Merge remote-tracking branch 'origin/next' into fix/1477-surface-source-marker
This commit is contained in:
5
.changeset/1580-999-sentinel-milestone-roadmap.md
Normal file
5
.changeset/1580-999-sentinel-milestone-roadmap.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1691
|
||||
---
|
||||
`milestone complete` and `roadmap analyze` now exclude the Phase 0 / Phase 999 backlog sentinels. A milestone whose only directory-less ROADMAP heading is a backlog sentinel can be completed without `--force`, and `roadmap analyze` no longer counts the sentinel in `phase_count` or routes `next_phase` into it. Completes the `^999` exclusion #1445 added to the progress denominators.
|
||||
5
.changeset/1733-windows-agent-skills-path-leak.md
Normal file
5
.changeset/1733-windows-agent-skills-path-leak.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1736
|
||||
---
|
||||
The `<agent_skills>` block emitted by `gsd init` no longer leaks backslash paths into `@`-reference skill paths on Windows. The global skill directory (a native `path.join` result) was interpolated into the generated markdown without POSIX normalization, producing references like `@C:\…\skills\name/SKILL.md`; the reference is now normalized at the emit site so skill references use forward slashes on every platform.
|
||||
7
.changeset/clever-cats-howl.md
Normal file
7
.changeset/clever-cats-howl.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1764
|
||||
---
|
||||
**Internal: agent install for cursor/windsurf/augment/trae/codebuddy now flows through the descriptor path** — ADR-1235 step 1 routes the trivial-converter runtime group's agents off the inline install() loop onto the descriptor-driven `installRuntimeArtifacts` path, applying the cross-cutting steps uniformly (pre-converter, no workflow-stamp). Agent output is byte-identical for all 16 runtimes (golden-parity asserted, global + local verified); no user-facing change.
|
||||
|
||||
<!-- docs-exempt: internal refactor, no user-facing surface -->
|
||||
5
.changeset/daring-otters-dart.md
Normal file
5
.changeset/daring-otters-dart.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1757
|
||||
---
|
||||
**Internal: getDirName is now derived from a documented `runtime.localConfigDir` descriptor field** — each runtime's local content-rewrite directory (e.g. `cursor`→`.cursor`, `copilot`→`.github`) moved from a hand-maintained if-chain into its capability descriptor (ADR-1239 Phase B), so it can no longer drift from the registry. Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing change.
|
||||
7
.changeset/eager-elks-frolic.md
Normal file
7
.changeset/eager-elks-frolic.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1759
|
||||
---
|
||||
**Internal: copyWithPathReplacement converter selection is now data-driven** — the installer's back-compat content-copy path replaced its 13 hardcoded `runtime === 'x'` flag chains with a single per-runtime dispatch table (ADR-1239 Phase B). Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing change.
|
||||
|
||||
<!-- docs-exempt: internal refactor, no user-facing surface -->
|
||||
5
.changeset/graceful-badgers-dance.md
Normal file
5
.changeset/graceful-badgers-dance.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1719
|
||||
---
|
||||
**#853 dispatch-flatten is now data-driven (ADR-1239 Phase B)** — whether GSD backgrounds the plan/execute orchestrator is decided from a documentation-sourced `backgroundDispatch` capability per host (via `gsd_run query dispatch-should-flatten`) instead of a hardcoded `runtime === 'codex'` check. **Cursor now backgrounds the orchestrator** (its docs document backgrounded subagent nesting); codex unchanged; all other hosts run inline. Fail-closed to inline on any uncertainty.
|
||||
5
.changeset/happy-birds-chatter.md
Normal file
5
.changeset/happy-birds-chatter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1755
|
||||
---
|
||||
**GSD now warns when a stale global CLI (e.g. a retired @gsd-build/sdk canary) shadows your project-local install** — the gsd-tools CLI startup detects when the running binary is outside the project root while a project-local install exists, and prints a remediation warning to stderr (non-blocking). (#1754)
|
||||
5
.changeset/humble-sloths-jump.md
Normal file
5
.changeset/humble-sloths-jump.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1742
|
||||
---
|
||||
**Windows install/upgrade/state-write operations no longer fail on transient antivirus/indexer file locks** — the fs.renameSync atomic-publish sites (install state, hooks config, capability ledger/lifecycle, phase/workstream/milestone dirs, roadmap, planning/state locks) now retry EPERM/EBUSY/EACCES via retryRenameSync instead of propagating the transient lock; enforced by the new local/require-fs-op-fallback lint rule (ADR-1703 Phase 6). (#1740)
|
||||
7
.changeset/kind-lynx-munch.md
Normal file
7
.changeset/kind-lynx-munch.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1728
|
||||
---
|
||||
**Internal: derive the non-Claude runtime list from the capability registry** — `NON_CLAUDE_RUNTIMES` is now computed from the capability registry instead of a hand-maintained literal, so it can no longer drift from the per-runtime descriptors. No user-visible behavior change (the list is identical).
|
||||
|
||||
<!-- docs-exempt: internal refactor, no user-facing surface -->
|
||||
5
.changeset/lucky-quails-greet.md
Normal file
5
.changeset/lucky-quails-greet.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1746
|
||||
---
|
||||
Windows: stop double-quoting $CLAUDE_PROJECT_DIR-anchored managed node hook paths during the #2979 legacy rewrite, which produced "\"$CLAUDE_PROJECT_DIR\"/..." and broke every node managed hook with MODULE_NOT_FOUND (PreToolUse-guard deadlock).
|
||||
5
.changeset/patient-otters-wave.md
Normal file
5
.changeset/patient-otters-wave.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1735
|
||||
---
|
||||
**Internal: extracted the runtime-artifact install engine from `bin/install.js`** — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` and their helpers now live in a dedicated `gsd-core/bin/lib/install-engine.cjs` module (ADR-1239 Phase B), so adapters can import the install pipeline instead of reaching into the 12k-line installer. Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing behaviour change.
|
||||
5
.changeset/tidy-tunas-click.md
Normal file
5
.changeset/tidy-tunas-click.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Security
|
||||
pr: 1725
|
||||
---
|
||||
**Installer writes are now confined to the declared config home** — the workflow/skill emit path (`copyWithPathReplacement`) and the Codex config writer (`installCodexConfig`) now reject any destination that escapes the install root: crafted or absolute paths, path-separator agent names, and pre-existing symlinks are refused before any delete or write. Fail-closed: an install write with no declared root is rejected rather than written unconfined.
|
||||
5
.changeset/vivid-seals-purr.md
Normal file
5
.changeset/vivid-seals-purr.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1690
|
||||
---
|
||||
**Host-Integration Interface (ADR-1239 Phase A)** — a versioned, negotiated capability contract (`runtime.hostIntegration`) over the six host-integration points (command, dispatch, model, hooks, state, artifact). Adds an in-process `negotiateHostCapabilities` handshake that fail-closes on undeclared/unknown/`undocumented` values (`effective ⊆ host-declared ∩ engine-known`), a typed degradation ladder, host-capability profiles, and a documentation-sourced per-CLI capability matrix for all 16 runtimes. Interface-definition only — no change to install behaviour.
|
||||
5
.changeset/zesty-rams-march.md
Normal file
5
.changeset/zesty-rams-march.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Security
|
||||
pr: 1706
|
||||
---
|
||||
**Install write-confinement (ADR-1239 Phase B)** — the installer now rejects any runtime-descriptor `destSubpath` that would write or delete outside the user's config home (path traversal, the config root itself, NUL bytes) and refuses to follow a pre-existing symlink that escapes it. Hardening only; no change to legitimate installs.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -67,6 +67,9 @@ build/
|
||||
# by `npm run build:lib`). Source of truth is src/; these are emitted, never edited.
|
||||
# Published via prepublishOnly; built before test via pretest. Grows as modules migrate.
|
||||
/tsconfig.build.tsbuildinfo
|
||||
/gsd-core/bin/lib/host-integration.cjs
|
||||
/gsd-core/bin/lib/install-engine.cjs
|
||||
/gsd-core/bin/lib/cli-skew-check.cjs
|
||||
/gsd-core/bin/lib/capability-loader.cjs
|
||||
/gsd-core/bin/lib/capability-source.cjs
|
||||
/gsd-core/bin/lib/capability-ledger.cjs
|
||||
|
||||
62
CONTEXT.md
62
CONTEXT.md
@@ -118,6 +118,12 @@ Module owning bounded, never-throw git repository introspection — the single s
|
||||
### Runtime Name Policy Module
|
||||
Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`.
|
||||
|
||||
### Host-Integration Interface
|
||||
Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with eight closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016.
|
||||
|
||||
### Install Engine Module
|
||||
Module owning the layout-driven runtime-artifact install pipeline — `installRuntimeArtifacts`, `uninstallRuntimeArtifacts`, `installOpencodeFamilySkills`, and their cluster helpers (`_copyStaged`, `_snapshotDir`/`_restoreDir`, legacy-migration + GSD-entry pruning, user-artifact preserve/restore). Extracted from the 12k-line `bin/install.js` (ADR-1239 Phase B, #1679) so adapters import the engine instead of reaching into the installer. Commit-attribution resolution stays in `bin/install.js` and is injected via a `resolveAttribution` parameter (the engine takes no config I/O). Source: `src/install-engine.cts` -> `gsd-core/bin/lib/install-engine.cjs`.
|
||||
|
||||
### Installer Migration Authoring Guard Module
|
||||
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
|
||||
|
||||
@@ -161,7 +167,7 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement
|
||||
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`). Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383).
|
||||
|
||||
### Runtime Artifact Install Plan Module
|
||||
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
|
||||
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
|
||||
|
||||
### Command Roster Module
|
||||
Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state.
|
||||
@@ -358,6 +364,31 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
|
||||
|
||||
---
|
||||
|
||||
## Probe family — spec-completeness probes (machine-oriented predicates)
|
||||
|
||||
> Glossary prose for these modules lives above (Probe Core / Edge Probe / Prohibition Probe / Verification Tier / Verification substrate). These are the greppable one-line predicates ADR-550's Consequences promised alongside the glossary. Research-derived numbers (N17/N18 rates) are deliberately kept out of this machine-canon and live hedged in `docs/design/verifier-reach.md`. (That design note and `docs/adr/1606` are co-delivered sibling PRs of epic #1605; predicate refs to them below resolve once the batch lands.)
|
||||
|
||||
`PROBE.principle=verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md`
|
||||
`PROBE.family=edge-probe(shape-axis)+prohibition-probe(must-NOT-axis), shared probe-core, run as spec-phase soft gates (ADR-550 D7)`
|
||||
`PROBE.protocol=recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason`
|
||||
`PROBE.core.seam=analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)`
|
||||
`PROBE.item.axes=status{resolved|dismissed|unresolved} x verification{<probe-defined>|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)`
|
||||
`PROBE.edge.verification=explicit|backstop`
|
||||
`PROBE.prohib.verification=test|judgment`
|
||||
`PROBE.ci.surface=the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)`
|
||||
`PROHIB.recall=LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)`
|
||||
`PROHIB.canon-referral=OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)`
|
||||
`PROHIB.enforce.green-rule=passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default`
|
||||
`PROHIB.enforce.kinds=node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)`
|
||||
`PROHIB.enforce.failfirst=MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)`
|
||||
`PROHIB.enforce.causation=opt-in clean-fixture control proves the red is content-caused not env-var-set (#1346); absent=documented residual`
|
||||
`PROHIB.descriptor.shape=5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)`
|
||||
`PROHIB.rail=core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability`
|
||||
`PROHIB.judgment-tier=never-silent / never-hard-halt soft gate; autonomous emits "unverified-prohibition — human review recommended" (exogenous grading, ADR-550 D4)`
|
||||
`PROHIB.enforce.adr=docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)`
|
||||
|
||||
---
|
||||
|
||||
## Test rules and lint
|
||||
|
||||
`RULESET.TESTS.no-source-grep=scripts/lint-no-source-grep.cjs rejects readFileSync source + .includes()/.match()/.startsWith() on the bound var; CI hard-fail`
|
||||
@@ -680,8 +711,8 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
|
||||
|
||||
`DEFECT.WINDOWS-FS-OPS.symptom=fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target`
|
||||
`DEFECT.WINDOWS-FS-OPS.examples=c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim`
|
||||
`DEFECT.WINDOWS-FS-OPS.detect=any rename/copy in build/install path without try/catch fallback`
|
||||
`DEFECT.WINDOWS-FS-OPS.fix-forward=catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow`
|
||||
`DEFECT.WINDOWS-FS-OPS.detect=ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape`
|
||||
`DEFECT.WINDOWS-FS-OPS.fix-forward=catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop`
|
||||
|
||||
`DEFECT.UNBOUNDED-SUBPROCESS.symptom=git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network`
|
||||
`DEFECT.UNBOUNDED-SUBPROCESS.examples=a33cbe72 worktree fix bound git subprocesses with timeout`
|
||||
@@ -720,36 +751,36 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples=#586/PR #650 ship.md verification gate — grep "^status:" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; the same broad-grep still lives in execute-phase.md (consolidation tracked by #651)`
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect=grep "^<key>:" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning <key>: is enough to break it`
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward=scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' "$f" | grep -m1 "^<key>:" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and trips windows-test-parity-guard (fenceRegexLiteralNewline); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples=#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward=match the fence with \r?\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file`
|
||||
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.symptom=local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.examples=PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; annotate // windows-portability-ok: <reason> when a bypass is intentional`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run lint:ci before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it`
|
||||
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom=a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === <that exact octal>; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples=#1634/PR #1638 tests/capability-lifecycle.test.cjs "a .cjs hook command is node-prefixed so it runs without the executable bit" failed windows-latest,24 on "precondition: file staged without +x" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect=grep tests for \`.mode & 0o777\` / \`.mode) === 0o\` / \`writeFileSync(...{ mode: 0o\` / \`chmodSync\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666)`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect=grep tests for \`.mode & 0o777\` / \`.mode) === 0o\` / \`writeFileSync(...{ mode: 0o\` / \`chmodSync\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward=gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention=ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (lint-windows-test-portability) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit`
|
||||
`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention=ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit`
|
||||
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom=path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected`
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples=PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\...\gsd-ial-windsurf-XXX\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only`
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect=any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization`
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect=any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\.md or /…\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)`
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward=normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always`
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention=RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\/g,'/'))`
|
||||
`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention=enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\/g,'/'))`
|
||||
|
||||
`RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional`
|
||||
`RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)`
|
||||
|
||||
`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom=an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual`
|
||||
`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples=PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side`
|
||||
`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect=any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/hardcoded/posix/path') is the compliant form`
|
||||
`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect=any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)`
|
||||
`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward=normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.`
|
||||
`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention=run npm run lint:ci (lint-windows-test-portability) before push — enhancement TBD to extend that lint to flag the literal-vs-pathFn assertion shape mechanically; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT`
|
||||
`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention=enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT`
|
||||
|
||||
`DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom=scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation`
|
||||
`DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples=PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control`
|
||||
@@ -810,6 +841,7 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.detect=Windows CI job at "Run unit tests" exits with code 1 within seconds of starting, no node:test output between "run-tests: suite=… files=N: …" line and "Process completed with exit code 1"; same job on Linux/macOS runs full duration`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward=chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor=tests/run-tests-harness.test.cjs "Windows argv-overflow chunking (issue #3597)" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform`
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW.prevention=a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)`
|
||||
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom=a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with "Cannot find module" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT`
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples=#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002`
|
||||
|
||||
@@ -817,6 +817,7 @@ The following checks run on every PR in addition to the test suite:
|
||||
| Job | What it checks | How to pass |
|
||||
|-----|----------------|-------------|
|
||||
| `Lint — ESLint` | No source-grep tests (see above), via the `local/no-source-grep` rule | Replace with `runGsdTools()` behavioral tests, or add `// allow-test-rule: <reason>` |
|
||||
| `Lint — cross-platform portability` | Windows-portability defects in tests, via `local/no-path-literal-in-assert` (more rules land per [ADR-1703](docs/adr/1703-portability-enforcement-architecture.md)) — e.g. a path-returning call asserted against a hardcoded `/`-literal | Normalize the actual: `String(pathFn(...)).replace(/\\/g, '/')`, or structure platform-specific code behind a `process.platform !== 'win32'` guard. **No `eslint-disable`** — see [cross-platform-portability-rules.md](docs/contributing/cross-platform-portability-rules.md) |
|
||||
|
||||
Run locally before pushing: `npm run lint` (or `npx eslint .`)
|
||||
|
||||
|
||||
1163
bin/install.js
1163
bin/install.js
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
],
|
||||
"probeExists": "gsd-core/VERSION"
|
||||
},
|
||||
"localConfigDir": ".agents",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -55,6 +56,16 @@
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "go"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"AUGMENT_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".augment",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -35,6 +36,14 @@
|
||||
"nesting": "nested",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToAugmentSkill"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToAugmentAgent"
|
||||
}
|
||||
],
|
||||
"local": [
|
||||
@@ -53,6 +62,14 @@
|
||||
"nesting": "nested",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToAugmentSkill"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToAugmentAgent"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -64,6 +81,16 @@
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "full", "backgroundDispatch": "undocumented" },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"CLAUDE_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".claude",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -61,6 +62,16 @@
|
||||
"Stop",
|
||||
"PreCompact",
|
||||
"FileChanged"
|
||||
]
|
||||
],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 5, "background": true, "subagentToolkit": "full", "backgroundDispatch": false },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"CLINE_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".cline",
|
||||
"configFormat": "markdown-dir",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -38,6 +39,16 @@
|
||||
"installSurface": "cline-rules",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "read-only", "backgroundDispatch": false },
|
||||
"modelMode": "active",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"CODEBUDDY_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".codebuddy",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -35,6 +36,14 @@
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCodebuddySkill"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToCodebuddyAgent"
|
||||
}
|
||||
],
|
||||
"local": [
|
||||
@@ -53,6 +62,14 @@
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCodebuddySkill"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToCodebuddyAgent"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -64,6 +81,16 @@
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"CODEX_HOME"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".codex",
|
||||
"configFormat": "toml",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -48,6 +49,16 @@
|
||||
"installSurface": "codex-toml",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": true },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"COPILOT_HOME"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".github",
|
||||
"configFormat": "markdown",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -48,6 +49,16 @@
|
||||
"installSurface": "copilot-instructions",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "undocumented"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"CURSOR_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".cursor",
|
||||
"configFormat": "none",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -35,6 +36,14 @@
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCursorCommand"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToCursorAgent"
|
||||
}
|
||||
],
|
||||
"local": [
|
||||
@@ -53,6 +62,14 @@
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToCursorCommand"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToCursorAgent"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -64,6 +81,16 @@
|
||||
"installSurface": "cursor-hooks-json",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": 2, "background": true, "subagentToolkit": "full", "backgroundDispatch": true },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"GEMINI_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".gemini",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -52,6 +53,16 @@
|
||||
"BeforeAgent",
|
||||
"AfterAgent",
|
||||
"BeforeModel"
|
||||
]
|
||||
],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-toml",
|
||||
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": "undocumented", "subagentToolkit": "undocumented", "backgroundDispatch": false },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"HERMES_HOME"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".hermes",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -48,6 +49,16 @@
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-programmatic",
|
||||
"dispatch": { "namedDispatch": false, "nested": true, "maxDepth": 1, "background": true, "subagentToolkit": "read-only", "backgroundDispatch": false },
|
||||
"modelMode": "active",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "python"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"env": []
|
||||
}
|
||||
},
|
||||
"localConfigDir": ".kilo",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -70,6 +71,16 @@
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": "kilo",
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": true, "maxDepth": -1, "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": false },
|
||||
"modelMode": "active",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "bun"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
],
|
||||
"probeExists": "skills"
|
||||
},
|
||||
"localConfigDir": ".kimi-code",
|
||||
"configFormat": "none",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -51,6 +52,16 @@
|
||||
"installSurface": "profile-marker-only",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": false },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "python"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"XDG_CONFIG_HOME"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".opencode",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -65,6 +66,16 @@
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": "opencode",
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": false, "subagentToolkit": "full", "backgroundDispatch": "undocumented" },
|
||||
"modelMode": "active",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "bun"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"QWEN_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".qwen",
|
||||
"configFormat": "settings-json",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -52,6 +53,16 @@
|
||||
"SubagentStop",
|
||||
"Stop",
|
||||
"PreCompact"
|
||||
]
|
||||
],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": true, "subagentToolkit": "full", "backgroundDispatch": false },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"TRAE_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".trae",
|
||||
"configFormat": "none",
|
||||
"artifactLayout": {
|
||||
"global": [
|
||||
@@ -27,6 +28,14 @@
|
||||
"nesting": "nested",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToTraeSkill"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToTraeAgent"
|
||||
}
|
||||
],
|
||||
"local": [
|
||||
@@ -37,6 +46,14 @@
|
||||
"nesting": "nested",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToTraeSkill"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToTraeAgent"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -47,6 +64,16 @@
|
||||
"installSurface": "profile-marker-only",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "imperative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": "undocumented", "maxDepth": "undocumented", "background": true, "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "engine",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,19 @@
|
||||
"WINDSURF_CONFIG_DIR"
|
||||
]
|
||||
},
|
||||
"localConfigDir": ".windsurf",
|
||||
"configFormat": "none",
|
||||
"artifactLayout": {
|
||||
"global": [],
|
||||
"global": [
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToWindsurfAgent"
|
||||
}
|
||||
],
|
||||
"local": [
|
||||
{
|
||||
"kind": "commands",
|
||||
@@ -29,6 +39,14 @@
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeCommandToWindsurfWorkflow"
|
||||
},
|
||||
{
|
||||
"kind": "agents",
|
||||
"destSubpath": "agents",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
"converter": "convertClaudeAgentToWindsurfAgent"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -39,6 +57,16 @@
|
||||
"installSurface": "profile-marker-only",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
"extendedHookEvents": [],
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": "undocumented", "nested": "undocumented", "maxDepth": "undocumented", "background": "undocumented", "subagentToolkit": "undocumented", "backgroundDispatch": "undocumented" },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "undocumented"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@
|
||||
"check-command-router.cjs",
|
||||
"cjs-command-router-adapter.cjs",
|
||||
"cli-exit.cjs",
|
||||
"cli-skew-check.cjs",
|
||||
"clock.cjs",
|
||||
"clusters.cjs",
|
||||
"code-review-flags.cjs",
|
||||
@@ -329,8 +330,10 @@
|
||||
"graphify-command-router.cjs",
|
||||
"graphify.cjs",
|
||||
"gsd2-import.cjs",
|
||||
"host-integration.cjs",
|
||||
"init-command-router.cjs",
|
||||
"init.cjs",
|
||||
"install-engine.cjs",
|
||||
"install-profiles.cjs",
|
||||
"installer-migration-authoring.cjs",
|
||||
"installer-migration-report.cjs",
|
||||
|
||||
@@ -439,8 +439,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` |
|
||||
| `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) |
|
||||
| `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` |
|
||||
| `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` |
|
||||
| `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` |
|
||||
| `init.cjs` | Compound context loading for each workflow type |
|
||||
| `install-engine.cjs` | Runtime-artifact install engine — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` + their helpers, extracted from `bin/install.js` (ADR-1239 Phase B, #1679); install.js imports them back and injects `getCommitAttribution` |
|
||||
| `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs |
|
||||
| `installer-migration-authoring.cjs` | Installer migration authoring guardrails for record metadata, explicit scopes, ownership evidence, and runtime contract citations |
|
||||
| `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration |
|
||||
|
||||
@@ -36,6 +36,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
- [Spike and sketch](how-to/spike-and-sketch.md) — use `/gsd-spike` and `/gsd-sketch` for exploratory work before committing to a plan
|
||||
- [Design a UI phase](how-to/design-a-ui-phase.md) — use the UI phase loop for frontend and visual work
|
||||
- [Develop a Capability for GSD 1.5+](how-to/develop-a-capability.md) — add feature Capabilities, hook fragments, and registry entries
|
||||
- [Add or update a host's integration](how-to/add-or-update-a-host-integration.md) — set a host's documentation-sourced `runtime.hostIntegration` axes (ADR-1239 Phase A), with the `undocumented` sentinel rule
|
||||
- [Turn a capability off (and keep it off)](how-to/turn-a-capability-off.md) — disable a capability via the surface, or gate individual hooks off without removing the capability
|
||||
- [Drive GSD from a tracker issue](how-to/drive-gsd-from-a-tracker-issue.md) — start a phase from a GitHub, Linear, or Jira issue
|
||||
- [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core
|
||||
|
||||
@@ -85,6 +85,20 @@ The **primitive vocabulary stays closed and first-party** (ADR-857 Decision 8):
|
||||
|
||||
Each phase is its own `approved-*` issue + PR with equivalence/parity proof.
|
||||
|
||||
### Amendment — Phase A implemented (#1684, v1.7.0)
|
||||
|
||||
Phase A is **implemented** (the ADR itself remains `Proposed` overall until Phases B–E land). The negotiated capability schema is materialized as a pure, additive, no-I/O module — the **Host-Integration Interface** (`src/host-integration.cts` → `gsd-core/bin/lib/host-integration.cjs`):
|
||||
|
||||
- **The eight negotiated axes** are carried under `capability.json` `runtime.hostIntegration` (extending, not replacing, the ADR-1016 axes), validated by `validateRuntimeBody` (`capability-validator.cjs`) across all 16 runtime descriptors, with the closed vocabulary kept in lock-step by a parity guard.
|
||||
- **`PROTOCOL_VERSION`** is an integer starting at `1`, **distinct** from the package `version` / `engines.gsd` semver (the `version`/`protocolVersion` overlap, resolved).
|
||||
- **`negotiateHostCapabilities(host, engine?)`** performs the in-process `initialize` exchange and enforces the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known`: an undeclared axis or an unknown / higher-`protocolVersion` value is **never** trusted — it degrades to the most-restrictive known value (fail-closed), never throws.
|
||||
- **`degradationFor`** is the typed Full/Degraded/Absent ladder table; **`profileOf` + `PROFILE_BASELINES`** classify each descriptor into `programmatic-cli` (9 hosts: claude, opencode, cursor, cline, hermes, qwen, kilo, trae, kimi), `declarative-cli` (7 hosts: codex, gemini, antigravity, augment, codebuddy, copilot, windsurf), or `ide` (defined as a baseline; no installed host yet — VS Code lands in Phase D).
|
||||
- **Overlap resolutions (explicit):** `commandStyle` (GSD emission style, retained) ⊥ `commandSurface` (host surface type); `hookEvents` dialect ⊥ `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); the `opencode-subset` `hookEvents` value remains reserved for the Phase D OpenCode hook-dialect consumer; `runtimeCompat` (feature→host) stays an independent override, orthogonal to these runtime→engine axes.
|
||||
|
||||
**Every per-host axis value is documentation-sourced, with citations.** Each of the 8 axes for all 16 installed CLIs was determined from that CLI's authoritative documentation (Context7 + the official dev docs/source), never inferred. The full per-CLI, per-axis matrix — value, source, and an evidence quote — is recorded in [`docs/reference/host-integration-capability-matrix.md`](reference/host-integration-capability-matrix.md), the deployment source-of-truth that Phases B–E build on. Where a CLI's docs genuinely do not state an axis, the descriptor carries the explicit `undocumented` sentinel (which `negotiateHostCapabilities` fail-closes on) rather than a guessed value — 22 such markers exist today, each with its search trail in the matrix. Two findings corrected this ADR's original appendix matrix: (1) current OpenAI **Codex** docs document slash-commands, so its `commandSurface` is `slash-file`, not `prose-only`; (2) several hosts run non-Node runtimes (opencode & kilo on **bun**; hermes & kimi on **python**; antigravity on **go**), so the `runtime` axis vocabulary was widened to `node|bun|sandboxed-web|python|go|rust|electron|other`. The documented `embeddingMode` split (9 imperative / 7 declarative, above) likewise reflects each CLI's real plugin/extension API, not a profile assumption.
|
||||
|
||||
No consumer wires the negotiated result yet — Phase A is interface-definition only; the engine↔host boundary (Phase B) and the adapters (Phase C) are where it is consumed.
|
||||
|
||||
## Host-capability profiles (negotiation baselines)
|
||||
|
||||
- **Programmatic-CLI** (Claude Code, pi, OpenCode): imperative; full dispatch; host hook bus; MCP; `slash` surface. The richest target — minimal degradation.
|
||||
|
||||
189
docs/adr/1606-prohibition-enforcement-verify-seam.md
Normal file
189
docs/adr/1606-prohibition-enforcement-verify-seam.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# ADR 1606: prohibition-enforcement verify-time seam [Proposed]
|
||||
|
||||
- **Status:** Proposed (consolidation ADR — see "Relationship to ADR-550")
|
||||
- **Date:** 2026-06-22
|
||||
|
||||
> **Provenance.** Drafted 2026-06-22 to promote a decision that accreted as **four**
|
||||
> chronological addendum blocks on ADR-550 (the 2026-06-12 test-tier disposition note that
|
||||
> folded in #1259, then #1279, #1346, and #1278) into a single, first-class architecture-of-
|
||||
> record for the verify-time enforcement subsystem. Authored by the #644/#1259 implementer.
|
||||
> The *area* (why prohibitions are first-class at all) traces to the author's
|
||||
> prohibition-elicitation (N18) and verifier-abstention (N17) findings — *the author's own
|
||||
> research*, cited as motivation, not claimed as a novel GSD contribution; the *enforcement
|
||||
> mechanism* in this ADR is motivated specifically by closing the caller-attestation fake-
|
||||
> green hole. Verified against `next` + `src/prohibition-enforcement.cts` (≈43KB) and
|
||||
> `gsd-core/references/prohibition-probe.md`.
|
||||
|
||||
## Relationship to ADR-550 (read this first)
|
||||
|
||||
ADR-550 is the **spec-phase probe contract**: what a prohibition *is*, how it is
|
||||
represented (`SPEC.md` acceptance criterion ↔ `must_haves.prohibitions:`), and how it is
|
||||
*tiered* (`test` vs `judgment`, Decisions 3–7). That ownership is unchanged.
|
||||
|
||||
This ADR carves out and consolidates the **verify-time enforcement mechanism** for the
|
||||
`test` tier — the `check prohibition-enforcement` producer in
|
||||
`src/prohibition-enforcement.cts` — which ADR-550 only documents as a chain of dated
|
||||
addenda. The boundary:
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|-------|
|
||||
| Prohibition representation, tiering, spec→plan projection | **ADR-550** (D3, D7) |
|
||||
| Judgment-tier soft-gate / "never a silent pass" policy | **ADR-550** (D4) |
|
||||
| Test-tier *enforcement producer* (locate → prove-fail-first → run → dispose) | **this ADR** |
|
||||
| Capability/core-rail placement of the verifier↔predicate contract | **ADR-857** *"Verification substrate vs. plug-in tier (the predicate boundary)"* (decision #6), referenced by both |
|
||||
|
||||
**Dedup proposal (decide at PR review):** on accepting this ADR, replace ADR-550's
|
||||
2026-06-12 / #1259 / #1279 / #1346 / #1278 enforcement addenda with a one-line pointer to
|
||||
this ADR, leaving 550 to own the contract and this ADR to own the mechanism. Until that is
|
||||
agreed, 550's addenda remain authoritative and this ADR is non-binding.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-550 D4 originally specified the `test` tier as a "hard gate in both interactive and
|
||||
autonomous modes" but left the *mechanism* unspecified. As the prohibition probe shipped
|
||||
(#644) and grew an enforcement producer (#1259→#1346), a set of load-bearing decisions had
|
||||
to be made that are not derivable from the contract alone:
|
||||
|
||||
- A generic producer cannot *trust* that a wired check actually fails on a violation. Caller
|
||||
attestation (`failFirst: true`) is unfalsifiable at verify time and was a fake-green hole.
|
||||
- A generic producer cannot *synthesize* a violation for an arbitrary check, so proving
|
||||
fail-first requires an author-supplied known-bad subject.
|
||||
- "The test went red" is not proof the *content* caused the red — an env-var-triggered or
|
||||
load-crash red forges a green.
|
||||
- The check descriptor must round-trip through the **flat** `parseMustHavesBlock` shared by
|
||||
`truths`/`artifacts`/`key_links` without a parser rewrite that would regress those readers.
|
||||
- A missing, partial, or un-provable check must **fail closed**, never silently pass — the
|
||||
whole point of the tier.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **No path greens on attestation alone.** A `test`-tier prohibition reaches
|
||||
`passed`/`green` **only** when its wired check (a) genuinely, **non-vacuously** runs and
|
||||
passes AND (b) is independently **machine-proven fail-first** against a known violation.
|
||||
The enforcement producer (`runProhibitionEnforcement` in
|
||||
`src/prohibition-enforcement.cts`) computes this as
|
||||
`passed = proof.provenFailFirst === true && run.passed === true` and only then emits
|
||||
non-empty `enforcementEvidence`. The disposition step
|
||||
(`dispositionForProhibition` in `src/probe-core.cts`) then greens a `test`-tier item
|
||||
**only** on that non-empty evidence; every other outcome — missing check, partial/invalid
|
||||
descriptor, can't-prove, throws, times out, no violation source, passes-on-violation,
|
||||
`located:false` — yields `{status:'unverified', flagged:true}` (`gaps_found`, never green)
|
||||
in both interactive and autonomous modes. (The green rule and the fail-closed default are
|
||||
intentionally split across the producer and the disposition so the disposition can fail
|
||||
closed even when the producer never ran.) This is the standing form of ADR-550 D4's
|
||||
guarantee.
|
||||
|
||||
2. **Two wired-check kinds, one producer.** The producer accepts exactly two mechanisms:
|
||||
- **`node-test`** — a `node --test` negative test that reports a real, **non-vacuous**
|
||||
failing test. Two distinct vacuity guards apply: `isNonVacuousNodeTestRed` rejects a
|
||||
load-crash red by requiring a failing test **named distinctly from the target file**;
|
||||
`isNonVacuousNodeTestPass` rejects the empty-file "0 tests = pass" forgery on the
|
||||
pass side. (Both live in `src/prohibition-enforcement.cts`.)
|
||||
- **`lint-rule`** — a lint/AST rule run through the project flat config as
|
||||
`eslint --format json` and filtered by `ruleId` (so `local/*` plugin rules load; a bare
|
||||
`--rule` cannot). Invoked via `process.execPath` against the resolved eslint CLI, not a
|
||||
bare `eslint` binary. Dogfooded on the in-tree `local/no-source-grep` rule.
|
||||
|
||||
3. **Machine-proven fail-first via an author-supplied violation fixture.** Before a clean
|
||||
pass can green, the producer runs the wired check against a **known-bad subject** and
|
||||
requires RED. The subject is sourced from `violationFixture`:
|
||||
- `lint-rule`: a file whose content violates `rule`; the rule id must appear in the JSON
|
||||
report (the rule must have teeth).
|
||||
- `node-test`: injected into the child as `GSD_PROHIB_SUBJECT=<violationFixture>`; the
|
||||
negative test reads that env var to locate its subject and must go RED against it.
|
||||
- **Absent fixture → fail closed** (never attestation). Existence is checked
|
||||
(`fs.existsSync(path.resolve(cwd, fixture))`) before spawning, symmetric with the
|
||||
lint-rule path which fail-closes on a `< 1`-file result.
|
||||
|
||||
4. **Causation control (opt-in).** A node-test red proves nothing about *why* it is red. An
|
||||
optional `cleanFixture` runs the same negative test a second time with
|
||||
`GSD_PROHIB_SUBJECT=<cleanFixture>` and requires **non-vacuous GREEN**
|
||||
(`isNonVacuousNodeTestPass`) — so fail-first is proven only when the check is **RED on the
|
||||
violation AND GREEN on the clean subject** (content-dependent red). Opt-in, not mandatory:
|
||||
absent `cleanFixture`, behaviour matches the pre-#1346 zero-authoring compose path and the
|
||||
"reds because the env var is set" case stays a documented residual for that one author's
|
||||
check. The lint-rule kind needs no analog (its subject *is* the linted file; no env-var
|
||||
indirection).
|
||||
|
||||
5. **Deterministic locate via five flat scalars — never a nested object.** The wired-check
|
||||
descriptor is authored at spec-phase and projected onto the `must_haves.prohibitions`
|
||||
item as **five flat scalar keys**: `check_kind`, `check_target`, `check_rule` (lint-rule
|
||||
only), `check_violation_fixture`, `check_clean_fixture` (optional). A nested `check: {}`
|
||||
object is **rejected**: `parseMustHavesBlock` is a flat parser and its
|
||||
`reconstructFrontmatter` serializer is lossy for nested object-lists, so a nested shape
|
||||
would mangle the round-trip and risk the shared `truths`/`artifacts`/`key_links` readers.
|
||||
`projectProhibitions` (in `src/probe-core.cts`) emits the scalars only for a well-formed
|
||||
descriptor; `descriptorFromProjection` (in `src/prohibition-enforcement.cts`) reads them
|
||||
back into the `{ kind, target, rule? }` `CheckDescriptor`. A prohibition authored with all
|
||||
five scalars machine-proves fail-first and greens **end-to-end through the projection with
|
||||
zero hand-authoring**.
|
||||
|
||||
6. **`failFirst` is demoted, not removed (FF-08).** The `CheckDescriptor.failFirst` field is
|
||||
kept for route-JSON backward-compat but **demoted to a non-authoritative hint** — the
|
||||
machine prover supersedes it. Removal was rejected (breaks the route-JSON shape mid-
|
||||
migration); demote-and-ignore satisfies "no path greens on attestation."
|
||||
|
||||
7. **The contract is the CI surface, not the LLM.** Per ADR-550 D5, CI deterministically
|
||||
tests parse/validate, the projection round-trip (fast-check property + CHK-03), the
|
||||
fail-closed guards (CHK-06), and backward-compat (CHK-07) — never the model's judgment.
|
||||
This ADR adds no CI claim over LLM behaviour.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** the verify-time enforcement subsystem has a single architecture-of-record
|
||||
instead of five addenda on a spec-phase ADR; the "never a silent pass" guarantee is stated
|
||||
once, completely, with its fail-closed defaults; the descriptor's flat-scalar shape and its
|
||||
rationale are findable without reading the frontmatter parser.
|
||||
- **Costs:** one more ADR to keep in sync with `src/prohibition-enforcement.cts`; the dedup
|
||||
against ADR-550's addenda must actually be executed at PR time or the repo carries two
|
||||
homes for the same decision (the explicit risk this ADR is meant to *remove*).
|
||||
- **Open conventions (renamable at review, zero live consumers):** `GSD_PROHIB_SUBJECT` and
|
||||
the `check_violation_fixture`/`check_clean_fixture` scalars have **no in-tree `node-test`
|
||||
consumer** yet (the only live dogfood is the lint-rule `local/no-source-grep`; node-test
|
||||
fail-first is exercised only by synthetic temp fixtures). A rename or an argv-for-env-var
|
||||
swap is a mechanical zero-migration find/replace — surfaced here for the maintainer to
|
||||
settle at review, exactly as #1278/#1279 were. (Hyrum's Law: this ADR deliberately marks
|
||||
them as not-yet-depended-on so they remain changeable; the *scalar key names emitted by
|
||||
`projectProhibitions` in shipped code* are, by contrast, already a contract.)
|
||||
- **Boundary held:** canon security/compliance is referred to `/gsd:secure-phase` + eslint,
|
||||
not minted here (ADR-550 D6); this ADR governs only bespoke product/values test-tier
|
||||
enforcement.
|
||||
|
||||
## Alternatives considered (rejected & deferred)
|
||||
|
||||
Enforcement-side alternatives, each with the standing reason and a re-open condition. *(The
|
||||
recall/representation/packaging-side alternatives — the withdrawn LLM classifier #652, a
|
||||
deterministic recall engine, a `polarity` field on `truths`, and the deferred dispatcher CLI —
|
||||
belong to the spec-phase contract and are recorded in ADR-550's "Alternatives considered.")*
|
||||
|
||||
- **A nested `check: {}` descriptor object — REJECTED.** `parseMustHavesBlock` is a flat
|
||||
parser and `reconstructFrontmatter` is lossy for nested object-lists, so a nested shape
|
||||
would mangle the round-trip and could regress the shared `truths`/`artifacts`/`key_links`
|
||||
readers. Hence the five **flat scalar** keys (Decision 5). *Re-open only if*
|
||||
`parseMustHavesBlock` is replaced with a structured parser (its own ADR, with the full
|
||||
shared-reader regression surface).
|
||||
- **An inline producer-written violation snippet — REJECTED.** To machine-prove fail-first the
|
||||
violation is an author-supplied **fixture path** (`check_violation_fixture`), not source the
|
||||
producer writes inline, which would bake rule-specific source into a generic producer
|
||||
(Decision 3).
|
||||
- **`failFirst` caller attestation as authoritative — REJECTED → DEMOTED (FF-08).** Trusting
|
||||
the caller's `failFirst: true` was an unfalsifiable fake-green hole; the machine prover
|
||||
supersedes it and the field is demoted to a non-authoritative hint kept only for route-JSON
|
||||
backward-compat (Decision 6). Outright removal was also rejected (breaks the route-JSON
|
||||
shape mid-migration).
|
||||
- **Mandatory causation control — REJECTED in favour of opt-in.** Requiring every node-test
|
||||
prohibition to ship a `cleanFixture` would regress the zero-authoring compose path and
|
||||
hard-gate every existing descriptor without one; the control is opt-in (Decision 4), leaving
|
||||
one documented residual rather than breaking working checks.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- **ADR-550** — spec-phase probe contract; this ADR consolidates its enforcement addenda, and
|
||||
ADR-550 holds the recall/representation/packaging-side rejected alternatives.
|
||||
- **ADR-857** — section *"Verification substrate vs. plug-in tier (the predicate boundary)"*
|
||||
(decision #6): the verifier↔predicate contract lands on the **core verify rail**
|
||||
(non-toggleable), never in `capabilities/`; this seam is its concrete enforcement instance.
|
||||
- **`gsd-core/references/prohibition-probe.md`** — the portable runtime reference.
|
||||
- **`docs/how-to/resolve-prohibition-findings.md`** — user-facing resolution guide.
|
||||
- Code: `src/prohibition-enforcement.cts`, `src/probe-core.cts` (`projectProhibitions`),
|
||||
`gsd-core/workflows/verify-phase.md`. Issues: #644, #1259, #1278, #1279, #1346.
|
||||
246
docs/adr/1703-portability-enforcement-architecture.md
Normal file
246
docs/adr/1703-portability-enforcement-architecture.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# ADR-1703: Cross-platform portability enforcement as AST ESLint rules
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-25 (Phase 0); **Accepted 2026-06-26** (Phase 7 closeout — all phases shipped)
|
||||
- **Issue:** [#1703](https://github.com/open-gsd/gsd-core/issues/1703) — Phase 0 of epic [#1702](https://github.com/open-gsd/gsd-core/issues/1702)
|
||||
- **Supersedes:** the regex-based `scripts/lint-windows-test-portability.cjs`, the
|
||||
`tests/windows-test-parity-guard.test.cjs` named-set ratchet (G1–G6), the
|
||||
`// windows-portability-ok:` comment convention, and `scripts/lib/allowlist-ratchet.cjs`
|
||||
usage for portability classes.
|
||||
|
||||
## Context
|
||||
|
||||
GSD must run correctly when installed and run on Windows (backslash paths, `C:\`,
|
||||
`cmd`/PowerShell, no `/bin/sh`, DOS file modes, `\r\n`), not just macOS/Linux. `CONTEXT.md`
|
||||
documents a `DEFECT.WINDOWS-*` taxonomy of failure shapes that recur and ship to the
|
||||
`windows-latest` CI lane undetected because the local `gsd-test` gate is Mac/Linux only.
|
||||
|
||||
Enforcement accreted as **three incompatible, hand-rolled mechanisms**:
|
||||
|
||||
1. **`scripts/lint-windows-test-portability.cjs`** — today a narrow regex *tripwire* for the
|
||||
chmod exec-bit + `sh`/`bash -c` shape, with a `windows-portability-ok` opt-out matched against
|
||||
the whole source. This epic was seeded (#1694) by an attempt to *extend* this script to the
|
||||
path-literal-in-assert shape; adversarial review of that extension found a regex that
|
||||
*silently could not match `deepStrictEqual`*, loose normalizer recognition (false negatives),
|
||||
and hand-rolled balanced-paren-splitting fragility — so the extension was **abandoned** in
|
||||
favour of this redesign. That abortive attempt is the concrete demonstration that growing the
|
||||
regex path is the wrong direction (Kernighan's Law, Greenspun's Tenth Rule); `CONTEXT.md`
|
||||
still records the path-literal lint as "enhancement TBD".
|
||||
2. **`tests/windows-test-parity-guard.test.cjs`** — a *ratchet*: a frozen `KNOWN_OFFENDERS`
|
||||
allowlist (G1–G6) that grandfathers existing violations and only blocks *new* ones. It
|
||||
institutionalizes the defects instead of removing them.
|
||||
3. **`// windows-portability-ok:`** — a bespoke comment opt-out matched by a whole-source regex,
|
||||
coarse enough that a single occurrence anywhere in a file can disable that file's check.
|
||||
|
||||
This is three parsers, two escape conventions, and a permanent grandfather list — to do a job
|
||||
that a linter does natively.
|
||||
|
||||
## Decision
|
||||
|
||||
Replace all three with a single coherent mechanism: **AST-based ESLint rules in the existing
|
||||
`local/*` plugin** (`eslint-rules/`, registered in `eslint.config.mjs`; ESLint v9 flat config,
|
||||
`RuleTester` available from `require('eslint')`). They use the parsers already in the stack:
|
||||
**Espree** (ESLint's default, `sourceType: 'commonjs'`) for the test-file `.cjs` rules, and
|
||||
**`@typescript-eslint/parser`** (already configured for `src/**/*.cts`) for the two production
|
||||
`.cts` rules. Specifically:
|
||||
|
||||
1. **AST, not regex.** Each portability check is an ESLint rule that matches real syntax nodes
|
||||
(`CallExpression`, `MemberExpression`, `Literal`, `TemplateLiteral`), not text. Rules run
|
||||
in-editor *and* in CI via the existing `eslint .` (invoked by `lint:ci` through `npm run
|
||||
lint`) — strictly more coverage than the CI-only `node scripts/lint-windows-test-portability.cjs`
|
||||
they replace.
|
||||
2. **Hard-fail, no ratchet, no grandfathering.** There is no `KNOWN_OFFENDERS` allowlist.
|
||||
Every existing and currently-grandfathered violation is **fixed**, not registered.
|
||||
3. **Zero escape hatches.** No per-line `eslint-disable` is permitted for portability rules
|
||||
(enforced — see "Strictness" below). Legitimately platform-specific code must be
|
||||
*structured* so the rule recognizes it (e.g. guarded by `process.platform !== 'win32'`),
|
||||
not annotated around.
|
||||
4. **Single source of truth.** Shared vocabulary (the `PATH_RETURNING_FNS` set, mode-bit
|
||||
octals, non-portable exec names) lives in one module `eslint-rules/lib/portability-vocab.cjs`,
|
||||
consumed by every rule and guarded against drift by an AST completeness check.
|
||||
5. **Tested with `RuleTester`.** Each rule ships an ESLint `RuleTester` suite of `valid`/
|
||||
`invalid` cases. Because `RuleTester` feeds fixtures to the rule directly (it does not scan
|
||||
the test file), the self-flagging problem that forced the whole-file opt-out simply does not
|
||||
exist — the opt-out hack is deleted, not reimplemented.
|
||||
|
||||
### Rule catalog (maps 1:1 to `DEFECT.WINDOWS-*`)
|
||||
|
||||
| Rule (`local/…`) | DEFECT (greppable in `CONTEXT.md`) | Surface |
|
||||
|---|---|---|
|
||||
| `no-path-literal-in-assert` | `DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT` | tests |
|
||||
| `no-posix-mode-bit-assert` | `DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT` | tests |
|
||||
| `no-unguarded-nonportable-exec` | `DEFECT.WINDOWS-TEST-PORTABILITY` (chmod+`sh -c`) + `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` | tests |
|
||||
| `no-crlf-fragile-split` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G1/G2/G3) + `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` | tests |
|
||||
| `no-hardcoded-tmp` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G4) | tests |
|
||||
| `no-bare-npm-exec` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G5) | tests |
|
||||
| `require-userprofile-with-home` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G6) | tests |
|
||||
| `normalize-path-in-content` | `DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` (`RULESET.CONTENT-PATH-NORMALIZATION`) | `src/**/*.cts` |
|
||||
| `require-fs-op-fallback` | `DEFECT.WINDOWS-FS-OPS` | `src/**/*.cts`, build/install |
|
||||
|
||||
**Taxonomy coverage.** This catalog addresses every `DEFECT.WINDOWS-*` class plus
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` in `CONTEXT.md`, to the extent each is *statically*
|
||||
detectable. `DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` has two parts: (a) the CRLF / literal-`\n`
|
||||
fence-match shape — covered by `no-crlf-fragile-split`; and (b) feeding a Windows `os.tmpdir()`
|
||||
path into a Git Bash glob / `bash -c` — covered jointly by `no-hardcoded-tmp` (steer tmp usage)
|
||||
and `no-unguarded-nonportable-exec` (require a platform guard on `bash -c`). The residual runtime
|
||||
Git-Bash path-translation behavior is not fully statically decidable; the rules catch the source
|
||||
shapes that produce it, not the runtime outcome. `DEFECT.WINDOWS-ARGV-OVERFLOW` is deliberately
|
||||
**not** in this catalog: it is a *runtime* argv-length property (the args-array size is not
|
||||
statically knowable — e.g. `execFileSync('node', [...N runtime paths])`), so no AST rule can
|
||||
soundly detect it. Phase 3 evaluated a `no-oversized-test-argv` heuristic and **dropped it as
|
||||
unsound** (it could only catch a contrived literal `.repeat(N)` command string, never the
|
||||
canonical array overflow). The class is addressed at the source: the production `run-tests.cjs`
|
||||
argv chunking under `RUN_TESTS_MAX_CMDLINE_CHARS`, with its anchor `tests/run-tests-harness.test.cjs`.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **`eslint-rules/<rule>.cjs`** — one file per rule, matching the existing `local/*` rule
|
||||
style. Each exports `{ meta, create }`.
|
||||
- **`eslint-rules/lib/portability-vocab.cjs`** — the single source of truth: `PATH_RETURNING_FNS`,
|
||||
mode-bit octal predicates, non-portable command names, normalizer-call recognizers.
|
||||
- **`eslint-rules/lib/platform-guard.cjs`** — shared AST helper answering "is this node
|
||||
*control-dependent* on a Windows platform condition?" (a dominator check, not a textual
|
||||
mention). It MUST recognize the guard shapes that actually occur in the suite:
|
||||
`process.platform !== 'win32'` / `=== 'win32'` (negated), `os.platform()`, a hoisted
|
||||
`const isWindows = …` consumed by a later `if (!isWindows)`, early-return guards, nested `if`
|
||||
blocks, and `node:test` skips (`t.skip()`, the `{ skip }` option / skip objects). The current
|
||||
regex lint is unsound here — it treats a bare `const isWindows = …` as "guarded" without
|
||||
requiring the dangerous call to be inside the branch; the AST helper fixes that by checking
|
||||
control dependence. This is the precision backbone that makes zero-escape-hatch viable
|
||||
(Postel's Law mitigation), and its correctness is the epic's primary risk: with no opt-out, an
|
||||
unrecognized legitimate shape is a CI-blocking false positive. Mitigation — `platform-guard`
|
||||
is `RuleTester`-tested against guard shapes harvested from the existing suite, and an
|
||||
unrecognized legitimate shape is fixed by teaching the helper, never by adding an opt-out.
|
||||
- **Drift guard** — a plain unit test (**not** `RuleTester`, which only feeds code *strings* to
|
||||
a rule and cannot read files or enumerate exports) parses `src/runtime-homes.cts` (and the
|
||||
relevant `bin/install.js` exports) with `@typescript-eslint/parser`, walks the AST to collect
|
||||
exported functions that return a filesystem path, and asserts each is present in
|
||||
`portability-vocab`'s `PATH_RETURNING_FNS` (or an explicit, reason-bearing ignore set). A new
|
||||
resolver that isn't registered fails CI.
|
||||
- **Wiring** — rules register in `eslint.config.mjs`'s `local` plugin and are set to `error`.
|
||||
No new `lint:ci` step; they ride the existing `eslint .` (which `lint:ci` runs via `npm run
|
||||
lint`). The **production** rules additionally require expanding the `eslint.config.mjs` file
|
||||
globs to cover `bin/install.js` and the build/install scripts — today the globs are
|
||||
`src/**/*.cts`, `gsd-core/bin/**/*.cjs`, `scripts/**/*.cjs`, and `tests/**/*.test.cjs`, so the
|
||||
top-level `bin/install.js` named by `DEFECT.WINDOWS-FS-OPS` is **not yet linted**; the glob
|
||||
expansion lands in the phase that ships `require-fs-op-fallback`.
|
||||
|
||||
### Strictness — enforcing zero escape hatches (Postel's Law)
|
||||
|
||||
Because there is no opt-out, two things must hold:
|
||||
|
||||
1. **Rules must be precise.** Every rule recognizes legitimate platform-gating via
|
||||
`platform-guard.cjs` and the canonical normalizer forms, so correctly-written
|
||||
platform-specific code is never flagged. A false positive is a rule bug, fixed in the rule.
|
||||
2. **The disable directive is itself banned for these rules.** Note `reportUnusedDisableDirectives`
|
||||
is **not** sufficient — it only flags directives that suppress *nothing*; a developer could
|
||||
write `// eslint-disable-next-line local/no-path-literal-in-assert` on a genuinely-violating
|
||||
line and the directive would count as "used" and pass. The ban is enforced by a dedicated
|
||||
guard: a small `local/no-portability-disable` meta-rule (matching `Program` comments) that
|
||||
**errors on any `eslint-disable[-next-line|-line]` directive referencing a
|
||||
`local/<portability-rule>`**. This is precise (only the portability rules are protected;
|
||||
every other rule keeps its normal inline-disable affordance), self-contained (no new
|
||||
dependency), and is itself unit-tested. `linterOptions.noInlineConfig: true` was rejected as
|
||||
the mechanism because it would ban *all* inline disables repo-wide, not just the portability
|
||||
rules.
|
||||
|
||||
## Applied software laws (engineering directive, Step 2.2)
|
||||
|
||||
- **Kernighan's Law / Greenspun's Tenth** — motivate the whole change: stop parsing a language
|
||||
with regex; use the real parser.
|
||||
- **Choose Boring Technology** — ESLint + `typescript-eslint` already present; no new tech.
|
||||
- **Gall's Law** — the migration is **incremental**: each phase adds one rule, fixes its
|
||||
violations, and removes only that class's hack. The old mechanisms keep running until their
|
||||
replacement lands. Full teardown is the *last* phase, not the first.
|
||||
- **Postel's Law** — zero escape hatches raises the precision bar; `platform-guard.cjs` is the
|
||||
required mitigation so the strict rules never reject legitimate code.
|
||||
- **Hyrum's Law** — removing `// windows-portability-ok:` breaks existing uses; every current
|
||||
occurrence is migrated (code restructured or the underlying violation fixed) in the phase
|
||||
that retires it. The vocab + rule semantics are documented here as the new contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** one mechanism; in-editor feedback; debuggable, unit-tested rules; no grandfather
|
||||
list; no bespoke comment parser; a documented, extensible architecture.
|
||||
|
||||
**Cost / risk:** fixing every grandfathered violation across the suite is a large, real diff
|
||||
(~15+ offender files for G1–G6 alone, plus the path-literal/mode-bit sets). Mitigated by
|
||||
phasing (one rule at a time, each independently reviewed and shipped) and by the rules being
|
||||
`error` from the moment they land so no new debt accrues.
|
||||
|
||||
**Migration is phased (Gall's Law):**
|
||||
|
||||
- **Phase 0** ADR (this) — the design record.
|
||||
- **Phase 1–3** `no-path-literal-in-assert`, `no-posix-mode-bit-assert`, `no-unguarded-nonportable-exec`,
|
||||
each landing with `portability-vocab.cjs` / `platform-guard.cjs` / the `RuleTester` harness as
|
||||
they are first needed.
|
||||
- **Phase 4** the G1–G6 rules + fix all grandfathered offenders + delete the ratchet test.
|
||||
- **Phase 5–6** production `normalize-path-in-content`, `require-fs-op-fallback`.
|
||||
- **Phase 7** teardown: delete the `windows-test-parity-guard` ratchet + `allowlist-ratchet` usage
|
||||
for these classes + sweep any residual `// windows-portability-ok:` comments; finalize the
|
||||
`CONTEXT.md` `DEFECT.WINDOWS-*` predicate rewrite; the forward architecture guide ("how to add a
|
||||
portability rule"). (The regex script `scripts/lint-windows-test-portability.cjs` was retired
|
||||
earlier — in Phase 3 — as its `no-unguarded-nonportable-exec` replacement landed.)
|
||||
|
||||
Each implementation phase runs the full engineering directive (rubber-duck → laws → architecture
|
||||
→ qa-test-architect → strict TDD via `RuleTester` → codex adversarial → Diátaxis → rebase+PR)
|
||||
and is its own approved child issue + PR under epic #1702.
|
||||
|
||||
## Phase 7 — as-built / acceptance (2026-06-26)
|
||||
|
||||
All seven phases shipped; the architecture is exercised in production and accepted. Two
|
||||
as-built deviations from the Phase 0 catalog, both within this ADR's precision discipline:
|
||||
|
||||
- **Phase 6 scope — `require-fs-op-fallback` narrowed to rename.** The catalog row named
|
||||
`DEFECT.WINDOWS-FS-OPS` for "`src/**/*.cts`, build/install". The defect's own `.fix-forward`
|
||||
defines the cure as *"catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry"* — so
|
||||
`copyFile`/`unlink` are the **fallback primitives**, not separate defect sites, and flagging
|
||||
them would flag the cure (`unlink` also has ~30 intentional best-effort cleanup sites that would
|
||||
be a FP minefield). v1 recognition is therefore `fs.rename`/`fs.renameSync` only, with the
|
||||
`RENAME_RETRY_ERRNOS` retry loop as the recognized compliant shape; `copyFile`/`unlink`
|
||||
transient-lock sub-classes are documented for a possible follow-up. The ADR-mandated glob
|
||||
expansion to `bin/install.js` + `scripts/build-hooks.js` (L124-126) landed as specified.
|
||||
Documented on [#1740](https://github.com/open-gsd/gsd-core/issues/1740).
|
||||
|
||||
- **Phase 6 precision tightening (codex review).** The rule's compliance shape was tightened after
|
||||
an adversarial gpt-5.5 review: a catch must BOTH reference a transient errno AND carry a retry
|
||||
signal (a loop `continue` backedge or a `return <call>` delegation — NOT a bare rethrow), and
|
||||
only the **nearest catching** try/catch counts (an outer errno-catch is unreachable once an inner
|
||||
catch intercepts). This enforces the defect's *"never silently swallow"* + cure-is-retry clauses
|
||||
honestly. See [`eslint-rules/require-fs-op-fallback.cjs`](../../eslint-rules/require-fs-op-fallback.cjs).
|
||||
|
||||
The forward "how to add a portability rule" recipe delivered by this phase lives at
|
||||
[`docs/contributing/adding-a-portability-rule.md`](../contributing/adding-a-portability-rule.md).
|
||||
|
||||
Two further as-built deviations from the Phase 0 text, reconciled in the post-merge
|
||||
coverage audit (#1749):
|
||||
|
||||
- **Disable-ban mechanism — meta-rule → out-of-band test.** §"Strictness" specified a
|
||||
`local/no-portability-disable` ESLint meta-rule to ban inline disables of portability
|
||||
rules. What shipped is [`tests/portability-rule-disable-ban.test.cjs`](../../tests/portability-rule-disable-ban.test.cjs)
|
||||
— a `node:test` that scans files for disable directives **outside ESLint**, so it cannot
|
||||
itself be eslint-disabled (an advantage over an in-process meta-rule, which the ADR noted
|
||||
as the motivating risk). The substitution is at least as strong; recorded here so the
|
||||
ADR's written mechanism matches the as-built one.
|
||||
|
||||
- **Drift-guard `bin/install.js` scope.** §"Architecture" said the drift guard parses
|
||||
`src/runtime-homes.cts` *and the relevant `bin/install.js` exports*. The shipped
|
||||
[`tests/portability-vocab-drift.test.cjs`](../../tests/portability-vocab-drift.test.cjs)
|
||||
originally covered only `runtime-homes.cts`; the audit extended it to `bin/install.js`
|
||||
with a SOUND shape only (a top-level function that directly `return path.*(...)` must be
|
||||
registered; plus a curated two-way existence lock on the installer path helpers). The
|
||||
looser body-contains heuristic used for `runtime-homes.cts` is unsound for the generated
|
||||
12k-line installer (~33 false positives), so a new installer resolver that builds a path
|
||||
via a temp variable relies on review — documented as a boundary in the test. The active
|
||||
resolver module (`runtime-homes.cts`) remains fully drift-guarded by the looser heuristic.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **Keep extending the regex lint.** Rejected — the adversarial review proved it is
|
||||
structurally fragile; every extension adds parser surface and bugs.
|
||||
2. **Keep the ratchet, just add rules.** Rejected — grandfathering is the thing being removed;
|
||||
the maintainer's directive is rip-and-replace, not legacy preservation.
|
||||
3. **Keep `// windows-portability-ok:` as an escape hatch.** Rejected — zero escape hatches
|
||||
chosen; precision via `platform-guard.cjs` replaces the need for an opt-out.
|
||||
4. **A standalone custom AST tool (not ESLint).** Rejected — Greenspun/Choose-Boring: ESLint is
|
||||
the boring, in-stack, in-editor linter; building a parallel tool repeats the original mistake.
|
||||
@@ -142,3 +142,40 @@ This ratifies the **deterministic SOURCE** for the test-tier `CheckDescriptor` t
|
||||
5. **Out of scope (unchanged boundaries).** Machine-proven fail-first (a violation-fixture / RuleTester-invalid proof replacing the `failFirst` caller attestation) stays tracked as **#1279**. The `dispositionForProhibition` green/fail-closed **policy** is untouched. No new check kinds are added.
|
||||
|
||||
Net effect on D3: the prohibition-item shape is extended with three optional, backward-compatible flat-scalar keys that give the test-tier locate a deterministic spec-phase source; the contract's CI-testable surface (D5) gains the projection round-trip parity (CHK-03), the fail-closed guard (CHK-06), and the byte-stable backward-compat fixture (CHK-07). The decision also lives in `src/probe-core.cts` / `src/prohibition-enforcement.cts` comments, the `verify-phase.md` / `spec-phase.md` prose, and the #1278 changeset.
|
||||
|
||||
## Addendum (2026-06-22) — Alternatives considered (recall / representation / packaging side)
|
||||
|
||||
This consolidates the spec-phase-side rejected and deferred alternatives for the probe family,
|
||||
so a re-proposal meets a recorded reason rather than a fresh debate. (The enforcement-mechanism
|
||||
alternatives — the flat-vs-nested descriptor, the inline violation snippet, `failFirst`
|
||||
attestation, and mandatory causation control — are recorded in **ADR-1606**, the
|
||||
prohibition-enforcement verify-time seam.)
|
||||
|
||||
- **A standalone LLM requirement *classifier* as a feature — REJECTED (#652, closed
|
||||
2026-06-04).** The enhancement "requirement classification in the spec phase should use an
|
||||
LLM-assisted classifier" was closed without approval: the edge-probe's shape taxonomy plus
|
||||
the prohibition probe's adversarial recall **already capture most of the value** a general
|
||||
classifier would, without adding a separate model-dependent surface to maintain. *Re-open
|
||||
only if* a classifier demonstrably beats both probes on a held-out battery.
|
||||
|
||||
- **A deterministic `prohibition-probe.cjs` recall engine — REJECTED.** Unlike the closed edge
|
||||
taxonomy, the prohibition recall stage is inherently LLM prose reasoning; only the
|
||||
schema/projection layer is real code (Decision 7b). A deterministic recall adapter is the
|
||||
scope-creep flagged in `gsd-core/references/prohibition-probe.md`; recall is validated
|
||||
offline (N18), not asserted in CI. *Re-open only if* recall can be made deterministic without
|
||||
collapsing the adversarial open-question that gives it model-robust reach.
|
||||
|
||||
- **A `polarity` field on `truths` — REJECTED (already decided — see Decision 3).** `truths`
|
||||
are positive observables with no `verify.cjs` handler; a prohibition parked there inherits
|
||||
non-enforcement. Recorded in Decision 3 ("`truths` is left untouched — no `polarity` field is
|
||||
added"); listed here only so the alternatives set is readable in one place.
|
||||
|
||||
- **A single dispatcher CLI for all probes — DEFERRED (already decided — see Decision 7e).**
|
||||
Each probe ships its own bin calling a shared `runProbeCli(...)`; a unified dispatcher is
|
||||
deferred as pure invocation plumbing with no migration debt. Recorded in Decision 7e; listed
|
||||
here only for completeness.
|
||||
|
||||
*Net:* the two NEW entries (#652 classifier, deterministic recall engine) are the only ones
|
||||
this addendum adds to 550's decision record; the other two are cross-references to existing
|
||||
decisions, collected so the probe family's full "alternatives considered" set is readable in
|
||||
one place.
|
||||
|
||||
150
docs/contributing/adding-a-portability-rule.md
Normal file
150
docs/contributing/adding-a-portability-rule.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Adding a cross-platform portability lint rule
|
||||
|
||||
GSD must run correctly on Windows as well as macOS/Linux. The `DEFECT.WINDOWS-*`
|
||||
taxonomy in [`CONTEXT.md`](../../CONTEXT.md) names the recurring failure shapes; a family of
|
||||
AST-based ESLint rules (the `local/*` plugin) enforces them **at write-time (in your editor)
|
||||
and in CI**, so a Windows-only defect is caught before it ships — not after it reaches the
|
||||
`windows-latest` CI lane.
|
||||
|
||||
This page is the **forward recipe**: how to add a new rule when you identify a portability
|
||||
defect class that isn't yet mechanically enforced. The architecture and rationale live in
|
||||
[ADR-1703](../adr/1703-portability-enforcement-architecture.md); the per-rule reference and
|
||||
fix how-tos live in [`cross-platform-portability-rules.md`](./cross-platform-portability-rules.md).
|
||||
|
||||
> **Diátaxis note:** this is an *Explanation* — it describes the architecture and the reasoning
|
||||
> behind the seams, so the recipe at the end makes sense. For "how do I fix a violation I got",
|
||||
> see the per-rule how-tos in the reference page.
|
||||
|
||||
## Why AST rules, not regex
|
||||
|
||||
The original enforcement was a regex scanner with a hand-rolled balanced-paren parser, a frozen
|
||||
`KNOWN_OFFENDERS` ratchet, and a bespoke `// windows-portability-ok:` comment opt-out. Adversarial
|
||||
review of an attempt to *extend* the regex found it silently could not match `deepStrictEqual`,
|
||||
had loose normalizer recognition, and hand-rolled paren-splitting fragility (Kernighan's Law /
|
||||
Greenspun's Tenth — parsing a language with regex). The rip-and-replace decision: **one mechanism,
|
||||
AST-based ESLint rules** using the parsers already in the stack, hard-fail with zero escape
|
||||
hatches, no ratchet/grandfathering. Full rationale: ADR-1703 "Alternatives considered".
|
||||
|
||||
## The five seams (and where each lives)
|
||||
|
||||
Every portability rule composes the same five seams. Adding a rule means touching each one.
|
||||
|
||||
### 1. The rule — `eslint-rules/<rule-name>.cjs`
|
||||
|
||||
One file per rule, exporting `{ meta, create }`. Matches real syntax nodes (`CallExpression`,
|
||||
`MemberExpression`, `Literal`, `TemplateLiteral`, `BinaryExpression`, `TryStatement`, …), **not**
|
||||
text. Each rule runs in-editor *and* in CI via the existing `eslint .` (invoked by `lint:ci`).
|
||||
|
||||
The two shapes that recur:
|
||||
- **Test-side rules** (surface `tests/**/*.test.cjs`) — flag a non-portable *assertion* or *test
|
||||
fixture* shape (path-literal-in-assert, posix-mode-bit-assert, unguarded exec, CRLF split,
|
||||
hardcoded `/tmp`, bare npm, HOME-without-USERPROFILE).
|
||||
- **Production rules** (surface `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js`) — flag
|
||||
a non-portable *production* shape (path-leak-in-content, unguarded fs-rename).
|
||||
|
||||
### 2. The shared vocabulary — `eslint-rules/lib/portability-vocab.cjs`
|
||||
|
||||
The single source of truth for path-related portability: `PATH_RETURNING_FNS` (Node builtins +
|
||||
project resolvers), the POSIX-normalizer recognizers (`.replace(/\\/g,'/')`, `toPosixPath`, …),
|
||||
and string-unwrap helpers. A new path resolver added to `src/runtime-homes.cts` MUST be registered
|
||||
here — the drift-guard test (`tests/portability-vocab-drift.test.cjs`) parses that source and
|
||||
**fails CI if a path-returning export is missing** from `PATH_RETURNING_FNS`.
|
||||
|
||||
### 3. The platform guard — `eslint-rules/lib/platform-guard.cjs`
|
||||
|
||||
The precision backbone. `isWindowsExcludedNode(node, sourceCode)` answers "is this node
|
||||
control-dependent on a Windows platform condition?" via a **dominator check, not a textual mention**:
|
||||
`if (process.platform !== 'win32') { … }`, early-return guards (`if (process.platform === 'win32') return;`),
|
||||
`os.platform()`, and hoisted binding-aware booleans (`const isWindows = …` consumed by
|
||||
`if (!isWindows)`, with reassignment detection). This is what makes **zero escape hatches** viable:
|
||||
legitimately POSIX-only code is *structured* behind a recognized guard, never annotated around
|
||||
(Postel's Law mitigation). If a legitimate shape isn't recognized, **teach the helper** — never add
|
||||
an opt-out.
|
||||
|
||||
### 4. The disable ban — `tests/portability-rule-disable-ban.test.cjs`
|
||||
|
||||
Because there is no opt-out, an `eslint-disable` of a portability rule would silently bypass it.
|
||||
This test runs **outside ESLint** (so it cannot itself be eslint-disabled) and fails the build on
|
||||
any `eslint-disable[-next-line|-line]` that names a protected portability rule, or any blanket
|
||||
disable. **Every new rule MUST be appended to `PROTECTED_RULES`** here, and if the rule covers a
|
||||
new surface (e.g. `bin/install.js`), that surface MUST be added to `collectTestFiles()`.
|
||||
|
||||
### 5. CI test selection — `scripts/ci-test-scope.cjs`
|
||||
|
||||
The `portability lint rules (ADR-1703)` rule selects the rule suites + disable-ban when
|
||||
`eslint-rules/`, `eslint.config.mjs`, or a covered production surface changes. Add new test files
|
||||
to its `tests:` list.
|
||||
|
||||
## The zero-escape-hatch contract
|
||||
|
||||
Two things must hold, and they are the epic's primary risk:
|
||||
|
||||
1. **Rules must be precise.** Every rule recognizes legitimate platform-gating via
|
||||
`platform-guard.cjs` and the canonical compliant shapes, so correctly-written platform-specific
|
||||
code is never flagged. A false positive is a rule bug, fixed in the rule — never by adding an
|
||||
opt-out.
|
||||
2. **Recognition must mean the cure, not just the symptom.** When a rule's compliance shape is "the
|
||||
catch handles the transient errno", the handler must actually *retry/fallback* (a loop `continue`
|
||||
backedge or a `return <call>` delegation), not merely *reference* the errno and rethrow. The
|
||||
`require-fs-op-fallback` rule encodes this (codex-review-tightened): the defect's cure is retry,
|
||||
not just recognition.
|
||||
|
||||
An unrecognized legitimate shape is fixed by teaching the helper/rule, never by annotation. This is
|
||||
the discipline that keeps the rules honest as the codebase grows.
|
||||
|
||||
## Recipe — add a new `local/*` portability rule
|
||||
|
||||
Run the full engineering directive (rubber-duck → software laws → architecture → qa-test-architect
|
||||
→ strict TDD via `RuleTester` → adversarial review → Diátaxis → rebase+PR). Concretely:
|
||||
|
||||
1. **Classify the defect.** Confirm it's a real `DEFECT.WINDOWS-*` shape (or a new class worth a
|
||||
predicate in `CONTEXT.md`). Decide the *sound* statically-detectable scope — narrow or document
|
||||
rather than ship FP-prone (Phase 5/6 each narrowed scope and documented the boundary).
|
||||
2. **Write the rule** — `eslint-rules/<rule-name>.cjs` (`{ meta, create }`, `type: 'problem'`,
|
||||
message cites the `DEFECT.*` predicate). Reuse `portability-vocab.cjs` / `platform-guard.cjs`.
|
||||
3. **TDD via `RuleTester`** — `tests/<rule-name>.rule.test.cjs`. Cover: the violation shape(s),
|
||||
every recognized compliant shape (platform guard, normalizer, retry signal, …), and the
|
||||
anti-patterns that must NOT satisfy compliance (silent-swallow catch, rethrow-only, unrelated
|
||||
errno). Use both espree (`.cjs`) and `@typescript-eslint/parser` (`.cts`) where the rule spans
|
||||
both. Tests are written FIRST and must fail before the rule exists, then pass.
|
||||
4. **Register + scope** — in `eslint.config.mjs`: add the rule to the `local` plugin's `rules` map
|
||||
and enable at `'error'` in the matching file-glob block. If the rule covers a new surface (e.g.
|
||||
`bin/install.js`), add a config block for it — apply ONLY the portability rules to generated
|
||||
code, not the full recommended set.
|
||||
5. **Disable ban** — append the rule name to `PROTECTED_RULES` in
|
||||
`tests/portability-rule-disable-ban.test.cjs`; add any new surface to `collectTestFiles()`.
|
||||
6. **CI selection** — add the new test file to the `portability lint rules (ADR-1703)` rule in
|
||||
`scripts/ci-test-scope.cjs`.
|
||||
7. **Fix every violation** — no ratchet, no grandfathering. Every existing + grandfathered offender
|
||||
is fixed in the same phase (route through a shared helper, add a guard, or normalize).
|
||||
8. **Docs** — add the rule to the reference table + a fix how-to in
|
||||
`cross-platform-portability-rules.md`; rewrite the `DEFECT.*` predicate's `detect=`/`fix-forward=`
|
||||
in `CONTEXT.md` to point at the rule; record known boundaries honestly.
|
||||
9. **Verify** — `npm run lint:ci` green; the touched modules' tests green; the `windows-latest` CI
|
||||
lane is the only true Windows signal.
|
||||
|
||||
## Catalog (shipped)
|
||||
|
||||
| Rule | DEFECT | Surface | Phase |
|
||||
|---|---|---|---|
|
||||
| `no-path-literal-in-assert` | `WINDOWS-PATH-LITERAL-IN-ASSERT` | tests | 1 |
|
||||
| `no-posix-mode-bit-assert` | `WINDOWS-POSIX-MODE-BIT-ASSERT` | tests | 2 |
|
||||
| `no-unguarded-nonportable-exec` | `WINDOWS-TEST-PORTABILITY` (chmod+`sh -c`) | tests | 3 |
|
||||
| `no-crlf-fragile-split` | `WINDOWS-TEST-PORTABILITY` (G1–G3) | tests | 4 |
|
||||
| `no-hardcoded-tmp` | `WINDOWS-TEST-PORTABILITY` (G4) | tests | 4 |
|
||||
| `no-bare-npm-exec` | `WINDOWS-TEST-PORTABILITY` (G5) | tests | 4 |
|
||||
| `require-userprofile-with-home` | `WINDOWS-TEST-PORTABILITY` (G6) | tests | 4 |
|
||||
| `normalize-path-in-content` | `WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` | `src/**/*.cts` | 5 |
|
||||
| `require-fs-op-fallback` | `WINDOWS-FS-OPS` | `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js` | 6 |
|
||||
|
||||
`DEFECT.WINDOWS-ARGV-OVERFLOW` is deliberately **not** in this catalog: argv length is a runtime
|
||||
property (the args-array size is not statically knowable), so no AST rule can soundly detect it.
|
||||
It is addressed at the source (`run-tests.cjs` chunking under `RUN_TESTS_MAX_CMDLINE_CHARS`).
|
||||
|
||||
## Teardown (complete)
|
||||
|
||||
The legacy machinery this architecture replaced is fully retired: the regex scanner
|
||||
`scripts/lint-windows-test-portability.cjs` (Phase 3), the `tests/windows-test-parity-guard.test.cjs`
|
||||
ratchet (Phase 4), `allowlist-ratchet.cjs` usage for portability classes (the module remains for
|
||||
unrelated size-budget lints), and the `// windows-portability-ok:` comment convention (swept — zero
|
||||
remain). Every `DEFECT.WINDOWS-*` predicate in `CONTEXT.md` now points at its enforcing rule.
|
||||
309
docs/contributing/cross-platform-portability-rules.md
Normal file
309
docs/contributing/cross-platform-portability-rules.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# Cross-platform portability lint rules
|
||||
|
||||
GSD must run on Windows as well as macOS/Linux. A family of AST-based ESLint rules (the
|
||||
`local/*` plugin) enforces the `DEFECT.WINDOWS-*` portability classes documented in
|
||||
[`CONTEXT.md`](../../CONTEXT.md) **at write-time (in your editor) and in CI**, so a
|
||||
Windows-only defect is caught before it ships — not after it reaches the `windows-latest` CI
|
||||
lane. The architecture and rationale are in [ADR-1703](../adr/1703-portability-enforcement-architecture.md);
|
||||
this page is the practical reference + how-to.
|
||||
|
||||
> **Adding a new rule?** See [`adding-a-portability-rule.md`](./adding-a-portability-rule.md) —
|
||||
> the five seams (rule / vocab / platform-guard / disable-ban / ci-scope), the zero-escape-hatch
|
||||
> contract, and the step-by-step recipe.
|
||||
|
||||
These rules are **hard-fail with zero escape hatches**: there is no `// windows-portability-ok:`
|
||||
comment and no `eslint-disable` for them (a `tests/portability-rule-disable-ban.test.cjs` check,
|
||||
running outside ESLint, fails the build if you try). Legitimately platform-specific code must be
|
||||
*structured* so the rule recognizes it (see "Platform guards" below) — not annotated around.
|
||||
|
||||
## Reference — the rules
|
||||
|
||||
| Rule | Flags | Surface |
|
||||
|---|---|---|
|
||||
| `local/no-path-literal-in-assert` | An `assert.equal`/`strictEqual`/`deepEqual`/`deepStrictEqual` or `expect(...).toBe`/`toEqual`/`toStrictEqual` where one operand is a **path-returning function call** and the other is a **hardcoded `/`-string literal** not normalized to POSIX. | `tests/**/*.test.cjs` |
|
||||
| `local/no-posix-mode-bit-assert` | An equality assertion comparing a file **`.mode`** (e.g. `statSync(p).mode & 0o777`) to an **octal literal** — Windows reports `0o666`/`0o444`, never the requested mode. | `tests/**/*.test.cjs` |
|
||||
| `local/no-unguarded-nonportable-exec` | A file that **both** sets a chmod exec-bit (`chmod`/`chmodSync` with `0oNNN & 0o111 !== 0`) **and** invokes `sh`/`bash` with a `-c` flag (`execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync`) without a Windows platform guard — Windows Git Bash ignores the exec bit for extension-less PATH-executed scripts. | `tests/**/*.test.cjs` |
|
||||
| `local/no-crlf-fragile-split` | A `.split('\n')` or `.split("\n")` call on `readFileSync` content, **or** a regex literal containing a bare `\n` used against `readFileSync` content — Windows `git-autocrlf` yields `\r\n` line endings so a literal `\n` split or regex will mismatch. | `tests/**/*.test.cjs` |
|
||||
| `local/no-hardcoded-tmp` | A hardcoded `/tmp/` string passed as the first argument to an `fs.*` function or `path.join` — `/tmp` does not exist on Windows. Use `os.tmpdir()` instead. | `tests/**/*.test.cjs` |
|
||||
| `local/no-bare-npm-exec` | An `execFileSync`/`spawnSync`/`spawn` call with `"npm"` as the command and no `{ shell: true }` option (or a platform-guarded equivalent) — `npm` is a `.cmd` batch wrapper on Windows and is not found without a shell. (`execSync`/`exec` already run via a shell, so they are not flagged.) | `tests/**/*.test.cjs` |
|
||||
| `local/require-userprofile-with-home` | A `process.env.HOME = <x>` assignment in a test file with no corresponding `process.env.USERPROFILE` **assignment** — Windows uses `USERPROFILE` as the home directory environment variable, not `HOME`. | `tests/**/*.test.cjs` |
|
||||
| `local/normalize-path-in-content` | A path-returning fn result (excluding `path.basename`, which returns a separator-less filename) interpolated **directly** into content without `.replace(/\\/g,'/')` normalization — backslash paths leak into generated content on Windows (`RULESET.CONTENT-PATH-NORMALIZATION`). Two content shapes are detected: (a) the template/string contains an `@`-reference marker (`@~/`, `@$`, `@/`), `$HOME`, or `~/`; (b) the quasi immediately following the interpolation starts with `/…\.md` or `/…\.json`. **Indirect data-flow** (path stored in a variable/field then interpolated) is not detected — normalize at source. Fix: `String(resolvedTarget).replace(/\\/g, '/')`. | `src/**/*.cts` |
|
||||
| `local/require-fs-op-fallback` | An unguarded `fs.rename` / `fs.renameSync` (the atomic-publish primitive) that is NOT inside a `try`/`catch` whose handler references a transient errno (`'EPERM'`/`'EBUSY'`/`'EACCES'`, or a `*RETRY_ERRNOS` set) AND is NOT behind a Windows platform guard — on Windows a concurrent reader / antivirus scanner can transiently hold the target open and throw. A `catch (e) {}` that silently swallows, or a catch that cleans-up-and-rethrows without an errno check, does **not** satisfy the rule. `fs.copyFile` / `fs.unlink` are deliberately **not** flagged (they are the *fallback primitives* named by the defect's own fix-forward, and `unlink` has many intentional best-effort cleanup sites). | `src/**/*.cts`, `bin/install.js`, `scripts/build-hooks.js` |
|
||||
|
||||
(See ADR-1703's catalog and [epic #1702](https://github.com/open-gsd/gsd-core/issues/1702) for the full phase history.)
|
||||
|
||||
The set of path-returning functions is single-sourced in
|
||||
[`eslint-rules/lib/portability-vocab.cjs`](../../eslint-rules/lib/portability-vocab.cjs) as
|
||||
`PATH_RETURNING_FNS` (Node's `path.*`/`os.homedir`/`os.tmpdir` plus the project resolvers such as
|
||||
`getGlobalConfigDir`, `resolveAgentDir`, `computePathPrefix`, …). A drift-guard test
|
||||
(`tests/portability-vocab-drift.test.cjs`) parses `src/runtime-homes.cts` and **fails CI if a new
|
||||
path resolver is added but not registered** in that list.
|
||||
|
||||
## How-to — fix a `no-path-literal-in-assert` violation
|
||||
|
||||
Why it fails on Windows: `path.join('a','b')` returns `a/b` on POSIX but `a\b` on Windows, so
|
||||
`assert.equal(path.join('a','b'), '/a/b')` passes on your Mac/Linux machine and the docker gate,
|
||||
then fails only on the `windows-latest` lane.
|
||||
|
||||
**Fix: normalize the ACTUAL operand to POSIX before comparing** — this is idempotent on POSIX
|
||||
(a no-op when there are no backslashes) and *reveals* a malformed return rather than masking it:
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
assert.strictEqual(getGlobalConfigDir('claude'), '/custom/claude');
|
||||
|
||||
// ✅ compliant
|
||||
assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude');
|
||||
```
|
||||
|
||||
Do **not** instead wrap the *expected* literal in `path.join(...)` to match the platform
|
||||
separator — that passes everywhere but masks a wrong backslash-on-POSIX return (both sides wrong
|
||||
together). Recognized normalizers: `.replace(/\\/g,'/')`, `.replace(/[\\/]/g,'/')`,
|
||||
`.replaceAll('\\','/')`, `.replaceAll(path.sep,'/')`, `.split(path.sep).join('/')`,
|
||||
`toPosixPath(...)`.
|
||||
|
||||
## How-to — fix a `no-posix-mode-bit-assert` violation
|
||||
|
||||
Windows does not honor POSIX file modes — `fs.statSync(p).mode` reads back `0o666` (writable) or
|
||||
`0o444` (readonly), never the `0o644`/`0o755` you wrote. A mode-bit assertion is therefore a
|
||||
POSIX-only precondition. **Gate it behind a platform check and keep the real behavioral assertion
|
||||
running on every OS** (do not delete it — scope it):
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644);
|
||||
|
||||
// ✅ scope the POSIX-only precondition; keep the behavioral assertion cross-platform
|
||||
if (process.platform !== 'win32') {
|
||||
assert.strictEqual(fs.statSync(p).mode & 0o777, 0o644);
|
||||
}
|
||||
assert.match(hookCommand, /^node /); // behavioral assertion — runs everywhere
|
||||
```
|
||||
|
||||
Prefer asserting the *behavior* (command shape, runnability) over the raw mode bit where you can.
|
||||
|
||||
## How-to — fix a `no-unguarded-nonportable-exec` violation
|
||||
|
||||
Why it fails on Windows: Windows Git Bash (msys2) does not honour Node's chmod exec bit for
|
||||
extension-less scripts that are invoked by searching PATH. A test that makes a fixture executable
|
||||
with `chmodSync(p, 0o755)` and then runs it with `execFileSync('bash', ['-c', '...'])` passes on
|
||||
macOS/Linux but fails only on the `windows-latest` CI lane (DEFECT.WINDOWS-TEST-PORTABILITY).
|
||||
|
||||
**Fix option A: gate the `sh`/`bash -c` invocation behind a platform check**
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
execFileSync('bash', ['-c', './fixture run']);
|
||||
|
||||
// ✅ platform-guarded
|
||||
fs.chmodSync(fixture, 0o755);
|
||||
if (process.platform !== 'win32') {
|
||||
execFileSync('bash', ['-c', './fixture run']);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix option B: invoke the script with an explicit interpreter (no -c flag)**
|
||||
|
||||
```js
|
||||
// ✅ passes the script path directly — exec bit not needed
|
||||
execFileSync('sh', [fixturePath]);
|
||||
```
|
||||
|
||||
## Platform guards (the only "escape" — by structure, not annotation)
|
||||
|
||||
If an assertion is *genuinely* POSIX-only, gate it behind a Windows platform check the rule
|
||||
recognizes — it then won't flag the guarded code. Recognized shapes:
|
||||
|
||||
```js
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(path.join(a, b), '/a/b'); // guarded → not flagged
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') return; // early-return guard
|
||||
assert.equal(path.join(a, b), '/a/b'); // → not flagged
|
||||
|
||||
const isWindows = process.platform === 'win32'; // hoisted boolean (any name, binding-resolved)
|
||||
if (!isWindows) assert.equal(path.join(a, b), '/a/b'); // → not flagged
|
||||
```
|
||||
|
||||
The guard is recognized by control-dependence (it must actually dominate the assertion), is
|
||||
binding-aware (a reassigned or `false`-initialized variable is not trusted), and handles
|
||||
`os.platform()` and `node:test` skip returns. See
|
||||
[`eslint-rules/lib/platform-guard.cjs`](../../eslint-rules/lib/platform-guard.cjs).
|
||||
|
||||
> **Note:** the `node:test` `test(name, { skip: isWindows ? … : false }, fn)` *option* object is
|
||||
> NOT recognized as a platform guard. To scope a POSIX-only assertion use an
|
||||
> `if (process.platform !== 'win32')` guard (or early-return) **inside** the callback.
|
||||
|
||||
## How-to — fix a `no-crlf-fragile-split` violation
|
||||
|
||||
Windows `git-autocrlf=true` (the default on Windows) rewrites `\n` to `\r\n` in checked-out files.
|
||||
A test that reads a file with `readFileSync` and then splits on `'\n'` (or uses a regex with a bare
|
||||
`\n`) will silently miscalculate line counts on Windows.
|
||||
|
||||
**Fix: use `/\r?\n/` everywhere you split or match lines in file content:**
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
const lines = fs.readFileSync(p, 'utf8').split('\n');
|
||||
assert.match(content, /^---\n/m);
|
||||
assert.match(content, /```bash\n/);
|
||||
|
||||
// ✅ CRLF-safe
|
||||
const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/);
|
||||
assert.match(content, /^---\r?\n/m);
|
||||
assert.match(content, /```bash\r?\n/);
|
||||
```
|
||||
|
||||
The `/\r?\n/` form is a no-op on POSIX (matches only `\n`) and correct on Windows (matches `\r\n`).
|
||||
|
||||
## How-to — fix a `no-hardcoded-tmp` violation
|
||||
|
||||
`/tmp` does not exist on Windows. Use `os.tmpdir()` to get the platform-appropriate temp directory:
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
const dir = path.join('/tmp/my-test-dir', 'sub');
|
||||
env.MY_VAR = '/tmp/custom-dir';
|
||||
|
||||
// ✅ portable
|
||||
const dir = path.join(os.tmpdir(), 'my-test-dir', 'sub');
|
||||
const customDir = path.join(os.tmpdir(), 'custom-dir');
|
||||
env.MY_VAR = customDir;
|
||||
```
|
||||
|
||||
When the same `/tmp/...` value is used both as a fixture env var and in an assertion, update both
|
||||
sides consistently so they still match:
|
||||
|
||||
```js
|
||||
// ❌ fragile — assertion tied to /tmp/ literal
|
||||
const customDir = path.join(os.tmpdir(), 'custom-dir');
|
||||
env.MY_VAR = customDir;
|
||||
assert.strictEqual(String(fn()).replace(/\\/g, '/'), '/tmp/custom-dir'); // ← still wrong
|
||||
|
||||
// ✅ assertion uses the same derived constant
|
||||
assert.strictEqual(String(fn()).replace(/\\/g, '/'), customDir.replace(/\\/g, '/'));
|
||||
```
|
||||
|
||||
## How-to — fix a `no-bare-npm-exec` violation
|
||||
|
||||
On Windows, `npm` is installed as `npm.cmd` (a CMD batch script). Without `{ shell: true }`,
|
||||
`execFileSync('npm', ...)` fails because the OS cannot find an executable named `npm` (no `.cmd`
|
||||
extension). Add `shell: true` or gate the call behind a platform check:
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
execFileSync('npm', ['ci'], { cwd: dir });
|
||||
|
||||
// ✅ shell: true — works on all platforms
|
||||
execFileSync('npm', ['ci'], { cwd: dir, shell: true });
|
||||
|
||||
// ✅ platform-guarded alternative
|
||||
execFileSync('npm', ['ci'], { cwd: dir, shell: process.platform === 'win32' });
|
||||
```
|
||||
|
||||
## How-to — fix a `require-userprofile-with-home` violation
|
||||
|
||||
Windows uses `USERPROFILE` as the home directory environment variable, not `HOME`. Whenever a test
|
||||
sets `process.env.HOME`, it must also set `process.env.USERPROFILE` to the same value (so that
|
||||
code under test that calls `os.homedir()` or reads `process.env.USERPROFILE` gets the isolated
|
||||
directory on Windows too). Mirror the teardown as well:
|
||||
|
||||
```js
|
||||
// ❌ flagged — Windows code-under-test reads USERPROFILE, not HOME
|
||||
const origHome = process.env.HOME;
|
||||
process.env.HOME = isolatedDir;
|
||||
// …
|
||||
process.env.HOME = origHome; // restore
|
||||
|
||||
// ✅ set and restore both
|
||||
const origHome = process.env.HOME;
|
||||
const origUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = isolatedDir;
|
||||
process.env.USERPROFILE = isolatedDir;
|
||||
// …
|
||||
if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
|
||||
if (origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = origUserProfile;
|
||||
```
|
||||
|
||||
## How-to — fix a `require-fs-op-fallback` violation
|
||||
|
||||
Why it fails on Windows: `fs.renameSync(tmp, target)` (the atomic-publish primitive) uses Windows
|
||||
`MoveFileEx` with `MOVEFILE_REPLACE_EXISTING`, which throws `EPERM`/`EBUSY`/`EACCES` when an
|
||||
antivirus scanner, indexer, or concurrent reader transiently holds the target open. On macOS/Linux
|
||||
`rename(2)` atomically replaces regardless of open handles, so the bare call passes everywhere
|
||||
except the `windows-latest` CI lane (DEFECT.WINDOWS-FS-OPS).
|
||||
|
||||
**Fix option A (preferred for production): route through `retryRenameSync`** — the shared drop-in
|
||||
from `shell-command-projection.cjs` that retries the transient errnos a bounded number of times
|
||||
before rethrowing. It is idempotent on POSIX (the transient errnos do not occur there):
|
||||
|
||||
```js
|
||||
import { retryRenameSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ❌ flagged — EPERM/EBUSY propagates unhandled on Windows
|
||||
fs.renameSync(tmpPath, target);
|
||||
|
||||
// ✅ drop-in — retries transient locks, throws on persistent failure
|
||||
retryRenameSync(tmpPath, target);
|
||||
```
|
||||
|
||||
**Fix option B: inline the `RENAME_RETRY_ERRNOS` loop** (the convention already used by
|
||||
`capability-ledger`, `capability-consent`, and `shell-command-projection`'s own `atomicRenameWithRetry`):
|
||||
|
||||
```js
|
||||
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
fs.renameSync(tmpPath, target);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt < 3 && RENAME_RETRY_ERRNOS.has(err.code)) { backoff(); continue; }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fix option C: gate behind a platform check** when the rename is genuinely POSIX-only:
|
||||
|
||||
```js
|
||||
// ✅ platform-guarded — not flagged
|
||||
if (process.platform !== 'win32') {
|
||||
fs.renameSync(tmpPath, target);
|
||||
}
|
||||
```
|
||||
|
||||
> **`copyFile` / `unlink` are not flagged.** Per the defect's own fix-forward, they are the
|
||||
> *fallback primitives* ("catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry"), not
|
||||
> separate defect sites. A retry delegated to a helper that itself wraps `renameSync` in the
|
||||
> `RENAME_RETRY_ERRNOS` loop is compliant because the helper's own `renameSync` is recognized; a
|
||||
> bare `fs.renameSync(...)` call is what gets flagged.
|
||||
|
||||
## How-to — add a new path resolver
|
||||
|
||||
When you add a function that returns a filesystem path (e.g. in `src/runtime-homes.cts`), add its
|
||||
name to `PATH_RETURNING_FNS` in `eslint-rules/lib/portability-vocab.cjs`. The drift-guard test
|
||||
will fail until you do.
|
||||
|
||||
## Known boundaries
|
||||
|
||||
The rule matches by spelling and inspects the direct operand (or a `String(<pathcall>)` wrapper):
|
||||
|
||||
- It assumes `path`/`os` are the standard modules and the resolver names are the project's — a
|
||||
local variable that *shadows* one of those names in a test file is out of scope.
|
||||
- Deeper wrapping (e.g. `realpathSync(path.join(...))`, `.toLowerCase()` on a path) is not
|
||||
inspected; assert against the path call directly or its `String(...)` wrap.
|
||||
- For a genuine explicit-dir *pass-through* assertion (a resolver that returns its input
|
||||
verbatim), the `String(...).replace(/\\/g,'/')` remedy is a harmless no-op.
|
||||
- **The rule catches a path-returning call interpolated *directly* into `${ }`.** It does NOT
|
||||
track **indirect data-flow** — a path stored in a variable or object field, then interpolated
|
||||
(e.g. `${globalSkillDir}/SKILL.md` → `@${entry.ref}`). Indirect content-path-leaks rely on
|
||||
`RULESET.CONTENT-PATH-NORMALIZATION` discipline (normalize at source) and code review.
|
||||
The one known indirect leak (`src/init.cts` `cmdAgentSkills` `entry.ref` building) is fixed
|
||||
by normalizing at the content-emit site: `- @${String(entry.ref).replace(/\\/g, '/')}`.
|
||||
- **Content detection shape (b)** fires when the quasi *immediately following* the interpolation
|
||||
starts with `/…\.md` or `/…\.json`. A bare `.md` or `.json` token in the *middle* of prose
|
||||
(e.g. `: see README.md`) does NOT qualify — the quasi must start with the forward slash.
|
||||
Config-dir substrings (`/.claude`, `/commands`, `/skills`, etc.) are deliberately NOT content
|
||||
markers — they caused false positives on log/error/diagnostic strings mentioning config dirs.
|
||||
111
docs/how-to/add-or-update-a-host-integration.md
Normal file
111
docs/how-to/add-or-update-a-host-integration.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# How to add or update a host's integration capabilities
|
||||
|
||||
This guide is for GSD maintainers adding a new host CLI, or updating an existing host's
|
||||
host-integration axes (ADR-1239 Phase A). It covers the **documentation-sourcing rule**, the
|
||||
eight `runtime.hostIntegration` axes, the `undocumented` sentinel, and how to validate.
|
||||
|
||||
The governing rule for this whole process: **every axis value must come from the host's own
|
||||
authoritative documentation. Never infer, guess, or assume.** Where the docs do not state an axis,
|
||||
record the explicit `undocumented` sentinel — not a plausible default. The reference matrix
|
||||
(`docs/reference/host-integration-capability-matrix.md`) is the source of truth, and every value in
|
||||
it carries a citation and an evidence quote.
|
||||
|
||||
---
|
||||
|
||||
## 1. Find the host's authoritative documentation
|
||||
|
||||
In order of preference:
|
||||
|
||||
1. **Context7** — `resolve-library-id` for the host, then `query-docs` for "plugins / subagents / hooks / commands / MCP / model API".
|
||||
2. **Official dev docs / source repo** — the host's documentation site or GitHub repo (plugin API, agents, hooks, MCP, command authoring).
|
||||
|
||||
Capture the exact source (Context7 library id + query, or the doc URL) and a short verbatim quote
|
||||
for each value you determine. You will paste these into the matrix in step 4.
|
||||
|
||||
## 2. Determine each of the eight axes from the docs
|
||||
|
||||
Read the docs and map them to the closed vocabulary. Do not pick a value unless a source states it.
|
||||
|
||||
| Axis | What to look for in the docs |
|
||||
|---|---|
|
||||
| `embeddingMode` | An in-process programmatic plugin/extension API (`imperative`) vs. configuration files only (`declarative`). |
|
||||
| `commandSurface` | How custom commands are authored/invoked: `slash-file` (.md), `slash-toml`, `slash-programmatic`, `palette`, `prose-only`. |
|
||||
| `dispatch` | Sub-agent delegation: `namedDispatch`, `nested`, `maxDepth` (int; `-1` = documented-unbounded), `background`, `subagentToolkit` (`full`/`read-only`). |
|
||||
| `modelMode` | A programmatic model request/provider API (`active`) vs. instruction/per-agent-field only (`passive`). |
|
||||
| `hookBus` | The host fires lifecycle events a plugin subscribes to (`host`), an extension host owns the bus (`engine`), or no bus (`none`). **Independent of `hooksSurface`** — e.g. opencode has `hooksSurface: none` but `hookBus: host`. |
|
||||
| `stateIO` | `filesystem`, `sandboxed-storage` (web IDE, no arbitrary FS), or `session-log-append`. |
|
||||
| `transport` | `mcp` (native MCP support) vs. `native-extension` (MCP needs a community extension). |
|
||||
| `runtime` | The plugin/extension runtime: `node`, `bun`, `sandboxed-web`, `python`, `go`, `rust`, `electron`, `other`. |
|
||||
|
||||
## 3. Write the `runtime.hostIntegration` block
|
||||
|
||||
In `capabilities/<id>/capability.json`, inside the `runtime` object, add (or edit) the block. Use a
|
||||
documented closed-vocabulary value, or the literal string `"undocumented"` for any axis the docs do
|
||||
not state:
|
||||
|
||||
```json
|
||||
"hostIntegration": {
|
||||
"embeddingMode": "declarative",
|
||||
"commandSurface": "slash-file",
|
||||
"dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": false, "subagentToolkit": "undocumented" },
|
||||
"modelMode": "passive",
|
||||
"hookBus": "host",
|
||||
"stateIO": "filesystem",
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
}
|
||||
```
|
||||
|
||||
**When to use `undocumented`:** only when you searched and the host's docs genuinely do not state the
|
||||
axis. It validates, but `negotiateHostCapabilities` **fail-closes** on it (degrades to the most
|
||||
restrictive known value) — so it is always safe and never a silent capability claim. A dispatch
|
||||
boolean or `maxDepth` may also be `"undocumented"`.
|
||||
|
||||
**Do not conflate the orthogonal axes:** `commandStyle` (GSD's emission style) is *not*
|
||||
`commandSurface` (the host's surface type); the `hookEvents` dialect is *not* `hookBus` (bus
|
||||
ownership); `runtimeCompat` (which features run on a host) is independent of these runtime→engine
|
||||
axes.
|
||||
|
||||
## 4. Record the citations in the reference matrix
|
||||
|
||||
Add (or update) the host's section in `docs/reference/host-integration-capability-matrix.md` with a
|
||||
row per axis: `Axis | Value | Source | Evidence`. For an `undocumented` value, put the search trail
|
||||
in the Source column. This file is the deployment source of truth — a value without a citation here
|
||||
is not allowed.
|
||||
|
||||
## 5. Validate
|
||||
|
||||
```bash
|
||||
npm run build:lib
|
||||
npm run gen:capability-registry # validateRuntimeBody runs on every descriptor
|
||||
```
|
||||
|
||||
`gen:capability-registry` must succeed with zero errors. The validator
|
||||
(`gsd-core/bin/lib/capability-validator.cjs`) rejects out-of-vocabulary values, malformed dispatch
|
||||
structs, and reserved keys (`__proto__`/`constructor`/`prototype`).
|
||||
|
||||
Then run the host-integration tests and the full cross-platform suite:
|
||||
|
||||
```bash
|
||||
node --test tests/host-integration-descriptors.test.cjs # asserts every descriptor validates + profiles
|
||||
gsd-test-both # Mac + Linux Docker (run before any PR)
|
||||
```
|
||||
|
||||
## 6. If you need a vocabulary value that does not exist yet
|
||||
|
||||
The vocabulary is intentionally **closed** (ADR-857 Decision 8): a genuinely new host shape requires
|
||||
a first-party primitive, reviewed. To add one (e.g. a new `runtime` kind):
|
||||
|
||||
1. Add the value to the relevant axis in `HOST_INTEGRATION_AXES` in `src/host-integration.cts`.
|
||||
2. Add the same value to the matching `VALID_*` set in `capability-validator.cjs`.
|
||||
|
||||
The parity guard (`tests/host-integration-validator-parity.test.cjs`) fails if these two drift, so
|
||||
they must be updated together. Document the new value's meaning in the matrix legend.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- Reference: [`docs/reference/host-integration-capability-matrix.md`](../reference/host-integration-capability-matrix.md) — the per-CLI sourced values.
|
||||
- ADR: [`docs/adr/1239-gsd-embeddable-orchestration-engine.md`](../adr/1239-gsd-embeddable-orchestration-engine.md) — why the interface exists and the Phase A amendment.
|
||||
- The closed-vocabulary runtime descriptor it extends: [ADR-1016](../adr/1016-runtime-capability-descriptor.md).
|
||||
@@ -138,3 +138,13 @@ Each GSD release may include installer migrations that rename, move, or retire m
|
||||
- [Manual update](../manual-update.md)
|
||||
- [Installer migrations](../installer-migrations.md)
|
||||
- [Docs index](../README.md)
|
||||
|
||||
## CLI version-skew warning
|
||||
|
||||
GSD warns (to stderr, non-blocking) when the resolved `gsd-tools.cjs` is **outside your project root** while a project-local install exists — a sign that a global install (often a retired `@gsd-build/sdk` canary) is shadowing your project-local GSD. The warning names the resolved path and, for the `@gsd-build/sdk` case, gives the removal command:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @gsd-build/sdk
|
||||
```
|
||||
|
||||
If you see this warning, remove the stale global package so `gsd_run` resolves the project-local install.
|
||||
|
||||
@@ -239,12 +239,29 @@ All steps/contributions are `onError: skip`. No gates.
|
||||
| **0 — Spike** | `mempalace init`/`mine`/`search`/`wake-up` against gsd-core's own `.planning/`; confirm wing/room mapping feels right | manual: recall surfaces real prior decisions |
|
||||
| **1 — Manifest + registry** | `capabilities/mempalace/capability.json` + `gen-capability-registry.cjs --write`; CI staleness gate green; consistency gate (id≠CLUSTERS collision) | `--check` passes |
|
||||
| **2 — Skills + agent + fragments** | the two skills, the curator agent, two fragment files; `augment` mode only | recall/capture work when invoked manually |
|
||||
| **3 — Config + federated flow** | all `mempalace.*` keys resolve via federated config; `capability-state` resolver reports the capability | state resolver shows installed/surfaced + hook activity |
|
||||
| **3 — Config + federated flow** | all `mempalace.*` keys resolve via federated config; `capability-state` resolver reports the capability | state resolver shows installed/surfaced + hook activity; **user can run `gsd capability enable mempalace` and `config-set mempalace.enabled true`** (inherited ADR-857 capability surface — UX-enable) |
|
||||
| **4 — Modes** | `kg_backend` then `replace`; `gsd-graphify` routing seam | each mode round-trips a decision |
|
||||
| **5 — Passive hooks + autonomous** | `auto_capture_hooks` installs native hooks; CLI-path capture verified headless (`/gsd-autonomous`, cron) | headless run captures with no MCP |
|
||||
| **6 — Loop wiring (blocked on ADR-857 phase-6)** | `loop render-hooks` called from `plan-phase.md`/`execute-phase.md`/etc. so hooks auto-fire | end-to-end auto recall/capture |
|
||||
| **6 — Loop wiring (shipped via ADR-857)** | the host-loop workflows call `loop render-hooks` at each canonical point, so registered capability hooks auto-fire | with `mempalace.enabled`, a `/gsd-execute-phase` run **auto-produces `MEMORY-RECALL.md` at `plan:pre`**, files capture at `plan:post`/`verify:post` with **no manual invocation**, and the curator spawns at `ship:post` — **verified** (`gsd-tools loop render-hooks plan:pre` returns the `mempalace-recall` step) |
|
||||
|
||||
Phases 1–5 ship value **before** ADR-857's phase-6 cutover (the skills are invocable directly). Phase 6 flips them to automatic.
|
||||
ADR-857 (the capability system + `loop render-hooks` infrastructure) is **released**, so the Phase-6 loop wiring is shipped: the host-loop workflows call `loop render-hooks` at each canonical point, and MemPalace auto-fires through it when `mempalace.enabled`. The skills (`/gsd:mempalace-recall`, `/gsd:mempalace-capture`) are also invocable directly for manual use.
|
||||
|
||||
### 15.1 Decision → Phase ownership (traceability)
|
||||
|
||||
Every design decision (§10) and user-facing capability is the explicit responsibility of exactly one phase. Cross-cutting policies are assigned a **primary** owner (the phase that first embodies them) with later phases that extend them noted:
|
||||
|
||||
| Decision / capability | Primary owner | Notes |
|
||||
|---|---|---|
|
||||
| D1 role=`feature` · D10 manifest · **D6 `onError:skip` no-gate policy** | **Phase 1** | D6 is encoded in the manifest's per-step `onError:skip`; every later phase inherits it. |
|
||||
| D3 transport (MCP-primary / CLI-fallback) · D4 verbatim drawers · D8 wing/room taxonomy · UX-recall · UX-capture | **Phase 2** | D3's MCP-primary rendering lives in the skills/fragments; the CLI-fallback *headless* path is exercised in Phase 5 (UX-headless). |
|
||||
| D2 tier=`full` opt-in · D11 federated config · UX-enable | **Phase 3** | UX-enable is the **inherited** `gsd capability enable mempalace` + `config-set` surface (ADR-857's CLI), verified in this phase — not a MemPalace-specific command. |
|
||||
| D5 three modes | **Phase 4** | Deferred from Phase 2 ("augment only"); Phase 4 owns `kg_backend`/`replace` + the `gsd-graphify` routing seam. |
|
||||
| D7 passive auto-capture · UX-passive · UX-headless | **Phase 5** | Native-hook install + the headless CLI-path transport (D3 fallback). |
|
||||
| D9 loop-point map (7 points) · UX-auto · UX-curator | **Phase 6** | Wired via the **shipped** ADR-857 `loop render-hooks` infrastructure (ADR-857 is released); auto-fires when `mempalace.enabled` — verified end-to-end. |
|
||||
|
||||
### 15.2 Loop wiring status
|
||||
|
||||
ADR-857 (the capability system + the `loop render-hooks` resolver + the workflow call sites) is **released**. The host-loop workflows (`plan-phase.md`, `execute-phase.md`, `verify-work.md`, `ship.md`, `discuss-phase.md`) call `loop render-hooks <point>` at each canonical point, so any registered capability — including `mempalace` — auto-fires when its `when` gate is true. **Verified:** `gsd-tools loop render-hooks plan:pre --raw` with `mempalace.enabled: true` returns the `mempalace-recall` step (`capId: mempalace`, `produces: MEMORY-RECALL.md`), rendered into the workflow markdown. There is therefore **no outstanding cross-doc gating dependency** for UX-auto / UX-curator — the earlier "blocked on ADR-857 *Migrate*" framing (in the original §15 and a prior audit comment) is retracted: that phase shipped. The manual skills (`/gsd:mempalace-recall`, `/gsd:mempalace-capture`) remain available for direct invocation independent of the loop.
|
||||
|
||||
## 16. Registration tax (per ADR-857 + repo checklists)
|
||||
|
||||
@@ -262,12 +279,14 @@ Phases 1–5 ship value **before** ADR-857's phase-6 cutover (the skills are inv
|
||||
|
||||
## 17. Open questions
|
||||
|
||||
1. **Wing identity** — one wing per repo (`project_code`) vs one per milestone? Recommendation: per-repo wing, milestone/phase as KG validity windows + rooms; revisit if wings get too coarse.
|
||||
2. **`replace` migration** — do we backfill existing `.planning/graphs/` into the palace KG, or only forward-fill? Recommendation: ship a one-shot `mempalace mine .planning/` + KG import as part of mode switch.
|
||||
3. **Curator agent tier** — the curator is operational (branches, API calls, error recovery) ⇒ `sonnet` model. Confirm.
|
||||
4. **Headless MCP availability** — verify MemPalace's stdio MCP server *is* reachable under `/gsd-autonomous`/cron, or commit fully to the CLI path there (FR-T1).
|
||||
5. **Phase-6 dependency** — accept shipping 1–5 ahead of loop wiring, or hold until phase-6 lands? Recommendation: ship ahead; the manual-invocation value is real and de-risks phase-6.
|
||||
6. **Diary `agent_name`** — namespace per GSD role (`gsd-orchestrator`) or per repo? Recommendation: per repo+role so diaries don't collide across projects.
|
||||
Each open question is traced to the phase whose acceptance must **resolve** it (so a decision doesn't sit ownerless between phases):
|
||||
|
||||
1. **Wing identity** _(resolve in **Phase 0** spike)_ — one wing per repo (`project_code`) vs one per milestone? Recommendation: per-repo wing, milestone/phase as KG validity windows + rooms; revisit if wings get too coarse. The Phase-0 spike gate ("recall surfaces real prior decisions") is where this is validated.
|
||||
2. **`replace` migration** _(resolve in **Phase 4**)_ — do we backfill existing `.planning/graphs/` into the palace KG, or only forward-fill? Recommendation: ship a one-shot `mempalace mine .planning/` + KG import as part of mode switch. Owned by the Phase-4 "Modes" gate.
|
||||
3. **Curator agent tier** _(resolve in **Phase 2**)_ — the curator is operational (branches, API calls, error recovery) ⇒ `sonnet` model. Confirm at Phase-2 agent delivery.
|
||||
4. **Headless MCP availability** _(resolve in **Phase 5**)_ — verify MemPalace's stdio MCP server *is* reachable under `/gsd-autonomous`/cron, or commit fully to the CLI path there (FR-T1). Owned by the Phase-5 headless gate.
|
||||
5. **Loop wiring** _(resolved — shipped)_ — ADR-857 is released and the host-loop workflows call `loop render-hooks`, so Phase-6 auto-fire is wired and verified end-to-end (§15.2). The manual skills (`/gsd:mempalace-recall`, `/gsd:mempalace-capture`) remain available for direct use.
|
||||
6. **Diary `agent_name`** _(resolve in **Phase 6**)_ — namespace per GSD role (`gsd-orchestrator`) or per repo? Recommendation: per repo+role so diaries don't collide across projects. Owned by the Phase-6 curator wiring (UX-curator).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ Runtime capabilities describe how GSD projects its artefacts onto one host CLI.
|
||||
| Axis | Field | Type summary |
|
||||
|---|---|---|
|
||||
| Config home | `runtime.configHome` | Structured object with `kind` (`dot-home` \| `dot-home-nested` \| `xdg` \| `generic-agents-root`), `name`, optional `parent`, `env[]`, `probe[]`, `probeExists`, `skillsHome`. `probeExists` is an optional sub-path applied to probe candidates: for `generic-agents-root` it is a hard filter (a candidate qualifies only if `<candidate>/<probeExists>` exists); for `dot-home-nested` it is a preference that makes probing pick the candidate GSD owns (e.g. `gsd-core/VERSION`) over a bare-existing sibling before falling back — see ADR-1016 and #213/#217. |
|
||||
| Local config dir | `runtime.localConfigDir` | Required dot-prefixed string. The runtime's **local** content-rewrite directory — the `./` target GSD stamps into rewritten artefact bodies (e.g. `./.claude/` → `./<localConfigDir>/`) and the local install dir basename. Backs `getDirName()` (registry-derived, #1679). Usually `.<runtime>` (the runtime's home dot-dir), but **three runtimes diverge** because they read GSD's content from a non-home directory: `copilot` → `.github` (GitHub Copilot reads custom instructions from `.github/copilot-instructions.md` / `.github/instructions/`; see `convertClaudeToCopilotContent` rewrites in `src/runtime-artifact-conversion.cts`), `antigravity` → `.agents` (local agent/workflow dir; see the antigravity rewrites in `src/runtime-artifact-conversion.cts`), `kimi` → `.kimi-code`. Distinct from `configHome.name` (the **global** install home, which for these three is `.copilot` / `antigravity` / `agents`). Byte-parity-proven against the prior hand-maintained mapping by the golden-install-parity harness. |
|
||||
| Config format | `runtime.configFormat` | Closed enum: `settings-json` \| `toml` \| `markdown` \| `markdown-dir` \| `none`. |
|
||||
| Artefact layout | `runtime.artifactLayout` | Object with `global` and `local` arrays of `ArtifactKind` (`kind`, `destSubpath`, `prefix`, `nesting`, `recursive`, `stage`). |
|
||||
| Command style | `runtime.commandStyle` | Closed enum: `slash-hyphen` \| `shell-var`. |
|
||||
|
||||
582
docs/reference/host-integration-capability-matrix.md
Normal file
582
docs/reference/host-integration-capability-matrix.md
Normal file
@@ -0,0 +1,582 @@
|
||||
# Host Integration Capability Matrix
|
||||
|
||||
This document is the maintainer-facing source of truth for the `hostIntegration` block in every
|
||||
`capabilities/<cli>/capability.json` runtime descriptor. Every per-CLI axis value is either:
|
||||
|
||||
- **documented** — backed by a cited authoritative source and evidence quote, or
|
||||
- **`undocumented`** — the explicit fail-closed sentinel used when the CLI's public documentation
|
||||
does not state a value for that axis. `undocumented` validates in the registry but never
|
||||
propagates into effective axes: negotiation degrades closed to the safe default.
|
||||
|
||||
Values are generated from per-CLI documentation research (Context7 + official docs). They are
|
||||
consumed verbatim by `gen:capability-registry` and validated by `capability-validator.cjs`.
|
||||
|
||||
---
|
||||
|
||||
## Axes legend
|
||||
|
||||
| Axis | Meaning |
|
||||
|---|---|
|
||||
| `embeddingMode` | Whether the CLI exposes an in-process programmatic API (`imperative`) or integrates purely through configuration files (`declarative`). |
|
||||
| `commandSurface` | How slash commands are registered: `slash-file` (markdown), `slash-toml` (TOML), `slash-programmatic` (code API), `palette`, `prose-only`. |
|
||||
| `modelMode` | Whether extensions can programmatically request or supply a model (`active`) or select only by config (`passive`). |
|
||||
| `hookBus` | Who owns the hook lifecycle: `host` (the CLI fires hooks), `engine` (VS Code/Electron extension host), `none`. |
|
||||
| `stateIO` | Filesystem access model: `filesystem` (full local FS), `sandboxed-storage`, `session-log-append`. |
|
||||
| `transport` | Integration transport: `mcp` (Model Context Protocol), `native-extension`. |
|
||||
| `runtime` | Plugin/extension execution runtime: `node`, `bun`, `python`, `go`, `rust`, `electron`, `sandboxed-web`, `other`. |
|
||||
|
||||
### dispatch sub-axes
|
||||
|
||||
| Sub-axis | Meaning |
|
||||
|---|---|
|
||||
| `namedDispatch` | Whether agents can be invoked by name (true/false/`undocumented`). |
|
||||
| `nested` | Whether subagents can themselves spawn subagents (true/false/`undocumented`). |
|
||||
| `maxDepth` | Maximum nesting depth (integer; -1 = unbounded; `undocumented`). |
|
||||
| `background` | Whether subagents can run asynchronously in the background (true/false/`undocumented`). |
|
||||
| `subagentToolkit` | Tool surface available to subagents: `full`, `read-only`, or `undocumented`. |
|
||||
| `backgroundDispatch` | Whether a BACKGROUND-dispatched sub-agent can itself spawn further named sub-agents — the #853 discriminator (true/false/`undocumented`). |
|
||||
|
||||
### Interface points
|
||||
|
||||
| Point | Meaning |
|
||||
|---|---|
|
||||
| `command` | Slash-command routing and invocation capability. |
|
||||
| `dispatch` | Subagent/multi-agent dispatch capability. |
|
||||
| `model` | Programmatic model selection capability. |
|
||||
| `hooks` | Lifecycle hook registration capability. |
|
||||
| `state` | Filesystem/state I/O capability. |
|
||||
| `artifact` | Artifact delivery (skills, commands) surface capability. |
|
||||
|
||||
---
|
||||
|
||||
## claude
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | https://code.claude.com/docs/en/agent-sdk/overview | "The Agent SDK offers hooks to execute custom code at critical points within the agent's lifecycle. These callback functions enable developer" |
|
||||
| commandSurface | slash-file | https://code.claude.com/docs/en/agent-sdk/slash-commands | "Each custom command is a markdown file where the filename (without the `.md` extension) becomes the command name. The file content defines w" |
|
||||
| modelMode | passive | https://code.claude.com/docs/en/agent-sdk/typescript | "setModel(model?: string): Changes the model (only available in streaming input mode) ... model overrides the default model for this subagent" |
|
||||
| hookBus | host | https://code.claude.com/docs/en/agent-sdk/python | "HookEvent = Literal['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'UserPromptSubmit', 'Stop', 'SubagentStop', 'PreCompact', 'Notificati" |
|
||||
| stateIO | filesystem | https://code.claude.com/docs/en/sandboxing | "The sandboxed Bash tool restricts file system access, granting read and write access to the current working directory and session temp direc" |
|
||||
| transport | mcp | https://code.claude.com/docs/en/mcp | "Project-Scoped MCP Server Configuration in .mcp.json ... This JSON structure illustrates the format for a project-scoped MCP server configur" |
|
||||
| runtime | node | https://code.claude.com/docs/en/agent-sdk/typescript | "import { query } from \"@anthropic-ai/claude-agent-sdk\"; ... pathToClaudeCodeExecutable (string) - Specifies the path to the Claude Code CLI" |
|
||||
| dispatch.namedDispatch | true | https://code.claude.com/docs/en/agent-sdk/subagents | "agents: { 'code-reviewer': AgentDefinition({ description: 'Expert code reviewer.', ... }) } ... subagent_type: block.inp" |
|
||||
| dispatch.nested | true | https://code.claude.com/docs/en/sub-agents | "As of Claude Code v2.1.172, a subagent can spawn its own subagents, allowing delegated tasks to split into parallel subt" |
|
||||
| dispatch.maxDepth | 5 | https://code.claude.com/docs/en/sub-agents | "foreground subagents can spawn at any depth, blocking their parent until completion. Background subagents are limited to" |
|
||||
| dispatch.background | true | https://code.claude.com/docs/en/sub-agents | "Subagents can run in the foreground, blocking the main conversation and passing permission prompts to you, or in the bac" |
|
||||
| dispatch.subagentToolkit | full | https://code.claude.com/docs/en/sub-agents | "If all tools remain selected, the subagent inherits all tools available to the main conversation." |
|
||||
| dispatch.backgroundDispatch | false | https://code.claude.com/docs/en/sub-agents | "Background subagents are limited to a depth of five and cannot spawn further, " |
|
||||
|
||||
Sources consulted:
|
||||
- https://code.claude.com/docs/en/sub-agents
|
||||
- https://code.claude.com/docs/en/agent-sdk/slash-commands
|
||||
- https://code.claude.com/docs/en/agent-sdk/subagents
|
||||
- https://code.claude.com/docs/en/agent-sdk/python
|
||||
- https://code.claude.com/docs/en/agent-sdk/typescript
|
||||
- https://code.claude.com/docs/en/agent-sdk/overview
|
||||
- https://code.claude.com/docs/en/mcp
|
||||
- https://code.claude.com/docs/en/sandboxing
|
||||
- Context7 /websites/code_claude
|
||||
- Context7 /llmstxt/code_claude_llms_txt
|
||||
|
||||
---
|
||||
|
||||
## codex
|
||||
|
||||
> **Note:** ADR-1239's host matrix lists Codex as `prose-only`; current OpenAI Codex dev docs document slash-commands, so `commandSurface` is `slash-file` here (docs are the source of truth).
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | declarative | https://developers.openai.com/codex/plugins/build | "No in-process programmatic API exists. Plugins integrate through: External command execution (hooks), MCP server processes, Configuration fi" |
|
||||
| commandSurface | slash-file | https://github.com/openai/codex/blob/main/codex-rs/core-skills/src/loader.rs | "const SKILLS_FILENAME: &str = \"SKILL.md\"; ... Each skill is a folder with a SKILL.md file containing YAML frontmatter with name and descript" |
|
||||
| modelMode | passive | https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs | "pub model_provider: Option<String> ... model is selected by config field; no programmatic model request API" |
|
||||
| hookBus | host | https://github.com/openai/codex/blob/main/codex/codex-rs/hooks/src/lib.rs | "pub const HOOK_EVENT_NAMES: [&str; 10] = [\"PreToolUse\", \"PermissionRequest\", \"PostToolUse\", \"PreCompact\", \"PostCompact\", \"SessionStart\", \"Us" |
|
||||
| stateIO | filesystem | https://developers.openai.com/codex/concepts/sandboxing | "workspace-write: The default mode allowing Codex to read files, edit within the workspace, and run routine local commands inside that bounda" |
|
||||
| transport | mcp | https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs | "pub mcp_servers: HashMap<String, McpServerConfig> ... Definition for MCP servers that Codex can reach out to for tool calls." |
|
||||
| runtime | node | https://github.com/openai/codex/blob/main/codex-cli/package.json | "\"engines\": {\"node\": \">=16\"} ... The npm-distributed CLI wrapper is a Node.js script (#!/usr/bin/env node)" |
|
||||
| dispatch.namedDispatch | true | https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs | "\"agent_type\".to_string(), JsonSchema::string(Some(agent_type_description.to_string())) ... apply_role_to_config(&mut con" |
|
||||
| dispatch.nested | true | https://developers.openai.com/codex/multi-agent | "agents.max_depth defaults to 1, which allows a direct child agent to spawn but prevents deeper nesting." |
|
||||
| dispatch.maxDepth | 1 | https://developers.openai.com/codex/config-reference | "agents.max_depth: Maximum nesting depth allowed for spawned agent threads (root sessions start at depth 0; default: 1)" |
|
||||
| dispatch.background | true | https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs | "spawn_agent returns the spawned agent id immediately; a separate wait_agent tool polls for final status." |
|
||||
| dispatch.subagentToolkit | full | https://developers.openai.com/codex/multi-agent | "Subagents inherit the sandbox policy and tool surface from the parent session." |
|
||||
| dispatch.backgroundDispatch | true | https://github.com/openai/codex/blob/main/codex-rs/core/templates/collab/experimental_prompt.md | "Sub-agents have access to the same set of tools as you do so you must tell them if they are allowed to spawn sub-agents themselves or not." The config (codex-rs/config/src/config_toml.rs) exposes an |
|
||||
|
||||
Sources consulted:
|
||||
- https://github.com/openai/codex (repo via gh CLI)
|
||||
- /openai/codex (Context7 library ID)
|
||||
- https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs
|
||||
- https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/multi_agents_spec.rs
|
||||
- https://github.com/openai/codex/blob/main/codex-rs/core-skills/src/loader.rs
|
||||
- https://developers.openai.com/codex/config-reference
|
||||
- https://developers.openai.com/codex/plugins/build
|
||||
- https://developers.openai.com/codex/multi-agent
|
||||
- https://developers.openai.com/codex/cli/slash-commands
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.maxDepth is configurable (Option<i32> with no documented upper bound); the documented default is 1 but the actual enforced maximum is not stated.
|
||||
- dispatch.subagentToolkit: docs say subagents 'inherit the tool surface' but do not enumerate whether any tools are excluded.
|
||||
- runtime: the Node.js entry point is a thin launcher shim; the actual agent execution runtime is a compiled Rust binary — axis classification is ambiguous.
|
||||
|
||||
---
|
||||
|
||||
## gemini
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | declarative | https://github.com/google-gemini/gemini-cli/blob/main/packages/sdk/SDK_DESIGN.md | "This feature is currently not implemented. (repeated for both extension and subagent SDK APIs; all actual integration is via files: TOML com" |
|
||||
| commandSurface | slash-toml | https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md | "Custom commands in Gemini CLI are defined in TOML format with the .toml file extension … Commands are invoked as slash commands in the CLI." |
|
||||
| modelMode | passive | https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md | "BeforeModel Hook: Fires before sending a request to the LLM … hookSpecificOutput.llm_request (object) — An object that overrides parts of th" |
|
||||
| hookBus | host | https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md | "Hooks function as host-fired events … The CLI fires events at predetermined lifecycle points [BeforeAgent, AfterAgent, BeforeTool, AfterTool" |
|
||||
| stateIO | filesystem | https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md | "Local access by default with gitignore/geminiignore respect … Set to a boolean to enable or disable the sandbox" |
|
||||
| transport | mcp | https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md | "Configure a Node.js MCP server using stdio … { \"mcpServers\": { \"nodeServer\": { \"command\": \"node\", \"args\": [\"dist/server.js\"] } } }" |
|
||||
| runtime | node | https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md | "References to node-pty and child_process indicate JavaScript/Node.js execution environment" |
|
||||
| dispatch.namedDispatch | true | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "Example of a custom subagent definition file (.gemini/agents/security-auditor.md) … name: security-auditor" |
|
||||
| dispatch.nested | false | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "Each subagent operates in an isolated context loop … This isolation also includes recursion protection, preventing subag" |
|
||||
| dispatch.maxDepth | 1 | https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md | "To prevent infinite loops and excessive token usage, subagents cannot call other subagents. … The architecture enforces" |
|
||||
| dispatch.background | undocumented | no authoritative doc — searched: https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md, /google-gemini/gemini-cli (Context7 library) | — |
|
||||
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md, /google-gemini/gemini-cli (Context7 library) | — |
|
||||
| dispatch.backgroundDispatch | false | https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/core/subagents.md | "To prevent infinite loops and excessive token usage, subagents cannot call other subagents." Additionally: "If a subagent is granted the `*` tool wildcard, it will still be unable to see or invoke ot |
|
||||
|
||||
Sources consulted:
|
||||
- https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md
|
||||
- https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md
|
||||
- https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md
|
||||
- https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md
|
||||
- https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md
|
||||
- https://github.com/google-gemini/gemini-cli/blob/main/packages/sdk/SDK_DESIGN.md
|
||||
- /google-gemini/gemini-cli (Context7 library)
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.background — docs describe subagents as operating in isolated context loops but do not state whether they execute synchronously or asynchronously.
|
||||
- dispatch.subagentToolkit — subagents have a configurable tool grant model but no single fixed value of 'full' or 'read-only' is stated as the architecture-level constraint.
|
||||
|
||||
---
|
||||
|
||||
## opencode
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | https://opencode.ai/docs/plugins | "Plugins are JavaScript/TypeScript modules that export plugin functions; they register hooks via `import type { Plugin } from '@opencode-ai/p'" |
|
||||
| commandSurface | slash-file | https://opencode.ai/docs/commands | "\"Create markdown files in the `commands/` directory to define custom commands.\" and \"The markdown file name becomes the command name." |
|
||||
| modelMode | active | /anomalyco/opencode (Context7) — packages/plugin/src/v2/promise/README.md | "`ctx.aisdk.sdk(async (event) => { ... event.sdk = mod.createXai(event.options) })` and `ctx.aisdk.language((event) => { ... event.language =" |
|
||||
| hookBus | host | https://opencode.ai/docs/plugins | "Host fires events including: `tool.execute.before`, `tool.execute.after`, `session.created`, `session.compacted`, `session.deleted`" |
|
||||
| stateIO | filesystem | https://opencode.ai/docs/plugins | "Plugin context includes `directory` (working directory path), `worktree` (git worktree path), and `$` (\"Bun's shell API\")" |
|
||||
| transport | mcp | https://opencode.ai/docs/mcp-servers | "\"OpenCode supports both local and remote servers.\" and \"Once added, MCP tools are automatically available to the LLM\"" |
|
||||
| runtime | bun | https://opencode.ai/docs/plugins | "\"$\": Bun's shell API for executing commands\" (plugin context property); \"OpenCode runs `bun install` at startup\"" |
|
||||
| dispatch.namedDispatch | true | https://opencode.ai/docs/agents | "\"Subagents can be invoked: Automatically by primary agents for specialized tasks based on their descriptions. Manually b" |
|
||||
| dispatch.nested | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — |
|
||||
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — |
|
||||
| dispatch.background | false | https://github.com/sst/opencode/issues/5887 | "\"Currently, sub-agent delegation in `opencode` appears to be synchronous or modal... There is no native 'fire-and-forget'" |
|
||||
| dispatch.subagentToolkit | full | https://opencode.ai/docs/agents | "The 'general' subagent \"Has full tool access (except todo), so it can make file changes when needed.\"" |
|
||||
| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://github.com/anomalyco/opencode/issues/18100 and https://github.com/anomalyco/opencode/blob/dev/opencode/packages/opencode/src/tool/task.ts | Opencode supports background task dispatch via the Task tool's `background: true` parameter but whether a background-spawned agent can itself spawn further sub-agents is not documented. |
|
||||
|
||||
Sources consulted:
|
||||
- https://opencode.ai/docs/plugins
|
||||
- https://opencode.ai/docs/agents
|
||||
- https://opencode.ai/docs/commands
|
||||
- https://opencode.ai/docs/mcp-servers
|
||||
- /websites/opencode_ai_plugins (Context7)
|
||||
- /anomalyco/opencode (Context7)
|
||||
- https://github.com/sst/opencode/issues/5887
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.nested
|
||||
- dispatch.maxDepth
|
||||
|
||||
---
|
||||
|
||||
## cursor
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | https://cursor.com/docs/sdk/typescript | "local.customTools where you define tool functions that execute 'in your process, so it can reach anything your code can'; Agent.create()" |
|
||||
| commandSurface | slash-file | https://cursor.com/docs/enterprise/llm-safety-and-controls | "Commands are reusable prompts invoked via slash commands (e.g., /test), while workflows enable multi-step processes" |
|
||||
| modelMode | passive | https://cursor.com/docs/sdk/python | "The model used for a run can be overridden by passing a ModelSelection object in SendOptions to agent.send()." |
|
||||
| hookBus | host | https://cursor.com/docs/hooks | "Agent hooks: sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart, subagentStop, beforeShellExecution, afterShellExecution" |
|
||||
| stateIO | filesystem | https://cursor.com/docs/reference/sandbox | "Local agents run with sandbox options disabled by default." |
|
||||
| transport | mcp | https://cursor.com/docs/mcp | "The Model Context Protocol (MCP) allows Cursor to connect to external tools and data sources." |
|
||||
| runtime | node | https://cursor.com/docs/sdk/typescript | "The SDK runs on Node.js. It requires Node.js 22.13 or later and is described as a Node-first package." |
|
||||
| dispatch.namedDispatch | true | https://cursor.com/docs/subagents | "Invoke specific subagents using slash commands in your prompt. This allows for direct control over which agent performs" |
|
||||
| dispatch.nested | true | https://cursor.com/docs/sdk/typescript | "The top-level agent and its direct subagents can launch subagents, but a subagent launched by another subagent can't lau" |
|
||||
| dispatch.maxDepth | 2 | https://cursor.com/docs/sdk/typescript | "The top-level agent and its direct subagents can launch subagents, but a subagent launched by another subagent can't lau" |
|
||||
| dispatch.background | true | https://cursor.com/docs/subagents | "Background, which returns immediately while the subagent works independently, best for long-running tasks or parallel wo" |
|
||||
| dispatch.subagentToolkit | full | https://cursor.com/docs/subagents | "Subagents can utilize MCP tools, inheriting all tools available to their parent agent, including those from configured s" |
|
||||
| dispatch.backgroundDispatch | true | https://cursor.com/docs/subagents (FAQ: Can subagents launch other subagents?) and https://cursor.com/docs/sdk/typescript (Subagents > Nested subagents) | FAQ: "As of Cursor 2.5, subagents have the capability to launch child subagents, enabling the creation of a hierarchical structure for coordinated tasks. This nested launching functionality requires T |
|
||||
|
||||
Sources consulted:
|
||||
- https://cursor.com/docs/subagents
|
||||
- https://cursor.com/docs/hooks
|
||||
- https://cursor.com/docs/sdk/typescript
|
||||
- https://cursor.com/docs/sdk/python
|
||||
- https://cursor.com/docs/mcp
|
||||
- https://cursor.com/docs/reference/sandbox
|
||||
- https://cursor.com/docs/enterprise/llm-safety-and-controls
|
||||
- /websites/cursor (Context7)
|
||||
|
||||
---
|
||||
|
||||
## cline
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx | "Implement the AgentPlugin interface to register tools, hooks, and configuration. The setup function is used for registering capabilities." |
|
||||
| commandSurface | slash-file | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/cline/apps/vscode/src/test/slash-commands.test.ts | "workflow markdown files (with .md, .markdown, or .txt extensions) are invoked as slash commands using their filename." |
|
||||
| modelMode | active | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md | "The Runtime API, accessible via createLlmsRuntime(...), allows for the creation of a registry that manages configured providers and their de" |
|
||||
| hookBus | host | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/README.md | "Package agent capabilities as extensions (plugins) that can register tools, observe lifecycle events, and modify agent behavior." |
|
||||
| stateIO | filesystem | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/cline/sdk/packages/shared/src/storage/paths.ts | "resolveClineDir() returns ~/.cline; resolveDocumentsExtensionPath('Workflows') returns ~/Documents/Cline/Workflows." |
|
||||
| transport | mcp | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/mcp/mcp-overview.mdx | "MCP (Model Context Protocol) enables Cline to interact with external tools and data sources" |
|
||||
| runtime | node | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/examples/plugins/typescript-lsp/README.md | "Installs a portable subagent plugin ... cp examples/plugins/agents-squad/index.ts ~/.cline/plugins/portable-subagents.ts." |
|
||||
| dispatch.namedDispatch | true | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/sdk/examples/plugins/agents-squad/README.md | "parent → start_subagent(preset: \"phantom\", task: \"Map the auth module\") → phantom: save_handoff(...)" |
|
||||
| dispatch.nested | false | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "subagents are restricted from editing files, using the browser, accessing MCP servers, or creating nested subagents." |
|
||||
| dispatch.maxDepth | 1 | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "They are explicitly prohibited from ... spawning other subagents." |
|
||||
| dispatch.background | true | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "Commands executed by subagents run in the background and are strictly limited to read-only operations" |
|
||||
| dispatch.subagentToolkit | read-only | /cline/cline (Context7) — https://github.com/cline/cline/blob/main/docs/features/subagents.mdx | "Subagents are equipped with tools for read-only operations, including reading file contents (read_file), listing directo" |
|
||||
| dispatch.backgroundDispatch | false | https://docs.cline.bot/features/subagents (mirrored at https://github.com/cline/cline/blob/main/docs/features/subagents.mdx) | "They cannot edit files, use the browser, or spawn nested subagents" — and from the GitHub source: "subagents are restricted from editing files, using the browser, accessing MCP servers, or creating n |
|
||||
|
||||
Sources consulted:
|
||||
- https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
|
||||
- https://github.com/cline/cline/blob/main/sdk/README.md
|
||||
- https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
|
||||
- https://github.com/cline/cline/blob/main/sdk/examples/plugins/agents-squad/README.md
|
||||
- https://github.com/cline/cline/blob/main/docs/features/subagents.mdx
|
||||
- https://github.com/cline/cline/blob/main/docs/mcp/mcp-overview.mdx
|
||||
- https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
|
||||
- /cline/cline (Context7)
|
||||
|
||||
---
|
||||
|
||||
## hermes
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "ctx.register_tool() puts your tool in the registry — the model sees it immediately" |
|
||||
| commandSurface | slash-programmatic | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "ctx.register_command('mystatus', handler=_handle_status, description='Show plugin status') — The command appears in autocomplete, /help output" |
|
||||
| modelMode | active | https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin | "register_provider(ProviderProfile(name=..., aliases=(...), display_name=..., env_vars=(...), base_url=..., auth_type=..., default_aux_model=" |
|
||||
| hookBus | host | https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks | "Hermes owns and manages the entire hook infrastructure. At runtime, HookRegistry.discover_and_load() scans ~/.hermes/hooks/" |
|
||||
| stateIO | filesystem | https://hermes-agent.nousresearch.com/docs/user-guide/configuration | "The agent has the same filesystem access as your user account." |
|
||||
| transport | mcp | https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp | "MCP support ships with the standard install — no extra step needed." |
|
||||
| runtime | python | Context7 /nousresearch/hermes-agent | "The plugin and agent runtime is Python (confirmed by register(ctx) in __init__.py, importlib.import_module, run_agent.py, tools/registry.py)" |
|
||||
| dispatch.namedDispatch | false | https://hermes-agent.nousresearch.com/docs/user-guide/features/delegation | "The documentation contains no mention of named agents. Subagents are identified only by role ('leaf' or 'orchestrator')" |
|
||||
| dispatch.nested | true | /nousresearch/hermes-agent (Context7) — configuration.md | "max_spawn_depth: 1 — Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot dele" |
|
||||
| dispatch.maxDepth | 1 | /nousresearch/hermes-agent (Context7) — configuration.md | "max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot dele" |
|
||||
| dispatch.background | true | https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.19 | "delegate_task(background=true) dispatches a subagent that runs in the background and returns a handle immediately" |
|
||||
| dispatch.subagentToolkit | read-only | https://hermes-agent.nousresearch.com/docs/guides/delegation-patterns | "Nested delegation is opt-in; by default, leaf subagents cannot call delegate_task, clarify, memory, send_message, or exe" |
|
||||
| dispatch.backgroundDispatch | false | https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/delegation.md (via Context7 query of /nousresearch/hermes-agent) | "Nested delegation is an opt-in feature, requiring role=\"orchestrator\" for children and an increased max_spawn_depth from its default of 1. It can also be globally disabled with orchestrator_enabled |
|
||||
|
||||
Sources consulted:
|
||||
- https://hermes-agent.nousresearch.com/docs/user-guide/features/delegation
|
||||
- https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks
|
||||
- https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp
|
||||
- https://hermes-agent.nousresearch.com/docs/user-guide/configuration
|
||||
- https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin
|
||||
- https://hermes-agent.nousresearch.com/docs/guides/delegation-patterns
|
||||
- https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.19
|
||||
- /nousresearch/hermes-agent (Context7)
|
||||
|
||||
Documentation gaps:
|
||||
- runtime — Hermes plugins and agent core run in Python, but this was confirmed by code inspection rather than explicit docs statement.
|
||||
- dispatch.namedDispatch — docs explicitly confirm no named-agent dispatch in delegate_task; Kanban has named profiles but that is a separate board system not a dispatch mechanism.
|
||||
|
||||
---
|
||||
|
||||
## antigravity
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | declarative | https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md | "Skills require a SKILL.md file; Workflows are saved as markdown files; Rules are manually defined constraints — all configuration-file-based" |
|
||||
| commandSurface | slash-file | https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md | "Workflows are saved as markdown files, providing a repeatable method for executing key processes. They can be invoked in the Agent using a s" |
|
||||
| modelMode | passive | https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7 | "Selection occurs via `-m` flag or `/model` command inside the TUI. No programmatic model request API is documented for extensions/skills" |
|
||||
| hookBus | host | https://www.aibuilderclub.com/blog/antigravity-cli-guide | "The CLI fires hooks, not the engine. These are JSON lifecycle interceptors (before tool call, after file edit, on session start)." |
|
||||
| stateIO | filesystem | https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026 | "Plugin staging at ~/.gemini/antigravity-cli/plugins/<name>/; skills at ~/.gemini/antigravity-cli/skills/" |
|
||||
| transport | mcp | https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7 | "Both local (stdio) and remote (HTTP) Model Context Protocol servers are supported" |
|
||||
| runtime | go | https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/ | "Built in Go, Antigravity CLI is snappier and more responsive." |
|
||||
| dispatch.namedDispatch | undocumented | no authoritative doc — searched: https://www.aibuilderclub.com/blog/antigravity-cli-guide, https://antigravity.google/docs/agents | — |
|
||||
| dispatch.nested | undocumented | no authoritative doc — searched: https://antigravity.google/docs/agents | — |
|
||||
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://antigravity.google/docs/agents | — |
|
||||
| dispatch.background | true | https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/ | "Antigravity CLI orchestrates multiple agents for complex tasks in the background" |
|
||||
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026 | — |
|
||||
| dispatch.backgroundDispatch | undocumented | no authoritative doc — Multiple sources consulted: antigravity.google/docs/cli-subagents (returned blank/JS-rendered), antigravity.google/docs/agent (blank), github.com/google-antigravity/antigravity-cli README, Context7 /google-antigravity/antigravity-cli | All documentation consulted describes a two-level orchestrator→subagent architecture. Background subagents run asynchronously while the main agent continues accepting prompts. The DataCamp tutorial st |
|
||||
|
||||
Sources consulted:
|
||||
- https://github.com/alphaperseii3000/google-antigravity-docs/blob/master/google-antigravity-docs.md
|
||||
- https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/
|
||||
- https://dev.to/arindam_1729/antigravity-cli-a-hands-on-guide-to-googles-terminal-coding-agent-5bc7
|
||||
- https://www.explainx.ai/blog/antigravity-cli-features-sandbox-plugins-subagents-2026
|
||||
- https://www.aibuilderclub.com/blog/antigravity-cli-guide
|
||||
- https://antigravity.google/docs/agents
|
||||
- https://antigravity.google/docs/hooks
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.namedDispatch — docs describe dynamic plain-English goal dispatch where agent names subagents at runtime; no pre-registered named sub-agent API documented.
|
||||
- dispatch.nested — no documentation found on whether subagents can themselves spawn further subagents.
|
||||
- dispatch.maxDepth — no documented depth limit or explicit unbounded statement found.
|
||||
- dispatch.subagentToolkit — docs describe a permissions approval model but do not explicitly state 'full' vs 'read-only' toolkit scope for subagents.
|
||||
|
||||
---
|
||||
|
||||
## augment
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | declarative | https://docs.augmentcode.com/cli/plugins | "Plugins can provide several types of components, including Custom Commands defined in Markdown files within the `commands/` directory... Hoo" |
|
||||
| commandSurface | slash-file | https://docs.augmentcode.com/cli/plugins | "Slash commands are Markdown files in the `commands/` directory. The filename becomes the command name" |
|
||||
| modelMode | passive | https://docs.augmentcode.com/cli/subagents | "| model | No | Model to use for the agent. If not specified, the CLI default model is used." |
|
||||
| hookBus | host | https://docs.augmentcode.com/cli/hooks | "Hook event types: PreToolUse (before a tool executes), PostToolUse (immediately after a tool completes), Stop (when the agent stops respondi" |
|
||||
| stateIO | filesystem | https://github.com/augmentcode/auggie | "Node.js 22+ required. Hook configurations use `${AUGMENT_PLUGIN_ROOT}`" |
|
||||
| transport | mcp | https://docs.augmentcode.com/cli/plugins | "Auggie supports a plugin system that allows you to extend its functionality with... MCP server integrations." |
|
||||
| runtime | node | https://github.com/augmentcode/auggie | "Node.js 22+ required" |
|
||||
| dispatch.namedDispatch | true | https://docs.augmentcode.com/cli/subagents | "| **name** | Yes | Name of the agent | ... you can trigger it by sending a message that references the agent name." |
|
||||
| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.augmentcode.com/cli/subagents | — |
|
||||
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.augmentcode.com/cli/subagents | — |
|
||||
| dispatch.background | true | https://docs.augmentcode.com/cli/subagents | "Subagents run in parallel with other subagents... will show a summary of their current progress in the main thread." |
|
||||
| dispatch.subagentToolkit | full | https://docs.augmentcode.com/cli/subagents | "If neither [tools nor disabled_tools] is specified, the subagent has access to all tools (default behavior)." |
|
||||
| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.augmentcode.com/cosmos/automations | The Augment Code (Cosmos) docs describe workers as 'sub-agents launched mid-session by a manager Expert using the worker-launch command. Each worker is its own session with its own messages and permis |
|
||||
|
||||
Sources consulted:
|
||||
- https://docs.augmentcode.com/cli/plugins
|
||||
- https://docs.augmentcode.com/cli/hooks
|
||||
- https://docs.augmentcode.com/cli/subagents
|
||||
- https://docs.augmentcode.com/cli/sdk-typescript
|
||||
- https://docs.augmentcode.com/setup-augment/mcp
|
||||
- https://github.com/augmentcode/auggie
|
||||
- /llmstxt/augmentcode_llms-full_txt (Context7)
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.nested
|
||||
- dispatch.maxDepth
|
||||
|
||||
---
|
||||
|
||||
## qwen
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Your entry point exports a ChannelPlugin object... this.registerCommand('mycommand', async (envelope, args) => { ... }); ... plugins load at startup as extensions." |
|
||||
| commandSurface | slash-file | https://qwenlm.github.io/qwen-code-docs/en/users/extension/introduction | "Extensions can provide custom commands by placing Markdown files in a commands/ subdirectory" |
|
||||
| modelMode | passive | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "The documentation does not expose a direct API for plugins to invoke the LLM or model directly." |
|
||||
| hookBus | host | https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks | "Qwen Code provides 14 distinct hook events: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, SessionStart, SessionEnd, Stop" |
|
||||
| stateIO | filesystem | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Runtime Environment: Node.js only. The architecture uses standard Node.js APIs: import, async/await, file I/O (writeFileSync), OS utilities" |
|
||||
| transport | mcp | https://qwenlm.github.io/qwen-code-docs/en/developers/tools/mcp-server | "Qwen Code integrates with MCP servers through a sophisticated discovery and execution system" |
|
||||
| runtime | node | https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins | "Language: Node.js (TypeScript/JavaScript). Execution model: In-process — plugins load at startup as extensions." |
|
||||
| dispatch.namedDispatch | true | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Named subagents are invoked when the AI identifies tasks matching their specialization... Users can also explicitly requ" |
|
||||
| dispatch.nested | false | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Fork children cannot create further forks. This is enforced at runtime — if a fork attempts to spawn another fork, it re" |
|
||||
| dispatch.maxDepth | 1 | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Fork children cannot create further forks. This is enforced at runtime" |
|
||||
| dispatch.background | true | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "Runs in background, parent continues immediately... Forks run parallel to the parent; the main conversation continues im" |
|
||||
| dispatch.subagentToolkit | full | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ | "When omitted, the subagent inherits all available tools from the parent session." |
|
||||
| dispatch.backgroundDispatch | false | https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/ (official Qwen Code documentation, 'Subagents' user guide page) and https://qwenlm.github.io/qwen-code-docs/en/design/fork-subagent/fork-subagent-design (Qwen Code fork-subagent design document, section '4. Recursive Fork Prevention') | The official user-facing Qwen Code docs state verbatim: "Fork children cannot create further forks. If a fork attempts spawning another fork, it receives an error instructing direct task execution ins |
|
||||
|
||||
Sources consulted:
|
||||
- https://qwenlm.github.io/qwen-code-docs/en/developers/channel-plugins
|
||||
- https://qwenlm.github.io/qwen-code-docs/en/users/features/sub-agents/
|
||||
- https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks
|
||||
- https://qwenlm.github.io/qwen-code-docs/en/users/extension/introduction
|
||||
- https://qwenlm.github.io/qwen-code-docs/en/developers/tools/mcp-server
|
||||
- /websites/qwenlm_github_io_qwen-code-docs_en (Context7)
|
||||
- /qwenlm/qwen-code (Context7)
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.nested — docs only restrict fork-type sub-agents from nesting; whether named sub-agents can themselves spawn named sub-agents is not stated.
|
||||
- dispatch.maxDepth — depth=1 is documented only for fork sub-agents; depth for named sub-agent chains is undocumented.
|
||||
|
||||
---
|
||||
|
||||
## codebuddy
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | declarative | https://www.codebuddy.ai/docs/cli/plugins-reference | "Commands are 'plain Markdown file[s]' located in commands/ by default ... a skill is a directory containing a SKILL.md ... The documentation" |
|
||||
| commandSurface | slash-file | https://www.codebuddy.ai/docs/cli/plugins-reference | "Commands are 'plain Markdown file[s]' located in commands/ by default ... Skills are prefixed with this (e.g., /my-first-plugin:hello)" |
|
||||
| modelMode | passive | https://www.codebuddy.ai/docs/cli/sdk | "The SDK is not for building plugins that run inside CodeBuddy. It's an external SDK for standalone applications" |
|
||||
| hookBus | host | https://www.codebuddy.ai/docs/cli/hooks | "Full support for the hook event family (27+ events), covering tool lifecycle (PreToolUse / PostToolUse / PostToolUseFailure)" |
|
||||
| stateIO | filesystem | https://www.codebuddy.ai/docs/cli/settings | "Storage operates in non-sandboxed mode by default ... Default: Full filesystem access governed by permission rules" |
|
||||
| transport | mcp | https://www.codebuddy.ai/docs/cli/cli-reference | "MCP (Model Context Protocol) is built-in as a core feature ... codebuddy mcp command to 'Configure Model Context Protocol (MCP) servers'" |
|
||||
| runtime | node | https://www.codebuddy.ai/docs/cli/sdk | "TypeScript/JavaScript: Node.js >= 18.20 ... npm install @tencent-ai/agent-sdk" |
|
||||
| dispatch.namedDispatch | true | https://www.codebuddy.ai/docs/cli/sub-agents | "Sub-agents can be invoked explicitly by name: 'Request a specific sub-agent by mentioning it in your command'" |
|
||||
| dispatch.nested | false | https://www.codebuddy.ai/docs/cli/sub-agents | "This prevents infinite nesting of agents (sub-agents cannot spawn other sub-agents)" |
|
||||
| dispatch.maxDepth | 1 | https://www.codebuddy.ai/docs/cli/sub-agents | "The architecture enforces exactly one level of nesting — only the main CodeBuddy Code instance can invoke sub-agents." |
|
||||
| dispatch.background | true | https://www.codebuddy.ai/docs/cli/sub-agents | "Launch a background agent using the run_in_background: true parameter ... Tasks return immediately with an ID" |
|
||||
| dispatch.subagentToolkit | full | https://www.codebuddy.ai/docs/cli/sub-agents | "By default, sub-agents inherit all tools when the tools field is omitted ... Sub-agents can access MCP tools from config" |
|
||||
| dispatch.backgroundDispatch | false | https://www.codebuddy.ai/docs/cli/sub-agents | "This prevents infinite nesting of agents (sub-agents cannot spawn other sub-agents)" — the restriction is stated as universal in the Sub-Agents documentation page. The daemon/background docs (https:/ |
|
||||
|
||||
Sources consulted:
|
||||
- https://www.codebuddy.ai/docs/cli/plugins
|
||||
- https://www.codebuddy.ai/docs/cli/plugins-reference
|
||||
- https://www.codebuddy.ai/docs/cli/sub-agents
|
||||
- https://www.codebuddy.ai/docs/cli/hooks
|
||||
- https://www.codebuddy.ai/docs/cli/sdk
|
||||
- https://www.codebuddy.ai/docs/cli/settings
|
||||
- /websites/codebuddy_cn (Context7)
|
||||
|
||||
---
|
||||
|
||||
## copilot
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | declarative | https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features | "Declarative elements include custom instructions, skills, custom agents, and plugin configurations—all defined through configuration files" |
|
||||
| commandSurface | slash-file | https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features | "Skills: Markdown files with instructions for specific contexts. Users can invoke via slash commands (e.g., /Markdown-Checker check README.md)" |
|
||||
| modelMode | passive | https://github.com/github/copilot-sdk/blob/main/docs/auth/byok.md | "Model selection via config: model: 'gpt-4.1', provider: { type: 'openai', ... }." |
|
||||
| hookBus | host | https://docs.github.com/en/copilot/reference/hooks-reference | "Hooks allow you to extend and customize the behavior of GitHub Copilot agents by executing custom shell commands at key points during agent" |
|
||||
| stateIO | filesystem | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers | "Configuration file Location: ~/.copilot/mcp-config.json. Hook config files stored in .github/hooks/*.json" |
|
||||
| transport | mcp | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers | "Copilot CLI comes with the GitHub MCP server already configured. STDIO is the standard transport." |
|
||||
| runtime | undocumented | no authoritative doc — searched: https://github.com/github/copilot-cli/blob/main/README.md, https://github.com/github/copilot-sdk/blob/main/nodejs/README.md | — |
|
||||
| dispatch.namedDispatch | true | https://github.com/github/copilot-sdk/blob/main/docs/features/custom-agents.md | "A custom agent is a named agent configuration that includes its own prompt and tool set. A sub-agent is a custom agent i" |
|
||||
| dispatch.nested | false | https://awesome-copilot.github.com/learning-hub/agents-and-subagents/ | "By default, subagents do not keep spawning additional subagents." |
|
||||
| dispatch.maxDepth | 1 | https://awesome-copilot.github.com/learning-hub/agents-and-subagents/ | "Depth counts how many agents are nested within one another. When the depth limit is reached, the innermost agent cannot" |
|
||||
| dispatch.background | true | https://docs.github.com/en/copilot/how-tos/copilot-cli/speed-up-task-completion | "Allow Copilot to use subagents and work autonomously to implement the plan without any further input." |
|
||||
| dispatch.subagentToolkit | full | https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli | "By default, custom agents have access to all tools. If you restrict an agent's access, a tools specification is added" |
|
||||
| dispatch.backgroundDispatch | false | https://code.visualstudio.com/docs/copilot/agents/subagents | "By default, subagents cannot spawn further subagents. This prevents infinite recursion when agents accidentally call themselves in a loop." The setting `chat.subagents.allowInvocationsFromSubagents` |
|
||||
|
||||
Sources consulted:
|
||||
- https://github.com/github/copilot-cli/blob/main/README.md (via Context7 /github/copilot-cli)
|
||||
- https://github.com/github/copilot-sdk/blob/main/docs/features/custom-agents.md (via Context7 /github/copilot-sdk)
|
||||
- https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers
|
||||
- https://docs.github.com/en/copilot/reference/hooks-reference
|
||||
- https://docs.github.com/en/copilot/concepts/agents/copilot-cli/comparing-cli-features
|
||||
- https://awesome-copilot.github.com/learning-hub/agents-and-subagents/
|
||||
|
||||
Documentation gaps:
|
||||
- runtime — docs describe the CLI binary and the SDK (Node.js/Go/Python/Rust) but do not state what runtime the CLI host itself or its plugin/extension loader executes in.
|
||||
- dispatch.nested exact authoritative source is awesome-copilot.github.com (community docs) not docs.github.com.
|
||||
|
||||
---
|
||||
|
||||
## kilo
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | https://kilo.ai/docs/automate/extending/plugins | "Plugins extend Kilo by hooking into events and adding functionality. They can: add custom tools the model can call (like read, write, bash)" |
|
||||
| commandSurface | slash-file | https://kilo.ai/docs/customize/workflows | "Workflows, also known as slash commands, allow users to automate repetitive tasks by defining step-by-step instructions" |
|
||||
| modelMode | active | https://kilo.ai/docs/automate/extending/plugins | "provider — dynamically supply model catalogs. auth — register OAuth or API-key flows for model providers. chat.params — Mutate temperature" |
|
||||
| hookBus | host | https://kilo.ai/docs/automate/extending/plugins | "event — fires for every internal bus event. Session: session.created, session.updated, session.idle, session.error, session.deleted" |
|
||||
| stateIO | filesystem | https://kilo.ai/docs/contributing/architecture | "Local execution and hosted execution are separate boundaries. Local runtime instances are Directory-keyed runtime context" |
|
||||
| transport | mcp | https://kilo.ai/docs/automate/mcp/what-is-mcp | "Kilo Code implements the Model Context Protocol to connect to both local and remote MCP servers" |
|
||||
| runtime | bun | https://kilo.ai/docs/automate/extending/plugins | "npm plugins are installed automatically at startup using Bun. Plugin context includes $ (Bun shell). Plugins are TypeScript or JavaScript mo" |
|
||||
| dispatch.namedDispatch | true | https://kilo.ai/docs/customize/custom-subagents | "Configured subagents can be invoked automatically by primary agents (like the Orchestrator) using the Task tool" |
|
||||
| dispatch.nested | true | https://github.com/Kilo-Org/kilocode/issues/7055 | "A subagent can still call the task tool if its merged permissions contain an explicit task rule, which enables nested su" |
|
||||
| dispatch.maxDepth | -1 | https://github.com/Kilo-Org/kilocode/issues/8637 | "there is no maximum nesting depth and the system relies entirely on permission gating" |
|
||||
| dispatch.background | true | https://kilo.ai/docs/code-with-ai/agents/orchestrator-mode | "Agents are also capable of launching multiple subagent sessions concurrently to facilitate parallel processing." |
|
||||
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://kilo.ai/docs/customize/custom-subagents | — |
|
||||
| dispatch.backgroundDispatch | false | https://kilo.ai/docs/automate/tools/new-task | "Importantly, subagents cannot spawn further subagents; only primary agents can use the `new_task` tool." |
|
||||
|
||||
Sources consulted:
|
||||
- https://kilo.ai/docs/automate/extending/plugins
|
||||
- https://kilo.ai/docs/customize/custom-subagents
|
||||
- https://kilo.ai/docs/customize/workflows
|
||||
- https://kilo.ai/docs/automate/mcp/what-is-mcp
|
||||
- https://kilo.ai/docs/code-with-ai/agents/orchestrator-mode
|
||||
- https://kilo.ai/docs/contributing/architecture
|
||||
- https://github.com/Kilo-Org/kilocode/issues/7055
|
||||
- https://github.com/Kilo-Org/kilocode/issues/8637
|
||||
- /websites/kilo_ai (Context7)
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.subagentToolkit — docs describe per-subagent configurable permissions (allow/ask/deny) but do not document a single default toolkit level (full vs read-only) for subagents that lack explicit permission overrides.
|
||||
|
||||
---
|
||||
|
||||
## windsurf
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | declarative | https://docs.devin.ai/desktop/cascade/cascade | "Cascade operates through configuration files rather than code plugins: .codeiumignore for file filtering, Memories and Rules for customizing" |
|
||||
| commandSurface | slash-file | https://docs.devin.ai/desktop/cascade/workflows | "Workflows are authored as markdown files (.md extension) … triggered through slash commands using the format /[workflow-name]." |
|
||||
| modelMode | passive | https://docs.devin.ai/desktop/models.md | "Models are selectable via configuration/UI only (SWE-1.5, SWE-1.6, Adaptive, Arena tiers, Claude, GPT)." |
|
||||
| hookBus | host | https://docs.devin.ai/desktop/cascade/hooks.md | "Cascade supports twelve hook events covering critical workflow points … Pre-hooks (can block actions): pre_read_code, pre_write_code, pre_ru" |
|
||||
| stateIO | filesystem | https://docs.devin.ai/desktop/cascade/cascade | "Cascade can create and modify codebases directly … File access can be restricted through .codeiumignore files" |
|
||||
| transport | mcp | https://docs.devin.ai/desktop/cascade/mcp | "Cascade now natively integrates with MCP, allowing you to bring your own selection of MCP servers for Cascade to use." |
|
||||
| runtime | undocumented | no authoritative doc — searched: https://docs.devin.ai/windsurf/plugins/getting-started.md, /llmstxt/windsurf_llms-full_txt (Context7) | — |
|
||||
| dispatch.namedDispatch | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md, https://docs.devin.ai/desktop/agent-command-center.md | — |
|
||||
| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — |
|
||||
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — |
|
||||
| dispatch.background | undocumented | no authoritative doc — searched: https://docs.devin.ai/desktop/acp.md, https://docs.devin.ai/cli/subagents.md | — |
|
||||
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://docs.devin.ai/cli/subagents.md | — |
|
||||
| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.devin.ai/desktop/cascade/cascade and https://docs.devin.ai/desktop/devin-local (official Windsurf/Devin docs, via docs.windsurf.com redirects) | The Windsurf/Cascade docs describe a background planning agent only in these terms: "In the background, a specialized planning agent continuously refines the long-term plan while your selected model f |
|
||||
|
||||
Sources consulted:
|
||||
- https://docs.devin.ai/desktop/cascade/workflows
|
||||
- https://docs.devin.ai/desktop/cascade/mcp
|
||||
- https://docs.devin.ai/desktop/cascade/hooks.md
|
||||
- https://docs.devin.ai/desktop/cascade/cascade
|
||||
- https://docs.devin.ai/desktop/models.md
|
||||
- https://docs.devin.ai/windsurf/plugins/getting-started.md
|
||||
- https://docs.devin.ai/cli/subagents.md
|
||||
- /llmstxt/windsurf_llms-full_txt (Context7)
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.namedDispatch — Cascade docs do not document a user-facing named sub-agent dispatch system.
|
||||
- dispatch.nested — no documentation for nested sub-agent support in Windsurf Cascade.
|
||||
- dispatch.maxDepth — no documented depth limit for Cascade sub-agents.
|
||||
- dispatch.background — Cascade has an internal background planning agent but no documented user-facing background sub-agent dispatch.
|
||||
- dispatch.subagentToolkit — no documentation for toolkit restrictions on Cascade sub-agents.
|
||||
- runtime — Windsurf IDE is Electron-based but no programmatic plugin runtime is documented to developers.
|
||||
|
||||
---
|
||||
|
||||
## trae
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | https://traeide.com/docs/how-to-manage-extensions-in-trae-ide | "Trae IDE is a VSCode fork; 'If an extension isn't available in Trae's store, you can install it from VS Code's marketplace' — inherits VSCode in-process extension model" |
|
||||
| commandSurface | slash-file | https://docs.trae.ai/ide/skills | "Skills stored as SKILL.md files in '.trae/skills/{skill_name}/' directory; 'Trae allows you to manually trigger skills if needed'" |
|
||||
| modelMode | passive | https://docs.trae.ai/ide/models | "Model selection via UI: 'click on the current model name to open the model list'; no programmatic model/LLM request API documented for plugins" |
|
||||
| hookBus | engine | https://news.ycombinator.com/item?id=44703164 | "Trae is 'ByteDance's VSCode fork' built on Electron/Monaco; inherits VSCode extension host lifecycle (activate/deactivate hooks, event subsc" |
|
||||
| stateIO | filesystem | https://traeide.com/news/6 | "Rules at '.trae/project_rules.md', skills at '.trae/skills/', MCP config at '.trae/mcp.json'; 'codebase files always remain on your local de" |
|
||||
| transport | mcp | https://docs.trae.ai/ide/model-context-protocol | "Page title from official docs: 'In TRAE IDE, MCP servers support three transport types' — MCP is built-in" |
|
||||
| runtime | node | https://news.ycombinator.com/item?id=44703164 | "Trae is a VSCode fork built on Electron; 'Electron is designed to create desktop applications… a backend using the Node.js runtime'" |
|
||||
| dispatch.namedDispatch | true | https://docs.trae.ai/ide/agent | "Agents in Trae 'can be called individually, or automatically called by SOLO Agent at the corresponding stage'" |
|
||||
| dispatch.nested | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/solo-mode, https://docs.trae.ai/ide/agent | — |
|
||||
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/solo-mode | — |
|
||||
| dispatch.background | true | https://news.aibase.com/news/22829 | "SOLO 'supports multi-tasking, allowing you to work on multiple development tasks simultaneously'; 'run multiple agents i" |
|
||||
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://docs.trae.ai/ide/agent | — |
|
||||
| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://docs.trae.ai/ide/agent; https://github.com/bytedance/trae-agent/blob/main/docs/roadmap.md | Trae's official documentation (docs.trae.ai) and the trae-agent GitHub roadmap do not document background/async agent dispatch or whether a background-spawned agent can itself spawn further sub-agents |
|
||||
|
||||
Sources consulted:
|
||||
- https://docs.trae.ai/ide/model-context-protocol
|
||||
- https://docs.trae.ai/ide/agent
|
||||
- https://docs.trae.ai/ide/skills
|
||||
- https://docs.trae.ai/ide/solo-mode
|
||||
- https://docs.trae.ai/ide/solo-coder
|
||||
- https://traeide.com/news/6
|
||||
- https://traeide.com/docs/how-to-manage-extensions-in-trae-ide
|
||||
- https://news.ycombinator.com/item?id=44703164
|
||||
- https://news.aibase.com/news/22829
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.nested — docs describe two-tier orchestration (SOLO → named agents) but do not state whether a spawned sub-agent can itself spawn further sub-agents.
|
||||
- dispatch.maxDepth — no integer depth limit documented beyond one orchestrator level.
|
||||
- dispatch.subagentToolkit — docs say agents can be configured with 'callable MCP services and other capabilities' but do not state whether sub-agents receive a full vs. restricted tool set.
|
||||
|
||||
---
|
||||
|
||||
## kimi
|
||||
|
||||
| Axis | Value | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| embeddingMode | imperative | https://context7.com/moonshotai/kimi-cli/llms.txt | "from kimi_cli.app import KimiCLI, enable_logging ... instance = await KimiCLI.create(session, agent_file=myagent) ... class Ls(CallableTool2)" |
|
||||
| commandSurface | slash-file | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/skills.md | "/skill:code-style ... /flow:code-review — Skills are SKILL.md markdown files with YAML frontmatter that become /skill:<name> and /flow:<name>" |
|
||||
| modelMode | passive | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/configuration/providers.md | "Use the `/model` command to switch between available models and thinking modes ... `--model` option overrides the default model" |
|
||||
| hookBus | host | https://moonshotai.github.io/kimi-cli/en/customization/hooks.html | "Core: Add hooks system (Beta) — configure `[[hooks]]` in `config.toml` to run custom shell commands at 13 lifecycle events including `PreToo" |
|
||||
| stateIO | filesystem | https://github.com/MoonshotAI/kimi-cli | "Kimi Code CLI is an AI agent that runs in the terminal ... capable of reading and editing code, executing shell commands, searching files" |
|
||||
| transport | mcp | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/reference/kimi-mcp.md | "kimi mcp add ... --transport stdio|http ... Manage MCP Servers: Use the kimi mcp sub-command group to add, list, remove, or authorize MCP se" |
|
||||
| runtime | python | https://context7.com/moonshotai/kimi-cli/llms.txt | "from kimi_cli.app import KimiCLI ... from kosong.tooling import CallableTool2 — CLI core is Python" |
|
||||
| dispatch.namedDispatch | true | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "subagents:\n coder:\n path: ./coder-sub.yaml\n description: \"Handle coding tasks\"\n reviewer:\n path: ./reviewer-sub.yaml" |
|
||||
| dispatch.nested | false | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "All subagent types are prohibited from nesting the `Agent` tool (subagents cannot create their own subagents). Only root" |
|
||||
| dispatch.maxDepth | 1 | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "All subagent types are prohibited from nesting the `Agent` tool (subagents cannot create their own subagents). Only root" |
|
||||
| dispatch.background | true | https://moonshotai.github.io/kimi-cli/en/customization/agents.html | "Subagents support foreground and background modes. The `run_in_background` parameter allows tasks to execute asynchronou" |
|
||||
| dispatch.subagentToolkit | undocumented | no authoritative doc — searched: https://moonshotai.github.io/kimi-cli/en/customization/agents.html | — |
|
||||
| dispatch.backgroundDispatch | false | https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/agents.md (also mirrored at https://moonshotai.github.io/kimi-cli/en/customization/agents.html) | "All subagent types are prohibited from nesting the `Agent` tool, meaning subagents cannot create their own subagents. Only the root agent has access to the `Agent` tool for launching further subagent |
|
||||
|
||||
Sources consulted:
|
||||
- https://moonshotai.github.io/kimi-cli/en/customization/hooks.html
|
||||
- https://moonshotai.github.io/kimi-cli/en/customization/agents.html
|
||||
- https://github.com/MoonshotAI/kimi-cli
|
||||
- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/skills.md
|
||||
- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/customization/agents.md
|
||||
- https://github.com/moonshotai/kimi-cli/blob/main/docs/en/reference/kimi-mcp.md
|
||||
- https://context7.com/moonshotai/kimi-cli/llms.txt
|
||||
- /moonshotai/kimi-cli (Context7)
|
||||
|
||||
Documentation gaps:
|
||||
- dispatch.subagentToolkit — docs show three built-in subagent types each with different tool subsets (coder=full, explore=read-only, plan=no shell/write); no single 'full' or 'read-only' value covers all types; maintainer should clarify the intended classification.
|
||||
- runtime — CLI core is Python; a Rust Wire implementation also exists; docs do not state a canonical plugin extension runtime.
|
||||
627
eslint-rules/lib/platform-guard.cjs
Normal file
627
eslint-rules/lib/platform-guard.cjs
Normal file
@@ -0,0 +1,627 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* platform-guard.cjs — precision backbone for no-path-literal-in-assert.
|
||||
*
|
||||
* Exported API:
|
||||
* classifyPlatformTest(node) → 'windows' | 'not-windows' | null
|
||||
* isWindowsExcludedNode(node, sourceCode) → boolean
|
||||
*
|
||||
* Shapes handled by isWindowsExcludedNode:
|
||||
*
|
||||
* (A) Consequent of `if (<not-windows test>) { ... }`:
|
||||
* if (process.platform !== 'win32') { <node> }
|
||||
*
|
||||
* (B) Alternate of `if (<windows test>) { ... } else { <node> }`:
|
||||
* if (process.platform === 'win32') { ... } else { <node> }
|
||||
*
|
||||
* (C) A preceding sibling IfStatement that is a Windows early-return guard,
|
||||
* making the node unreachable on Windows:
|
||||
* if (process.platform === 'win32') return;
|
||||
* if (process.platform === 'win32') return t.skip(...);
|
||||
* if (process.platform === 'win32') { ...; return; }
|
||||
*
|
||||
* (D) Hoisted windows-boolean consumed by (A)/(B)/(C). Both the conventional
|
||||
* names (isWindows, IS_WINDOWS, isWin, onWindows) AND arbitrary-named
|
||||
* variables (e.g. `const winFlag = process.platform === 'win32'`) are
|
||||
* resolved by looking up the variable's initializer in the enclosing scope
|
||||
* and classifying that expression. Negation (`!winFlag`) is applied after
|
||||
* the lookup, so `if (!winFlag)` is correctly recognized as a not-windows
|
||||
* guard when winFlag was initialized to a windows test.
|
||||
*
|
||||
* If a shape is genuinely ambiguous, the function returns false so the rule
|
||||
* errs toward reporting — the fix is to teach this helper, never an opt-out.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Identifier names conventionally used for "is this Windows?" booleans.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const WINDOWS_BOOL_NAMES = new Set(['isWindows', 'IS_WINDOWS', 'isWin', 'onWindows']);
|
||||
|
||||
/**
|
||||
* Classify a test expression as a Windows test, not-Windows test, or unrelated.
|
||||
*
|
||||
* Recognized forms:
|
||||
* - `process.platform === 'win32'` → 'windows'
|
||||
* - `process.platform !== 'win32'` → 'not-windows'
|
||||
* - `os.platform() === 'win32'` → 'windows'
|
||||
* - `os.platform() !== 'win32'` → 'not-windows'
|
||||
* - `isWindows` / `IS_WINDOWS` / etc → 'windows'
|
||||
* - `!isWindows` / etc → 'not-windows'
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {'windows' | 'not-windows' | null}
|
||||
*/
|
||||
function classifyPlatformTest(node) {
|
||||
if (!node) return null;
|
||||
|
||||
// Binary: X === 'win32' or X !== 'win32' or 'win32' === X etc.
|
||||
if (node.type === 'BinaryExpression' && (node.operator === '===' || node.operator === '!==')) {
|
||||
const { left, right, operator } = node;
|
||||
if (_isPlatformExpr(left) && _isWin32Literal(right)) {
|
||||
return operator === '===' ? 'windows' : 'not-windows';
|
||||
}
|
||||
if (_isPlatformExpr(right) && _isWin32Literal(left)) {
|
||||
return operator === '===' ? 'windows' : 'not-windows';
|
||||
}
|
||||
}
|
||||
|
||||
// Bare identifier: isWindows, IS_WINDOWS, isWin, onWindows
|
||||
if (node.type === 'Identifier' && WINDOWS_BOOL_NAMES.has(node.name)) {
|
||||
return 'windows';
|
||||
}
|
||||
|
||||
// Negated: !isWindows
|
||||
if (
|
||||
node.type === 'UnaryExpression' &&
|
||||
node.operator === '!' &&
|
||||
node.argument.type === 'Identifier' &&
|
||||
WINDOWS_BOOL_NAMES.has(node.argument.name)
|
||||
) {
|
||||
return 'not-windows';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True if node is `process.platform` or `os.platform()` */
|
||||
function _isPlatformExpr(node) {
|
||||
// process.platform
|
||||
if (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === 'process' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'platform'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// os.platform()
|
||||
if (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
!node.callee.computed &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.object.name === 'os' &&
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
node.callee.property.name === 'platform'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if node is the string literal 'win32' */
|
||||
function _isWin32Literal(node) {
|
||||
return node.type === 'Literal' && node.value === 'win32';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `targetNode` only executes on non-Windows because it is
|
||||
* control-dependent on one of the recognized Windows-guard shapes.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} targetNode
|
||||
* @param {import('eslint').SourceCode} sourceCode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWindowsExcludedNode(targetNode, sourceCode) {
|
||||
// Walk ancestors bottom-up to find a guarding IfStatement.
|
||||
const ancestors = _getAncestors(targetNode, sourceCode);
|
||||
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const ancestor = ancestors[i];
|
||||
|
||||
if (ancestor.type !== 'IfStatement') continue;
|
||||
|
||||
const testClassification = _classifyPlatformTestWithHoisting(
|
||||
ancestor.test,
|
||||
targetNode,
|
||||
sourceCode
|
||||
);
|
||||
|
||||
if (!testClassification) continue;
|
||||
|
||||
// Determine which branch targetNode is in
|
||||
const inConsequent = _containsNode(ancestor.consequent, targetNode);
|
||||
const inAlternate = ancestor.alternate != null && _containsNode(ancestor.alternate, targetNode);
|
||||
|
||||
if (inConsequent && testClassification === 'not-windows') {
|
||||
// if (platform !== 'win32') { <target> } → excluded
|
||||
return true;
|
||||
}
|
||||
if (inAlternate && testClassification === 'windows') {
|
||||
// if (platform === 'win32') { … } else { <target> } → excluded
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for early-return guards in the same block as the target node
|
||||
if (_hasEarlyWindowsReturnBefore(targetNode, sourceCode)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a test expression, resolving hoisted windows-boolean variables.
|
||||
*
|
||||
* C3 (binding-aware): for bare Identifier or !Identifier test forms, this
|
||||
* function resolves the variable's binding in the lexical scope:
|
||||
*
|
||||
* 1. If `sourceCode.getScope` is available (real ESLint rule context), use
|
||||
* it to resolve the NEAREST binding of the identifier, walking scope.upper
|
||||
* so inner shadows take priority. If a binding is found in-file:
|
||||
* a. Classify the initializer — not a platform test → return null.
|
||||
* b. Check for reassignment (any write reference after init) → return null.
|
||||
* c. Otherwise return the init classification (with negation applied).
|
||||
* If NO in-file binding exists (global/import), fall through to the name
|
||||
* heuristic below.
|
||||
*
|
||||
* 2. AST-walk fallback (unit-test contexts without live scope): for identifiers
|
||||
* NOT in WINDOWS_BOOL_NAMES, use _resolveIdentifierInitBindingAware which
|
||||
* respects inner shadows and reassignment. For names IN WINDOWS_BOOL_NAMES
|
||||
* with no in-file binding found, apply the name heuristic.
|
||||
*
|
||||
* 3. Direct platform expressions (`process.platform === 'win32'`, etc.) are
|
||||
* classified directly (no change from before).
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} testNode — the IfStatement's .test
|
||||
* @param {import('eslint').Rule.Node} targetNode — the node we are checking
|
||||
* @param {import('eslint').SourceCode} sourceCode
|
||||
* @returns {'windows' | 'not-windows' | null}
|
||||
*/
|
||||
function _classifyPlatformTestWithHoisting(testNode, targetNode, sourceCode) {
|
||||
// Step 1: try direct classification of platform expressions
|
||||
// (BinaryExpression process.platform === 'win32', etc.)
|
||||
// Do NOT use classifyPlatformTest here for the bare-identifier forms —
|
||||
// we want binding-aware resolution for those.
|
||||
const directBinary = _classifyPlatformExprOnly(testNode);
|
||||
if (directBinary) return directBinary;
|
||||
|
||||
// Extract the identifier and negation flag from the test expression.
|
||||
let identNode = null;
|
||||
let negated = false;
|
||||
|
||||
if (testNode.type === 'Identifier') {
|
||||
identNode = testNode;
|
||||
negated = false;
|
||||
} else if (
|
||||
testNode.type === 'UnaryExpression' &&
|
||||
testNode.operator === '!' &&
|
||||
testNode.argument.type === 'Identifier'
|
||||
) {
|
||||
identNode = testNode.argument;
|
||||
negated = true;
|
||||
}
|
||||
|
||||
if (!identNode) return null;
|
||||
|
||||
const identName = identNode.name;
|
||||
|
||||
// Step 2: binding-aware resolution via ESLint scope (when available).
|
||||
if (typeof sourceCode.getScope === 'function') {
|
||||
const scopeResult = _resolveIdentifierViaScope(identNode, identName, negated, sourceCode);
|
||||
// scopeResult is one of:
|
||||
// 'windows' | 'not-windows' — binding found, init classifies as platform test
|
||||
// null — binding found but doesn't classify (or reassigned)
|
||||
// 'no-binding' — no in-file binding; fall through to name heuristic
|
||||
if (scopeResult !== 'no-binding') return scopeResult;
|
||||
// Fall through: no in-file binding → name heuristic below.
|
||||
} else {
|
||||
// AST-walk fallback (unit-test contexts without live scope).
|
||||
// Use binding-aware AST walk for ALL names.
|
||||
const astResult = _resolveIdentifierInitBindingAware(identName, identNode, targetNode, sourceCode);
|
||||
if (astResult !== 'no-binding') {
|
||||
if (!astResult) return null;
|
||||
return negated
|
||||
? (astResult === 'windows' ? 'not-windows' : 'windows')
|
||||
: astResult;
|
||||
}
|
||||
// No binding found via AST walk → fall through to name heuristic.
|
||||
}
|
||||
|
||||
// Step 3: name heuristic — only for globally-recognized Windows bool names
|
||||
// that have no in-file binding (imported/global constants like `isWindows`
|
||||
// imported from a test helper).
|
||||
if (WINDOWS_BOOL_NAMES.has(identName)) {
|
||||
return negated ? 'not-windows' : 'windows';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a BinaryExpression or os.platform() call as a platform test.
|
||||
* Does NOT handle bare Identifiers or !Identifier — those need binding-aware
|
||||
* resolution (handled above in _classifyPlatformTestWithHoisting).
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {'windows' | 'not-windows' | null}
|
||||
*/
|
||||
function _classifyPlatformExprOnly(node) {
|
||||
if (!node) return null;
|
||||
if (node.type === 'BinaryExpression' && (node.operator === '===' || node.operator === '!==')) {
|
||||
const { left, right, operator } = node;
|
||||
if (_isPlatformExpr(left) && _isWin32Literal(right)) {
|
||||
return operator === '===' ? 'windows' : 'not-windows';
|
||||
}
|
||||
if (_isPlatformExpr(right) && _isWin32Literal(left)) {
|
||||
return operator === '===' ? 'windows' : 'not-windows';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an identifier's binding via ESLint scope analysis.
|
||||
*
|
||||
* Walks `scope.upper` from the identifier's immediate scope to find the NEAREST
|
||||
* binding (so inner shadows take priority over outer declarations).
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} identNode
|
||||
* @param {string} identName
|
||||
* @param {boolean} negated
|
||||
* @param {import('eslint').SourceCode} sourceCode
|
||||
* @returns {'windows' | 'not-windows' | null | 'no-binding'}
|
||||
*/
|
||||
function _resolveIdentifierViaScope(identNode, identName, negated, sourceCode) {
|
||||
let scope;
|
||||
try {
|
||||
scope = sourceCode.getScope(identNode);
|
||||
} catch (_) {
|
||||
return 'no-binding';
|
||||
}
|
||||
if (!scope) return 'no-binding';
|
||||
|
||||
// Walk scope chain from innermost to outermost; take the NEAREST binding.
|
||||
let s = scope;
|
||||
while (s) {
|
||||
const variable = s.variables.find(v => v.name === identName);
|
||||
if (variable) {
|
||||
// Found an in-file binding (the NEAREST one wins — inner shadow beats outer).
|
||||
const defs = variable.defs;
|
||||
if (!defs || defs.length === 0) {
|
||||
// Binding exists but no declarator (e.g. function parameter) — no init.
|
||||
return null;
|
||||
}
|
||||
const decl = defs[0].node; // VariableDeclarator
|
||||
if (!decl || !decl.init) {
|
||||
// No initializer (e.g. `let w;`) → not a platform test.
|
||||
return null;
|
||||
}
|
||||
// Classify the initializer.
|
||||
const cls = classifyPlatformTest(decl.init);
|
||||
if (!cls) return null; // init is not a platform test
|
||||
|
||||
// Check for reassignment: any write reference that is NOT the initialization.
|
||||
const isReassigned = variable.references.some(
|
||||
ref => ref.isWrite() && !ref.init
|
||||
);
|
||||
if (isReassigned) return null;
|
||||
|
||||
// Valid platform guard binding found.
|
||||
return negated
|
||||
? (cls === 'windows' ? 'not-windows' : 'windows')
|
||||
: cls;
|
||||
}
|
||||
s = s.upper;
|
||||
}
|
||||
|
||||
// No binding found in any scope — treat as a global/imported name.
|
||||
return 'no-binding';
|
||||
}
|
||||
|
||||
/**
|
||||
* Binding-aware AST-walk resolver — used as a fallback when
|
||||
* sourceCode.getScope is not available.
|
||||
*
|
||||
* Walks ancestor blocks from innermost to outermost, looking for a
|
||||
* VariableDeclaration of `name` that precedes `targetNode`.
|
||||
*
|
||||
* Key differences from the old _resolveIdentifierInit:
|
||||
* - Returns 'no-binding' when NO declaration of `name` is found in any
|
||||
* ancestor block (so the caller can apply the name heuristic).
|
||||
* - Returns null (not 'no-binding') when a declaration IS found but:
|
||||
* • its init does not classify as a platform test, OR
|
||||
* • the variable is reassigned (any ExpressionStatement `name = ...`
|
||||
* appears before targetNode after the declaration), OR
|
||||
* • an inner-scope declaration shadows the outer one (inner wins).
|
||||
* - Stops at the FIRST block that declares `name` (innermost shadow wins).
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {import('eslint').Rule.Node} identNode — the Identifier AST node (for inner-shadow check)
|
||||
* @param {import('eslint').Rule.Node} targetNode — the assert CallExpression node
|
||||
* @param {import('eslint').SourceCode} sourceCode
|
||||
* @returns {'windows' | 'not-windows' | null | 'no-binding'}
|
||||
*/
|
||||
function _resolveIdentifierInitBindingAware(name, identNode, targetNode, sourceCode) {
|
||||
const ancestors = _getAncestors(targetNode, sourceCode);
|
||||
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const block = ancestors[i];
|
||||
if (block.type !== 'BlockStatement' && block.type !== 'Program') continue;
|
||||
|
||||
const stmts = block.body;
|
||||
if (!stmts) continue;
|
||||
|
||||
// Find which direct-child statement contains the targetNode.
|
||||
let targetIdx = -1;
|
||||
for (let j = 0; j < stmts.length; j++) {
|
||||
if (_containsNode(stmts[j], targetNode) || stmts[j] === targetNode) {
|
||||
targetIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetIdx === -1) continue;
|
||||
|
||||
// Scan all preceding siblings in this block for a declaration of `name`.
|
||||
let foundDecl = null;
|
||||
let foundDeclIdx = -1;
|
||||
for (let j = 0; j < targetIdx; j++) {
|
||||
const stmt = stmts[j];
|
||||
if (stmt.type !== 'VariableDeclaration') continue;
|
||||
for (const decl of stmt.declarations) {
|
||||
if (
|
||||
decl.type === 'VariableDeclarator' &&
|
||||
decl.id &&
|
||||
decl.id.type === 'Identifier' &&
|
||||
decl.id.name === name
|
||||
) {
|
||||
foundDecl = decl;
|
||||
foundDeclIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundDecl) break;
|
||||
}
|
||||
|
||||
if (foundDecl) {
|
||||
// A binding was found in this block. Innermost shadow wins — stop climbing.
|
||||
|
||||
// No initializer → not a platform guard.
|
||||
if (!foundDecl.init) return null;
|
||||
|
||||
// Init must classify as a platform test.
|
||||
const cls = classifyPlatformTest(foundDecl.init);
|
||||
if (!cls) return null;
|
||||
|
||||
// Check for reassignment: any ExpressionStatement `name = ...` between
|
||||
// foundDeclIdx and targetIdx.
|
||||
if (_hasReassignmentBetween(name, stmts, foundDeclIdx + 1, targetIdx)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cls;
|
||||
}
|
||||
|
||||
// No declaration found in this block — continue climbing to outer scope.
|
||||
}
|
||||
|
||||
// No declaration found in any ancestor block.
|
||||
return 'no-binding';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when any statement in stmts[fromIdx..toIdx) is an assignment
|
||||
* expression `<name> = ...` (simple reassignment, not an initializer).
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Array} stmts
|
||||
* @param {number} fromIdx — inclusive
|
||||
* @param {number} toIdx — exclusive
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _hasReassignmentBetween(name, stmts, fromIdx, toIdx) {
|
||||
for (let j = fromIdx; j < toIdx; j++) {
|
||||
const stmt = stmts[j];
|
||||
if (
|
||||
stmt.type === 'ExpressionStatement' &&
|
||||
stmt.expression.type === 'AssignmentExpression' &&
|
||||
stmt.expression.left.type === 'Identifier' &&
|
||||
stmt.expression.left.name === name
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy alias kept for _isWindowsEarlyReturn's call to
|
||||
* _classifyPlatformTestWithHoisting, which uses stmt (the IfStatement) as
|
||||
* the "targetNode" to look up hoisting context. No callers outside that path.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {import('eslint').Rule.Node} targetNode
|
||||
* @param {import('eslint').SourceCode} sourceCode
|
||||
* @returns {'windows' | 'not-windows' | null}
|
||||
*/
|
||||
function _resolveIdentifierInit(name, targetNode, sourceCode) {
|
||||
const result = _resolveIdentifierInitBindingAware(name, null, targetNode, sourceCode);
|
||||
if (result === 'no-binding') return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when there is a preceding sibling statement (before targetNode in ANY
|
||||
* enclosing block — function body, nested block, or Program) that is an
|
||||
* IfStatement whose consequence is a Windows-only early return — making
|
||||
* targetNode unreachable on Windows.
|
||||
*
|
||||
* Recognized patterns:
|
||||
* if (windowsTest) return;
|
||||
* if (windowsTest) return <expr>;
|
||||
* if (windowsTest) { …; return; } — block with a return
|
||||
*
|
||||
* C2 fix: climbs ALL ancestor blocks, not just the innermost one.
|
||||
* An early-return guard in a function body before a nested if-block that
|
||||
* contains targetNode is equally valid (control cannot reach targetNode on Windows
|
||||
* because the outer return fired first).
|
||||
*/
|
||||
function _hasEarlyWindowsReturnBefore(targetNode, sourceCode) {
|
||||
const ancestors = _getAncestors(targetNode, sourceCode);
|
||||
|
||||
// Walk ALL ancestor blocks bottom-up (innermost first).
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const block = ancestors[i];
|
||||
if (block.type !== 'BlockStatement' && block.type !== 'Program') continue;
|
||||
|
||||
const stmts = block.body;
|
||||
if (!stmts) continue;
|
||||
|
||||
// Find targetNode's position in this block's statements.
|
||||
// targetNode might be nested inside a statement; we need the direct-child index.
|
||||
let targetStmtIdx = -1;
|
||||
for (let j = 0; j < stmts.length; j++) {
|
||||
if (_containsNode(stmts[j], targetNode) || stmts[j] === targetNode) {
|
||||
targetStmtIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetStmtIdx === -1) continue;
|
||||
|
||||
// Scan preceding siblings in this block for a Windows early-return guard.
|
||||
for (let j = 0; j < targetStmtIdx; j++) {
|
||||
const stmt = stmts[j];
|
||||
if (_isWindowsEarlyReturn(stmt, sourceCode, block)) return true;
|
||||
}
|
||||
|
||||
// No guard found in this block — continue climbing to outer blocks.
|
||||
// (Unlike the IfStatement-branch check, an early-return in an outer block
|
||||
// before the nested block that contains targetNode is equally protective.)
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `stmt` is `if (<windows test>) return;` / `if (<windows test>) return <expr>;`
|
||||
* / `if (<windows test>) { …; return; }` with no `else`.
|
||||
*/
|
||||
function _isWindowsEarlyReturn(stmt, sourceCode, _block) {
|
||||
if (stmt.type !== 'IfStatement') return false;
|
||||
if (stmt.alternate != null) return false; // has else → not a simple guard
|
||||
|
||||
const testClass = _classifyPlatformTestWithHoisting(stmt.test, stmt, sourceCode);
|
||||
if (testClass !== 'windows') return false;
|
||||
|
||||
// Consequent must contain a return statement
|
||||
const consequent = stmt.consequent;
|
||||
if (!consequent) return false;
|
||||
|
||||
if (consequent.type === 'ReturnStatement') return true;
|
||||
|
||||
if (consequent.type === 'BlockStatement') {
|
||||
// Only direct-child ReturnStatements are checked. Nested/conditional returns
|
||||
// (e.g. inside inner if-blocks) are intentionally NOT treated as guards —
|
||||
// this is the sound conservative choice: we only suppress the report when
|
||||
// we are certain execution cannot continue on Windows.
|
||||
return consequent.body.some(s => s.type === 'ReturnStatement');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ancestor chain for `node` using the sourceCode API.
|
||||
* Returns an array from outermost to innermost (not including node itself).
|
||||
*/
|
||||
function _getAncestors(node, sourceCode) {
|
||||
// ESLint 8+: sourceCode.getAncestors(node)
|
||||
if (sourceCode.getAncestors) {
|
||||
try {
|
||||
return sourceCode.getAncestors(node);
|
||||
} catch (_) {
|
||||
// Fallback: not always available outside a rule handler
|
||||
}
|
||||
}
|
||||
// Fallback: traverse the AST manually (used in unit tests)
|
||||
return _findAncestors(sourceCode.ast, node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the ancestor chain by walking the AST.
|
||||
* Returns array from root to immediate parent of target.
|
||||
* Skips `parent` and other cycle-inducing keys.
|
||||
*/
|
||||
function _findAncestors(root, target) {
|
||||
const chain = [];
|
||||
function walk(node, ancestors) {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
if (node === target) {
|
||||
chain.push(...ancestors);
|
||||
return true;
|
||||
}
|
||||
for (const key of Object.keys(node)) {
|
||||
if (SKIP_KEYS.has(key)) continue;
|
||||
const child = node[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item && typeof item === 'object' && item.type) {
|
||||
if (walk(item, [...ancestors, node])) return true;
|
||||
}
|
||||
}
|
||||
} else if (child && typeof child === 'object' && child.type) {
|
||||
if (walk(child, [...ancestors, node])) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
walk(root, []);
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys to skip when traversing an AST node to avoid circular parent refs.
|
||||
* ESLint attaches `parent` to every node, which creates cycles.
|
||||
*/
|
||||
const SKIP_KEYS = new Set(['parent', 'tokens', 'comments']);
|
||||
|
||||
/**
|
||||
* Returns true when `container` node contains `target` node (by identity).
|
||||
* Skips `parent` and other non-AST keys to avoid circular reference loops.
|
||||
*/
|
||||
function _containsNode(container, target) {
|
||||
if (!container || typeof container !== 'object') return false;
|
||||
if (container === target) return true;
|
||||
for (const key of Object.keys(container)) {
|
||||
if (SKIP_KEYS.has(key)) continue;
|
||||
const child = container[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item && typeof item === 'object' && item.type) {
|
||||
if (_containsNode(item, target)) return true;
|
||||
}
|
||||
}
|
||||
} else if (child && typeof child === 'object' && child.type) {
|
||||
if (_containsNode(child, target)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyPlatformTest,
|
||||
isWindowsExcludedNode,
|
||||
};
|
||||
337
eslint-rules/lib/portability-vocab.cjs
Normal file
337
eslint-rules/lib/portability-vocab.cjs
Normal file
@@ -0,0 +1,337 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* portability-vocab.cjs — single source of truth for path-related portability.
|
||||
*
|
||||
* PATH_RETURNING_FNS: canonical list of function calls (Node builtins and
|
||||
* project resolvers) that return a filesystem path. The drift-guard test
|
||||
* (tests/portability-vocab-drift.test.cjs) enforces completeness against
|
||||
* src/runtime-homes.cts's exported path-returning functions.
|
||||
*
|
||||
* EXTEND THIS LIST when adding a new path resolver to the codebase.
|
||||
* The drift-guard test will fail if you forget.
|
||||
*
|
||||
* ── Known boundaries ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* Matching is by spelling: `path`, `os`, and the project resolver names below
|
||||
* are assumed to refer to the standard Node modules / project resolver exports.
|
||||
* A local variable that shadows one of these names (e.g. `const path = …`) is
|
||||
* out of scope — the helpers treat it as the real module.
|
||||
*
|
||||
* isPosixNormalizerCall inspects only the DIRECT argument of the call node;
|
||||
* deeper nesting (e.g. `String(path.join(...)).toLowerCase().replace(/\\/g,'/')`)
|
||||
* is not covered — only the outermost call and one level of String() cast are
|
||||
* visible to the rule.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canonical set of function names (dotted or bare) that return filesystem paths.
|
||||
*
|
||||
* Format:
|
||||
* - "path.join" → MemberExpression: object=Identifier{path}, property=Identifier{join}
|
||||
* - "os.homedir" → MemberExpression: object=Identifier{os}, property=Identifier{homedir}
|
||||
* - "getGlobalDir" → Identifier callee with that name
|
||||
*/
|
||||
const PATH_RETURNING_FNS = [
|
||||
// ── Node built-ins ──────────────────────────────────────────────────────────
|
||||
'path.join',
|
||||
'path.resolve',
|
||||
'path.dirname',
|
||||
'path.basename',
|
||||
'path.normalize',
|
||||
'path.relative',
|
||||
'os.homedir',
|
||||
'os.tmpdir',
|
||||
|
||||
// ── Project resolvers (src/runtime-homes.cts exports + install.js helpers) ──
|
||||
// Add bare function names here; dotted forms (e.g. obj.resolveX) are not used
|
||||
// in the test corpus because these are module-level exports, not methods.
|
||||
'resolveAgentDir',
|
||||
'getGlobalConfigDir',
|
||||
'getGlobalSkillsBase',
|
||||
'getGlobalSkillDir',
|
||||
'getGlobalSkillDisplayPath',
|
||||
'resolveSkillsBaseFromDescriptor',
|
||||
'resolveConfigHomeFromDescriptor',
|
||||
'resolveKimiGlobalDir',
|
||||
'resolveAntigravityGlobalDir',
|
||||
'getGlobalDir',
|
||||
'getConfigDirFromHome',
|
||||
'resolveKiloConfigPath',
|
||||
'resolveOpencodeConfigPath',
|
||||
'computePathPrefix',
|
||||
'expandHome',
|
||||
'getPathX',
|
||||
'normalizeInstallRelativePath',
|
||||
'toPosixPath',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns true when `node` is a CallExpression whose callee matches one of the
|
||||
* PATH_RETURNING_FNS entries.
|
||||
*
|
||||
* Handles two call shapes:
|
||||
* - Dotted: path.join(…) → callee is MemberExpression{object: Identifier, property: Identifier}
|
||||
* - Bare: getGlobalDir() → callee is Identifier
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node - AST node to inspect
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPathReturningCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
|
||||
// Dotted call: path.join, os.homedir, etc.
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
const dotted = `${callee.object.name}.${callee.property.name}`;
|
||||
if (PATH_RETURNING_FNS.includes(dotted)) return true;
|
||||
}
|
||||
|
||||
// Bare call: getGlobalConfigDir(), resolveKimiGlobalDir(), etc.
|
||||
if (callee.type === 'Identifier') {
|
||||
if (PATH_RETURNING_FNS.includes(callee.name)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `node` is a string literal (or a template literal with no
|
||||
* expressions) whose value contains '/' and does NOT look like a URL.
|
||||
*
|
||||
* URL exclusion: value starts with 'http://' or 'https://'.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPosixSlashStringLiteral(node) {
|
||||
if (!node) return false;
|
||||
|
||||
// Plain string literal
|
||||
if (node.type === 'Literal' && typeof node.value === 'string') {
|
||||
const v = node.value;
|
||||
if (!v.includes('/')) return false;
|
||||
if (v.startsWith('http://') || v.startsWith('https://')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Template literal with no expressions (static): `some/path`
|
||||
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
||||
const v = node.quasis[0]?.value?.cooked ?? '';
|
||||
if (!v.includes('/')) return false;
|
||||
if (v.startsWith('http://') || v.startsWith('https://')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `node` is a CallExpression that normalizes its first
|
||||
* argument to POSIX-style slashes.
|
||||
*
|
||||
* Recognized shapes:
|
||||
* 1. <x>.replace(/\\/g, '/') — regex /\\/g with replacement '/'
|
||||
* 2. <x>.replace(/[\\/]/g, '/') — regex /[\\/]/g with replacement '/'
|
||||
* 3. <x>.replaceAll('\\', '/') — literal backslash to slash
|
||||
* 4. <x>.replaceAll(path.sep, '/') — path.sep to slash
|
||||
* 5. <x>.split(path.sep).join('/') — split-join idiom
|
||||
* 6. toPosixPath(<x>) — explicit wrapper
|
||||
*
|
||||
* Note: for replace(), we REQUIRE the 'g' flag on the regex AND the regex
|
||||
* source must actually target backslashes (source `\\` or `[\\/]`).
|
||||
* A regex like /foo/g or /\//g does NOT qualify.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPosixNormalizerCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
|
||||
// toPosixPath(<x>)
|
||||
if (callee.type === 'Identifier' && callee.name === 'toPosixPath') return true;
|
||||
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
const method = callee.property.name;
|
||||
const args = node.arguments;
|
||||
|
||||
// <x>.replace(regex, '/')
|
||||
// REQUIRE: g flag + regex source must target backslashes: `\\` or `[\\/]`
|
||||
if (method === 'replace' && args.length >= 2) {
|
||||
const regexArg = args[0];
|
||||
const replacementArg = args[1];
|
||||
if (
|
||||
regexArg.type === 'Literal' &&
|
||||
regexArg.regex != null &&
|
||||
regexArg.regex.flags.includes('g') &&
|
||||
_isBackslashTargetingRegex(regexArg.regex.pattern) &&
|
||||
_isSlashReplacement(replacementArg)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// <x>.replaceAll(sep, '/')
|
||||
if (method === 'replaceAll' && args.length >= 2) {
|
||||
const sepArg = args[0];
|
||||
const replacementArg = args[1];
|
||||
if (_isSlashReplacement(replacementArg)) {
|
||||
// replaceAll('\\', '/') or replaceAll('\\\\', '/') or replaceAll(path.sep, '/')
|
||||
if (_isBackslashLiteral(sepArg)) return true;
|
||||
if (_isPathSep(sepArg)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// <x>.split(path.sep).join('/')
|
||||
// The callee is <split_result>.join — check the object for .split(path.sep)
|
||||
if (method === 'join' && args.length >= 1 && _isSlashReplacement(args[0])) {
|
||||
const splitCall = callee.object;
|
||||
if (
|
||||
splitCall.type === 'CallExpression' &&
|
||||
splitCall.callee.type === 'MemberExpression' &&
|
||||
!splitCall.callee.computed &&
|
||||
splitCall.callee.property.type === 'Identifier' &&
|
||||
splitCall.callee.property.name === 'split' &&
|
||||
splitCall.arguments.length >= 1 &&
|
||||
_isPathSep(splitCall.arguments[0])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if node is the replacement '/' string literal */
|
||||
function _isSlashReplacement(node) {
|
||||
return node && node.type === 'Literal' && node.value === '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* True if regexPattern (the raw regex source string, as stored in the AST's
|
||||
* `.regex.pattern` field) actually targets backslashes.
|
||||
*
|
||||
* Accepted: exactly `\\` (two-char, two backslashes: matches one backslash)
|
||||
* exactly `[\\/]` (five-char: backslash-or-forward-slash charset)
|
||||
* Rejected: `foo`, `\/` (forward-slash only), anything else.
|
||||
*
|
||||
* @param {string} pattern — the AST `.regex.pattern` string
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isBackslashTargetingRegex(pattern) {
|
||||
// Pattern `\\` (two backslash chars in the regex) — matches a single backslash
|
||||
if (pattern === '\\\\') return true;
|
||||
// Pattern `[\\/]` (backslash-or-forward-slash charset) — five chars
|
||||
if (pattern === '[\\\\/]') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if node is a backslash literal ('\\' or '\\\\') */
|
||||
function _isBackslashLiteral(node) {
|
||||
if (!node || node.type !== 'Literal') return false;
|
||||
return node.value === '\\' || node.value === '\\\\';
|
||||
}
|
||||
|
||||
/** True if node is path.sep */
|
||||
function _isPathSep(node) {
|
||||
return (
|
||||
node &&
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === 'path' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'sep'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If `node` is `String(<x>)`, return `<x>`; otherwise return `node` as-is.
|
||||
* Allows the rule to see through String() casts on path expressions.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {import('eslint').Rule.Node}
|
||||
*/
|
||||
function unwrapString(node) {
|
||||
if (
|
||||
node &&
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'String' &&
|
||||
node.arguments.length === 1
|
||||
) {
|
||||
return node.arguments[0];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* If `node` is a method-call chain of the form `<receiver>.replace(...)`,
|
||||
* `<receiver>.replaceAll(...)`, or `<receiver>.split(...).join(...)` that is
|
||||
* NOT a valid POSIX normalizer (i.e. `isPosixNormalizerCall(node)` is false),
|
||||
* return the receiver (the `.object` of the callee MemberExpression).
|
||||
*
|
||||
* This lets the rule detect:
|
||||
* `path.join(a,b).replace(/foo/g, '/')` → not a normalizer, but the
|
||||
* receiver `path.join(a,b)` IS a path-returning call → violation.
|
||||
*
|
||||
* Only peels ONE layer. The caller is responsible for checking the peeled node.
|
||||
* Returns `null` when `node` is already a valid normalizer or is not a method chain.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {import('eslint').Rule.Node | null}
|
||||
*/
|
||||
function unwrapNonNormalizerMethodChain(node) {
|
||||
if (!node || node.type !== 'CallExpression') return null;
|
||||
// If it IS a valid normalizer, do NOT peel — the caller already handled that.
|
||||
if (isPosixNormalizerCall(node)) return null;
|
||||
|
||||
const callee = node.callee;
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
const method = callee.property.name;
|
||||
// String-mutation methods that commonly wrap path calls
|
||||
if (method === 'replace' || method === 'replaceAll') {
|
||||
return callee.object;
|
||||
}
|
||||
// <x>.split(...).join(...) — callee.object is the .split() result;
|
||||
// peel to the .split()'s receiver
|
||||
if (method === 'join') {
|
||||
const splitCall = callee.object;
|
||||
if (
|
||||
splitCall &&
|
||||
splitCall.type === 'CallExpression' &&
|
||||
splitCall.callee.type === 'MemberExpression' &&
|
||||
!splitCall.callee.computed &&
|
||||
splitCall.callee.property.type === 'Identifier' &&
|
||||
splitCall.callee.property.name === 'split'
|
||||
) {
|
||||
return splitCall.callee.object;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PATH_RETURNING_FNS,
|
||||
isPathReturningCall,
|
||||
isPosixSlashStringLiteral,
|
||||
isPosixNormalizerCall,
|
||||
unwrapString,
|
||||
unwrapNonNormalizerMethodChain,
|
||||
};
|
||||
154
eslint-rules/no-bare-npm-exec.cjs
Normal file
154
eslint-rules/no-bare-npm-exec.cjs
Normal file
@@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-bare-npm-exec
|
||||
*
|
||||
* Flag bare 'npm' invocations via execFileSync/spawnSync/spawn without
|
||||
* `shell: true`. On Windows, `npm` is `npm.cmd` — a CMD batch file — and
|
||||
* cannot be launched without a shell.
|
||||
*
|
||||
* ## What this enforces (G5)
|
||||
*
|
||||
* - `execFileSync('npm', ...)` / `spawnSync('npm', ...)` / `spawn('npm', ...)`
|
||||
* whose options object (last arg, if ObjectExpression) does NOT set
|
||||
* `shell: true`, `shell: isWindows`, or `shell: process.platform === 'win32'`.
|
||||
*
|
||||
* ## What this does NOT flag
|
||||
*
|
||||
* - `execSync('npm install', ...)` — execSync always runs through a shell
|
||||
* (cmd.exe on Windows automatically resolves npm.cmd), so it is safe without
|
||||
* `shell: true`. Only direct binary exec functions (execFileSync, spawnSync,
|
||||
* spawn) bypass the shell and require explicit `{ shell: true }`.
|
||||
*
|
||||
* Message: Windows needs `npm.cmd` — pass `{ shell: true }`.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow bare "npm" execFileSync/spawnSync/spawn/execSync without shell:true (fails on Windows)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
bareNpmExec:
|
||||
'Bare "npm" invocation without { shell: true } is not portable ' +
|
||||
'(DEFECT.WINDOWS-TEST-PORTABILITY): On Windows, npm is a CMD batch file ' +
|
||||
'(npm.cmd) and requires a shell to execute. Pass { shell: true } as the ' +
|
||||
'options argument.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
/** Functions that take (command, args, options) — direct binary exec, no shell */
|
||||
const EXEC_FILE_FNS = new Set(['execFileSync', 'spawnSync', 'spawn']);
|
||||
|
||||
/**
|
||||
* Returns the string value of a Literal node, or null.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stringValue(node) {
|
||||
if (node && node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the function name for a CallExpression callee (Identifier or
|
||||
* MemberExpression), or null if not recognized.
|
||||
* @param {import('eslint').Rule.Node} callee
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getFnName(callee) {
|
||||
if (callee.type === 'Identifier') return callee.name;
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
return callee.property.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an ObjectExpression has `shell: true`, `shell: isWindows`,
|
||||
* or `shell: process.platform === 'win32'`.
|
||||
* @param {import('eslint').Rule.Node} optionsNode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasShellTrue(optionsNode) {
|
||||
if (!optionsNode || optionsNode.type !== 'ObjectExpression') return false;
|
||||
for (const prop of optionsNode.properties) {
|
||||
if (prop.type !== 'Property') continue;
|
||||
const keyName =
|
||||
prop.key.type === 'Identifier'
|
||||
? prop.key.name
|
||||
: stringValue(prop.key);
|
||||
if (keyName !== 'shell') continue;
|
||||
const val = prop.value;
|
||||
// shell: true
|
||||
if (val.type === 'Literal' && val.value === true) return true;
|
||||
// shell: isWindows / shell: IS_WINDOWS / shell: isWin / shell: onWindows
|
||||
if (val.type === 'Identifier') {
|
||||
const name = val.name;
|
||||
if (
|
||||
name === 'isWindows' ||
|
||||
name === 'IS_WINDOWS' ||
|
||||
name === 'isWin' ||
|
||||
name === 'onWindows'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// shell: process.platform === 'win32'
|
||||
if (
|
||||
val.type === 'BinaryExpression' &&
|
||||
(val.operator === '===' || val.operator === '==') &&
|
||||
val.left.type === 'MemberExpression' &&
|
||||
val.left.object.type === 'Identifier' &&
|
||||
val.left.object.name === 'process' &&
|
||||
val.left.property.type === 'Identifier' &&
|
||||
val.left.property.name === 'platform' &&
|
||||
val.right.type === 'Literal' &&
|
||||
val.right.value === 'win32'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const fnName = getFnName(node.callee);
|
||||
if (!fnName) return;
|
||||
|
||||
const args = node.arguments;
|
||||
if (!args || args.length === 0) return;
|
||||
|
||||
// Pattern A: execFileSync/spawnSync/spawn('npm', ...)
|
||||
if (EXEC_FILE_FNS.has(fnName)) {
|
||||
const firstArg = stringValue(args[0]);
|
||||
if (firstArg !== 'npm') return;
|
||||
|
||||
// Find last ObjectExpression argument as the options
|
||||
const lastArg = args[args.length - 1];
|
||||
if (hasShellTrue(lastArg)) return;
|
||||
|
||||
// No shell:true — report
|
||||
context.report({ node, messageId: 'bareNpmExec' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
418
eslint-rules/no-crlf-fragile-split.cjs
Normal file
418
eslint-rules/no-crlf-fragile-split.cjs
Normal file
@@ -0,0 +1,418 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-crlf-fragile-split
|
||||
*
|
||||
* Flag CRLF-fragile file-content splitting and regex patterns in test files.
|
||||
* Windows git-autocrlf causes readFileSync to return \r\n line endings; code
|
||||
* that splits on bare `\n` or uses regexes with bare `\n` will silently
|
||||
* mismatch on Windows.
|
||||
*
|
||||
* ## What this enforces
|
||||
*
|
||||
* G1 — a `.split('\n')` / `.split("\n")` CallExpression whose receiver is
|
||||
* (transitively) a `readFileSync`/`fs.readFileSync` result — directly,
|
||||
* via a chain, or via an Identifier that scope-resolves to a variable
|
||||
* initialized from readFileSync.
|
||||
* Message: use `.split(/\r?\n/)`.
|
||||
*
|
||||
* G2/G3 — a RegExpLiteral whose pattern contains a bare `\n` (a `\n` not
|
||||
* part of `\r?\n` / `\r\n` / `[\r\n]` etc.) used as the pattern of a
|
||||
* `.match`/`.test`/`.exec`/`.replace`/`.replaceAll`/`.split`/`.matchAll`
|
||||
* call on a readFileSync-derived receiver. ALSO flags a RegExpLiteral
|
||||
* with a bare `\n` whose source contains a markdown fence (```) or a
|
||||
* frontmatter anchor (`^---`), since those shapes target file content.
|
||||
* Message: use `\r?\n` (Windows git-autocrlf yields `\r\n`).
|
||||
*
|
||||
* ## Known boundaries
|
||||
*
|
||||
* The data-flow is scope-based: a readFileSync result is tracked via the
|
||||
* immediate call-chain or a single variable binding initialized from
|
||||
* readFileSync in the same file scope. A regex stored far from its use, or
|
||||
* content obtained via a non-readFileSync read (e.g. fs.readFile callback,
|
||||
* streams), may not be caught. G2/G3 additionally fires on fence/frontmatter
|
||||
* regex shapes even when data-flow is indirect, to catch the most common
|
||||
* markdown parsing patterns.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-CRLF-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs');
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow CRLF-fragile file-content split and regex patterns in tests (fails on Windows with git-autocrlf)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
crlfFragileSplit:
|
||||
'Splitting on literal "\\n" on readFileSync content is CRLF-fragile ' +
|
||||
'(DEFECT.WINDOWS-CRLF-TEST-PORTABILITY): Windows git-autocrlf yields "\\r\\n" ' +
|
||||
'line endings. Use .split(/\\r?\\n/) instead.',
|
||||
crlfFragileRegex:
|
||||
'RegExp with a bare "\\n" on readFileSync content is CRLF-fragile ' +
|
||||
'(DEFECT.WINDOWS-CRLF-TEST-PORTABILITY): Windows git-autocrlf yields "\\r\\n" ' +
|
||||
'line endings. Use \\r?\\n (or [\\r\\n]) instead.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the string value of a Literal node, or null.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stringValue(node) {
|
||||
if (node && node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the node is a call to `readFileSync` or `fs.readFileSync`.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isReadFileSyncCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
// readFileSync(...)
|
||||
if (callee.type === 'Identifier' && callee.name === 'readFileSync') return true;
|
||||
// fs.readFileSync(...)
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
callee.property.name === 'readFileSync'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `node` is (transitively) derived from a readFileSync call.
|
||||
*
|
||||
* Handles:
|
||||
* - Direct: readFileSync(...) -- the node itself IS the readFileSync call
|
||||
* - Chain: readFileSync(...).toString() etc.
|
||||
* - Identifier resolved via scope to a variable initialized from readFileSync
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isReadFileSyncDerived(node) {
|
||||
if (!node) return false;
|
||||
|
||||
// Direct readFileSync call
|
||||
if (isReadFileSyncCall(node)) return true;
|
||||
|
||||
// MemberExpression: x.something — check the object
|
||||
if (node.type === 'MemberExpression') {
|
||||
return isReadFileSyncDerived(node.object);
|
||||
}
|
||||
|
||||
// CallExpression: x.something() — check object of the callee
|
||||
if (node.type === 'CallExpression') {
|
||||
if (isReadFileSyncCall(node)) return true;
|
||||
if (node.callee.type === 'MemberExpression') {
|
||||
return isReadFileSyncDerived(node.callee.object);
|
||||
}
|
||||
}
|
||||
|
||||
// Identifier: resolve to its variable initializer via scope
|
||||
if (node.type === 'Identifier') {
|
||||
return resolveIdentifierToReadFileSync(node);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an Identifier node, walk the scope chain to find its binding,
|
||||
* then check if the initializer is derived from readFileSync.
|
||||
* @param {import('eslint').Rule.Node} identNode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function resolveIdentifierToReadFileSync(identNode) {
|
||||
if (typeof sourceCode.getScope !== 'function') return false;
|
||||
|
||||
let scope;
|
||||
try {
|
||||
scope = sourceCode.getScope(identNode);
|
||||
} catch (_) {
|
||||
// If scope resolution fails (e.g. due to unsupported node type or
|
||||
// parser version mismatch), conservatively return false (not flagged).
|
||||
// This is an intentional boundary: an unresolvable scope produces a
|
||||
// false negative rather than a spurious error.
|
||||
return false;
|
||||
}
|
||||
if (!scope) return false;
|
||||
|
||||
let s = scope;
|
||||
while (s) {
|
||||
const variable = s.variables.find(v => v.name === identNode.name);
|
||||
if (variable) {
|
||||
const defs = variable.defs;
|
||||
if (!defs || defs.length === 0) return false;
|
||||
const decl = defs[0].node; // VariableDeclarator
|
||||
if (!decl || !decl.init) return false;
|
||||
// Check the init is readFileSync-derived
|
||||
return isReadFileSyncDerived(decl.init);
|
||||
}
|
||||
s = s.upper;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a RegExpLiteral has at least one FRAGILE bare \n — a \n
|
||||
* that is not adequately protected against CRLF.
|
||||
*
|
||||
* Per-occurrence classification: every \n in the pattern is inspected
|
||||
* individually. A \n is SAFE when ANY of these hold:
|
||||
* 1. Immediately preceded by \r? (part of \r?\n)
|
||||
* 2. Immediately preceded by \r (part of \r\n)
|
||||
* 3. Inside a character class [...] that also contains \r
|
||||
* (e.g. [\r\n], [^\r\n], [\n\r])
|
||||
*
|
||||
* Everything else is FRAGILE: [^\n], [\n], or a bare \n in the main pattern.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — Literal with regex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasBareLiteralNewline(node) {
|
||||
if (!node || node.type !== 'Literal' || !node.regex) return false;
|
||||
const pattern = node.regex.pattern;
|
||||
if (!pattern.includes('\\n')) return false;
|
||||
|
||||
// Walk the pattern, find every \n occurrence and classify it.
|
||||
let i = 0;
|
||||
// Track whether we are inside a [...] character class and whether
|
||||
// the current class contains \r.
|
||||
let inClass = false;
|
||||
let classHasCarriageReturn = false;
|
||||
let foundFragile = false;
|
||||
|
||||
while (i < pattern.length) {
|
||||
// Entering a character class
|
||||
if (pattern[i] === '[' && !inClass) {
|
||||
inClass = true;
|
||||
classHasCarriageReturn = false;
|
||||
i++;
|
||||
// Skip optional ^ negation
|
||||
if (i < pattern.length && pattern[i] === '^') i++;
|
||||
// Skip ] if it appears immediately after [ or [^, where it is literal
|
||||
if (i < pattern.length && pattern[i] === ']') i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exiting a character class
|
||||
if (pattern[i] === ']' && inClass) {
|
||||
inClass = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Escape sequences inside the pattern
|
||||
if (pattern[i] === '\\' && i + 1 < pattern.length) {
|
||||
const next = pattern[i + 1];
|
||||
if (next === 'r') {
|
||||
// \r — if inside a class, note it contains \r
|
||||
if (inClass) classHasCarriageReturn = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (next === 'n') {
|
||||
// \n found — classify it
|
||||
// Check if preceded by \r? or \r (look back in the raw pattern string)
|
||||
// "preceded by" means the two chars before the current \\ are \r or \r?
|
||||
const before2 = pattern.slice(Math.max(0, i - 2), i); // up to 2 chars before \\
|
||||
const safeByPrefix =
|
||||
before2.endsWith('\\r?') || // \r?\n (but \r? is 3 chars, before is 2 — need to check before3)
|
||||
before2.endsWith('\\r'); // \r\n
|
||||
|
||||
// Re-check with a wider window for \r?\n (pattern chars: \r?\n = 5 chars)
|
||||
const before3 = pattern.slice(Math.max(0, i - 3), i);
|
||||
const safeByPrefixFull =
|
||||
before3 === '\\r?' || // \r?\n
|
||||
before2 === '\\r'; // \r\n
|
||||
|
||||
if (inClass) {
|
||||
// Inside a class: safe only if the class itself contains \r
|
||||
if (!classHasCarriageReturn) {
|
||||
foundFragile = true;
|
||||
}
|
||||
} else if (!safeByPrefixFull) {
|
||||
foundFragile = true;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Any other escape: skip both chars
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return foundFragile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a RegExpLiteral with a bare \n is used on a readFileSync-
|
||||
* derived receiver via .match/.test/.exec/.replace/.replaceAll/.split/.matchAll.
|
||||
*
|
||||
* Two AST shapes:
|
||||
* Shape A: str.match(/regex/) — regex is an ARG to the call.
|
||||
* regex.parent = CallExpression (arg), callee.object = str
|
||||
* Shape B: /regex/.test(str) — regex is the callee object.
|
||||
* regex.parent = MemberExpression (the .test callee)
|
||||
* regex.parent.parent = CallExpression, first arg = str
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} regexNode — the RegExpLiteral
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRegexUsedOnFileContent(regexNode) {
|
||||
const FILE_METHODS = new Set(['match', 'test', 'exec', 'replace', 'replaceAll', 'split', 'matchAll']);
|
||||
const parent = regexNode.parent;
|
||||
if (!parent) return false;
|
||||
|
||||
// Shape A: str.match(regex) — regex is an argument; parent is CallExpression
|
||||
if (parent.type === 'CallExpression') {
|
||||
const callee = parent.callee;
|
||||
if (
|
||||
callee &&
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
FILE_METHODS.has(callee.property.name)
|
||||
) {
|
||||
// regex must actually be one of the arguments (not the callee)
|
||||
if (parent.arguments.includes(regexNode)) {
|
||||
return isReadFileSyncDerived(callee.object);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shape B: /regex/.test(str) — regex is the callee object.
|
||||
// In this case, regexNode.parent is the MemberExpression (/regex/.test)
|
||||
if (parent.type === 'MemberExpression' && !parent.computed) {
|
||||
if (
|
||||
parent.object === regexNode &&
|
||||
parent.property.type === 'Identifier' &&
|
||||
FILE_METHODS.has(parent.property.name)
|
||||
) {
|
||||
// parent.parent should be the CallExpression
|
||||
const callExpr = parent.parent;
|
||||
if (callExpr && callExpr.type === 'CallExpression' && callExpr.callee === parent) {
|
||||
const args = callExpr.arguments;
|
||||
if (args && args.length > 0) {
|
||||
return isReadFileSyncDerived(args[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a RegExpLiteral pattern:
|
||||
* - has a bare \n, AND
|
||||
* - contains a markdown fence (```) or frontmatter anchor (^---)
|
||||
*
|
||||
* These shapes target file content by convention even without direct
|
||||
* data-flow tracking.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — Literal with regex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMarkdownOrFrontmatterRegex(node) {
|
||||
if (!node || node.type !== 'Literal' || !node.regex) return false;
|
||||
if (!hasBareLiteralNewline(node)) return false;
|
||||
const pattern = node.regex.pattern;
|
||||
// Markdown fence: ```
|
||||
if (pattern.includes('```')) return true;
|
||||
// Frontmatter anchor: ^---
|
||||
if (/\^---/.test(pattern)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Per-file state ──────────────────────────────────────────────────────
|
||||
|
||||
/** Collected G1 violations: {node} */
|
||||
const g1Violations = [];
|
||||
/** Collected G2/G3 violations: {node} */
|
||||
const g2g3Violations = [];
|
||||
|
||||
return {
|
||||
// G1: .split('\n') on readFileSync-derived content
|
||||
CallExpression(node) {
|
||||
const callee = node.callee;
|
||||
if (
|
||||
callee &&
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
callee.property.name === 'split'
|
||||
) {
|
||||
const args = node.arguments;
|
||||
if (args && args.length >= 1) {
|
||||
const argVal = stringValue(args[0]);
|
||||
if (argVal === '\n') {
|
||||
// Is the receiver derived from readFileSync?
|
||||
if (isReadFileSyncDerived(callee.object)) {
|
||||
g1Violations.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// G2/G3: RegExpLiteral with bare \n
|
||||
Literal(node) {
|
||||
if (!node.regex) return;
|
||||
if (!hasBareLiteralNewline(node)) return;
|
||||
|
||||
// Check G2/G3 via data-flow (receiver is readFileSync-derived)
|
||||
if (isRegexUsedOnFileContent(node)) {
|
||||
g2g3Violations.push(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check G2/G3 via content shape (markdown fence or frontmatter)
|
||||
if (isMarkdownOrFrontmatterRegex(node)) {
|
||||
g2g3Violations.push(node);
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
for (const node of g1Violations) {
|
||||
if (!isWindowsExcludedNode(node, sourceCode)) {
|
||||
context.report({ node, messageId: 'crlfFragileSplit' });
|
||||
}
|
||||
}
|
||||
for (const node of g2g3Violations) {
|
||||
if (!isWindowsExcludedNode(node, sourceCode)) {
|
||||
context.report({ node, messageId: 'crlfFragileRegex' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
110
eslint-rules/no-hardcoded-tmp.cjs
Normal file
110
eslint-rules/no-hardcoded-tmp.cjs
Normal file
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-hardcoded-tmp
|
||||
*
|
||||
* Flag hardcoded `/tmp/` paths passed to `fs.*` calls or `path.join()`.
|
||||
* On Windows, `/tmp/` does not exist — use `os.tmpdir()` instead.
|
||||
*
|
||||
* ## What this enforces (G4)
|
||||
*
|
||||
* A string Literal whose value starts with `/tmp/` (or is exactly `/tmp`)
|
||||
* passed as an argument to:
|
||||
* - An `fs.<method>(...)` call
|
||||
* - A `path.join('/tmp/...', …)` call
|
||||
*
|
||||
* Message: use `os.tmpdir()`.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow hardcoded /tmp/ paths in fs.* calls or path.join() (not portable to Windows)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
hardcodedTmp:
|
||||
'Hardcoded "/tmp/" path is not portable (DEFECT.WINDOWS-TEST-PORTABILITY): ' +
|
||||
'Windows does not have /tmp/. Use os.tmpdir() to get the platform-appropriate ' +
|
||||
'temp directory instead.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
/**
|
||||
* Returns true if `node` is a string Literal starting with /tmp/ or equal to /tmp.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isTmpLiteral(node) {
|
||||
if (!node || node.type !== 'Literal') return false;
|
||||
if (typeof node.value !== 'string') return false;
|
||||
return node.value === '/tmp' || node.value.startsWith('/tmp/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this CallExpression is an `fs.<method>(...)` call.
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isFsMethodCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'fs' &&
|
||||
callee.property.type === 'Identifier'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this CallExpression is a `path.join(...)` call.
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPathJoinCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'path' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
callee.property.name === 'join'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
// Check fs.<method>(...) calls
|
||||
if (isFsMethodCall(node)) {
|
||||
for (const arg of node.arguments) {
|
||||
if (isTmpLiteral(arg)) {
|
||||
context.report({ node: arg, messageId: 'hardcodedTmp' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check path.join('/tmp/...', ...) calls
|
||||
if (isPathJoinCall(node)) {
|
||||
const args = node.arguments;
|
||||
if (args && args.length > 0 && isTmpLiteral(args[0])) {
|
||||
context.report({ node: args[0], messageId: 'hardcodedTmp' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
190
eslint-rules/no-path-literal-in-assert.cjs
Normal file
190
eslint-rules/no-path-literal-in-assert.cjs
Normal file
@@ -0,0 +1,190 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-path-literal-in-assert
|
||||
*
|
||||
* Flag assertion calls where a path-returning function (path.join, path.resolve,
|
||||
* getGlobalConfigDir, …) is compared to a hardcoded POSIX-slash string literal.
|
||||
* These assertions FAIL on Windows because path.join emits backslashes.
|
||||
*
|
||||
* Triggers on:
|
||||
* assert.equal|strictEqual|deepEqual|deepStrictEqual(actual, expected)
|
||||
* expect(actual).toBe|toEqual|toStrictEqual(expected)
|
||||
*
|
||||
* Out of scope — intentionally NOT reported:
|
||||
* assert.notEqual|notStrictEqual(actual, expected)
|
||||
* expect(actual).not.toBe|not.toEqual|not.toStrictEqual(expected)
|
||||
* A path-vs-POSIX-literal INEQUALITY passes on Windows regardless of separator
|
||||
* differences, so it does not exhibit the portability-defect shape this rule
|
||||
* targets.
|
||||
*
|
||||
* Suppressed when:
|
||||
* - The path operand is wrapped by a POSIX normalizer (replace/replaceAll/toPosixPath/…)
|
||||
* - The assertion is inside a Windows-excluded block (platform guard, early-return,
|
||||
* hoisted isWindows) as detected by platform-guard.cjs
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT
|
||||
*
|
||||
* ── Known boundaries ───────────────────────────────────────────────────────────
|
||||
*
|
||||
* (a) Name-based matching only. The rule recognises `path`, `os`, and the
|
||||
* project resolver names listed in PATH_RETURNING_FNS by spelling alone. If
|
||||
* a test file declares a LOCAL variable named `path` that shadows the real
|
||||
* `path` module, that shadow is out of scope — the rule will still treat a
|
||||
* `path.join(...)` call as path-returning.
|
||||
*
|
||||
* (b) Shallow operand inspection. Only the direct first/second argument of the
|
||||
* assert call is inspected, plus one level of `String(<x>)` cast and one
|
||||
* level of non-normalizer method-chain peeling (`.replace()`, `.replaceAll()`,
|
||||
* `.split().join()`). Deeper wrapping — e.g. `.toLowerCase()` applied after
|
||||
* a path call, or `fs.realpathSync(path.join(...))` — is NOT detected as a
|
||||
* path-returning expression and will not trigger the rule.
|
||||
*
|
||||
* (c) Harmless no-op remedy. For explicit dir-pass-through assertions (where the
|
||||
* path really does contain forward-slashes even on Windows), wrapping with
|
||||
* `String(<x>).replace(/\\\\/g, '/')` is the correct suppression; on POSIX
|
||||
* systems where `\\` never appears, the replace is a no-op and has zero cost.
|
||||
*/
|
||||
|
||||
const {
|
||||
isPathReturningCall,
|
||||
isPosixSlashStringLiteral,
|
||||
isPosixNormalizerCall,
|
||||
unwrapString,
|
||||
unwrapNonNormalizerMethodChain,
|
||||
} = require('./lib/portability-vocab.cjs');
|
||||
|
||||
const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs');
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow path-returning calls compared to hardcoded POSIX-slash literals in assertions (fails on Windows)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
pathLiteral:
|
||||
"Path-returning call compared to a hardcoded '/'-literal (DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT): " +
|
||||
"fails on Windows where path.join emits '\\\\'. " +
|
||||
"Normalize the actual: String(<expr>).replace(/\\\\\\\\/g, '/') or .replaceAll(path.sep, '/').",
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
/** assert.equal / assert.strictEqual / assert.deepEqual / assert.deepStrictEqual */
|
||||
const ASSERT_EQUALITY_METHODS = new Set([
|
||||
'equal',
|
||||
'strictEqual',
|
||||
'deepEqual',
|
||||
'deepStrictEqual',
|
||||
]);
|
||||
|
||||
/** expect(actual).<matcher>(expected) */
|
||||
const EXPECT_MATCHERS = new Set(['toBe', 'toEqual', 'toStrictEqual']);
|
||||
|
||||
/**
|
||||
* Returns true when `pathNode` represents a path call and `literalNode` is
|
||||
* a POSIX slash literal, AND the path call is NOT already normalized.
|
||||
*
|
||||
* `rawPathNode` is the operand as-is (before unwrapping) — we check it for
|
||||
* normalizer wrapping before stripping String().
|
||||
*
|
||||
* Lookup order:
|
||||
* 1. If rawPathNode IS a valid POSIX normalizer → no violation.
|
||||
* 2. Unwrap String() cast → check if inner call is a path call.
|
||||
* 3. If rawPathNode is a non-normalizer method chain (e.g. .replace(/foo/g,'/'))
|
||||
* peel one layer to find if the receiver is a path-returning call.
|
||||
*/
|
||||
function isViolation(rawPathNode, rawLiteralNode) {
|
||||
// Is the path-side already wrapped by a POSIX normalizer?
|
||||
if (isPosixNormalizerCall(rawPathNode)) return false;
|
||||
|
||||
// Unwrap String() cast to see the inner call
|
||||
const pathNode = unwrapString(rawPathNode);
|
||||
|
||||
if (isPathReturningCall(pathNode)) {
|
||||
if (!isPosixSlashStringLiteral(rawLiteralNode)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// C1: if rawPathNode is a non-normalizer method chain (.replace, .replaceAll,
|
||||
// .split().join()) wrapping a path call, that is still a violation — the method
|
||||
// chain does not perform a valid POSIX normalization.
|
||||
const peeled = unwrapNonNormalizerMethodChain(rawPathNode);
|
||||
if (peeled != null) {
|
||||
const innerPath = unwrapString(peeled);
|
||||
if (isPathReturningCall(innerPath) && isPosixSlashStringLiteral(rawLiteralNode)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const callee = node.callee;
|
||||
|
||||
// ── assert.<method>(actual, expected) ──────────────────────────────
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'assert' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
ASSERT_EQUALITY_METHODS.has(callee.property.name)
|
||||
) {
|
||||
const args = node.arguments;
|
||||
if (args.length < 2) return;
|
||||
const actual = args[0];
|
||||
const expected = args[1];
|
||||
// Ignore 3rd arg (message)
|
||||
|
||||
const violated =
|
||||
isViolation(actual, expected) ||
|
||||
isViolation(expected, actual);
|
||||
|
||||
if (violated && !isWindowsExcludedNode(node, sourceCode)) {
|
||||
context.report({ node, messageId: 'pathLiteral' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── expect(actual).<matcher>(expected) ─────────────────────────────
|
||||
// Shape: CallExpression{ callee: MemberExpression{ object: CallExpression{callee: Identifier{expect}}, property: Identifier{<matcher>} } }
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
EXPECT_MATCHERS.has(callee.property.name) &&
|
||||
callee.object.type === 'CallExpression' &&
|
||||
callee.object.callee.type === 'Identifier' &&
|
||||
callee.object.callee.name === 'expect' &&
|
||||
callee.object.arguments.length === 1
|
||||
) {
|
||||
const actual = callee.object.arguments[0]; // the arg to expect(...)
|
||||
const matcherArgs = node.arguments;
|
||||
if (matcherArgs.length < 1) return;
|
||||
const expected = matcherArgs[0];
|
||||
|
||||
const violated =
|
||||
isViolation(actual, expected) ||
|
||||
isViolation(expected, actual);
|
||||
|
||||
if (violated && !isWindowsExcludedNode(node, sourceCode)) {
|
||||
context.report({ node, messageId: 'pathLiteral' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
409
eslint-rules/no-posix-mode-bit-assert.cjs
Normal file
409
eslint-rules/no-posix-mode-bit-assert.cjs
Normal file
@@ -0,0 +1,409 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-posix-mode-bit-assert
|
||||
*
|
||||
* Flag assertion calls where a file-mode expression (e.g. fs.statSync(p).mode,
|
||||
* or fs.statSync(p).mode & 0o777) is compared to an octal numeric literal.
|
||||
* These assertions PASS on macOS/Linux but FAIL on Windows because Windows
|
||||
* reports the DOS-attribute-derived mode (0o666 writable / 0o444 readonly),
|
||||
* never the requested POSIX octal.
|
||||
*
|
||||
* Triggers on:
|
||||
* assert.equal|strictEqual|deepEqual|deepStrictEqual(actual, expected)
|
||||
* expect(actual).toBe|toEqual|toStrictEqual(expected)
|
||||
*
|
||||
* A "file-mode expression" is one that:
|
||||
* M0. Contains a `.mode` MemberExpression (non-computed):
|
||||
* x.mode, fs.statSync(p).mode, x.mode & 0oNNN
|
||||
* M1. Contains a computed `['mode']` MemberExpression:
|
||||
* x['mode'], stat['mode'] & 0o777
|
||||
* M2. Is a variable whose binding (resolved via scope) is initialized to a
|
||||
* mode expression: `const m = stat.mode` / `const m = stat['mode']`
|
||||
* Conservative: only flags when binding resolves in-file and is not
|
||||
* reassigned before the assertion.
|
||||
* M3. Is a variable destructured as `mode` from an object:
|
||||
* `const { mode } = fs.statSync(p)` — the `mode` binding is a mode expr.
|
||||
* Conservative: same resolution rules as M2.
|
||||
* M4. Is a CallExpression to `Number`/`parseInt` whose first argument contains
|
||||
* a mode expression (recursive): `Number(stat.mode & 0o777)`,
|
||||
* `parseInt(stat.mode, 8)`.
|
||||
*
|
||||
* The violation is flagged when:
|
||||
* 1. One operand is (or contains/resolves-to) a mode expression, AND
|
||||
* 2. An octal numeric literal appears either as the other operand, OR
|
||||
* as the right-hand side of the bitwise expression containing the mode.
|
||||
*
|
||||
* Suppressed when:
|
||||
* - The assertion node is inside a Windows-excluded block (platform guard,
|
||||
* early-return guard, hoisted isWindows) as detected by platform-guard.cjs.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT
|
||||
*
|
||||
* ── Known boundaries ───────────────────────────────────────────────────────────
|
||||
*
|
||||
* (a) The rule detects `.mode` / `['mode']` by property name. It assumes any
|
||||
* `.mode` or `['mode']` alongside an octal literal in an equality assertion
|
||||
* is a filesystem mode check. A non-fs `.mode` or `['mode']` compared to an
|
||||
* octal literal IS flagged — the defect shape (POSIX-mode assertion that
|
||||
* fails on Windows) is the primary concern, and false positives for non-fs
|
||||
* `.mode` vs an octal literal are vanishingly rare in test code.
|
||||
*
|
||||
* (b) Variable-capture (M2) and destructure (M3) detection is scope-based.
|
||||
* When a binding RESOLVES in-file to a mode expression and is not reassigned,
|
||||
* the variable is treated as a mode expression. An unresolvable or reassigned
|
||||
* identifier is NOT flagged (conservative — avoids false positives on
|
||||
* non-fs identifiers or imported constants).
|
||||
*
|
||||
* (c) The `node:test` `test(name, { skip: isWindows ? … : false }, fn)` OPTION
|
||||
* object is NOT recognized as a platform guard. To make a mode-bit assertion
|
||||
* POSIX-only use an `if (process.platform !== 'win32')` guard (or an early-
|
||||
* return guard) inside the callback — the rule recognizes those shapes.
|
||||
*
|
||||
* (d) Octal detection covers `0o`/`0O` prefix literals. Legacy `0NNN` octal
|
||||
* literals (banned by strict mode and most linters) are not a concern in
|
||||
* modern test files and are not handled.
|
||||
*/
|
||||
|
||||
const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs');
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow asserting POSIX file mode bits compared to octal literals (fails on Windows)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
posixModeBit:
|
||||
'Asserting a POSIX file mode (DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT): Windows reports ' +
|
||||
'0o666/0o444, not the requested octal. Gate this precondition on ' +
|
||||
"`if (process.platform !== 'win32')` and keep the platform-independent " +
|
||||
'behavioral assertion running on every OS.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
/** assert.equal / assert.strictEqual / assert.deepEqual / assert.deepStrictEqual */
|
||||
const ASSERT_EQUALITY_METHODS = new Set([
|
||||
'equal',
|
||||
'strictEqual',
|
||||
'deepEqual',
|
||||
'deepStrictEqual',
|
||||
]);
|
||||
|
||||
/** expect(actual).<matcher>(expected) */
|
||||
const EXPECT_MATCHERS = new Set(['toBe', 'toEqual', 'toStrictEqual']);
|
||||
|
||||
/**
|
||||
* Returns true when the given AST node IS an octal numeric literal.
|
||||
* Matches `0o`/`0O` prefix form (ES6+). Raw source is checked because
|
||||
* `node.value` for `0o644` is `420` (decimal) — the same integer can be
|
||||
* written as `0x1A4` or `420` without being a mode-bit assertion.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isOctalLiteral(node) {
|
||||
if (!node || node.type !== 'Literal') return false;
|
||||
if (typeof node.value !== 'number') return false;
|
||||
// Check raw source representation via sourceCode
|
||||
const raw = sourceCode.getText(node);
|
||||
return raw.startsWith('0o') || raw.startsWith('0O');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `node` is a syntactic mode expression — one that
|
||||
* directly contains a `.mode` or `['mode']` MemberExpression anywhere
|
||||
* within it (including inside BinaryExpression and Number/parseInt wrappers).
|
||||
*
|
||||
* Recognized shapes (M0, M1, M4):
|
||||
* M0: x.mode — non-computed MemberExpression
|
||||
* M0: fs.statSync(p).mode — chained non-computed
|
||||
* M0: x.mode & 0o777 — .mode inside a BinaryExpression
|
||||
* M1: x['mode'] — computed MemberExpression, string 'mode'
|
||||
* M1: x['mode'] & 0o777 — computed .mode inside BinaryExpression
|
||||
* M4: Number(x.mode & 0o777) — Number() wrapping a mode expression
|
||||
* M4: parseInt(x.mode, 8) — parseInt() wrapping a mode expression
|
||||
*
|
||||
* Does NOT resolve variable references (that is done by isModeExpression).
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function containsSyntacticModeExpression(node) {
|
||||
if (!node) return false;
|
||||
|
||||
// M0: Non-computed MemberExpression with property name 'mode'
|
||||
if (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'mode'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// M1: Computed MemberExpression with string property 'mode'
|
||||
if (
|
||||
node.type === 'MemberExpression' &&
|
||||
node.computed &&
|
||||
node.property.type === 'Literal' &&
|
||||
node.property.value === 'mode'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// BinaryExpression: recurse left and right (covers x.mode & 0o777)
|
||||
if (node.type === 'BinaryExpression') {
|
||||
return (
|
||||
containsSyntacticModeExpression(node.left) ||
|
||||
containsSyntacticModeExpression(node.right)
|
||||
);
|
||||
}
|
||||
|
||||
// M4: Number(...) or parseInt(...) — recurse into the first argument
|
||||
if (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
(node.callee.name === 'Number' || node.callee.name === 'parseInt') &&
|
||||
node.arguments.length >= 1
|
||||
) {
|
||||
return containsSyntacticModeExpression(node.arguments[0]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare Identifier through the ESLint scope to determine whether
|
||||
* its binding is initialized to a mode expression (M2/M3).
|
||||
*
|
||||
* Returns true when ALL of the following hold:
|
||||
* - A VariableDeclarator binding for the name is found in-file scope.
|
||||
* - The declarator's init is a mode expression:
|
||||
* M2: `const m = stat.mode` / `const m = stat['mode']` — init is a
|
||||
* MemberExpression (or expression) containing a mode MemberExpression.
|
||||
* M3: `const { mode } = fs.statSync(p)` — the declarator id is an
|
||||
* ObjectPattern that includes a property keyed 'mode' matching
|
||||
* this identifier's name.
|
||||
* - The variable is NOT reassigned after initialization.
|
||||
*
|
||||
* Returns false (conservative) when:
|
||||
* - No in-file binding is found (could be an import, global, or parameter).
|
||||
* - The binding does not resolve to a mode expression.
|
||||
* - The variable is reassigned.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} identNode — the Identifier AST node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function resolveIdentifierToModeExpression(identNode) {
|
||||
if (!identNode || identNode.type !== 'Identifier') return false;
|
||||
if (typeof sourceCode.getScope !== 'function') return false;
|
||||
|
||||
let scope;
|
||||
try {
|
||||
scope = sourceCode.getScope(identNode);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!scope) return false;
|
||||
|
||||
const name = identNode.name;
|
||||
|
||||
// Walk scope chain innermost-first to find the nearest binding.
|
||||
let s = scope;
|
||||
while (s) {
|
||||
const variable = s.variables.find(v => v.name === name);
|
||||
if (variable) {
|
||||
// Found an in-file binding.
|
||||
const defs = variable.defs;
|
||||
if (!defs || defs.length === 0) return false; // no declarator (e.g. parameter)
|
||||
|
||||
const decl = defs[0].node; // VariableDeclarator
|
||||
if (!decl) return false;
|
||||
|
||||
// Check for reassignment: any write reference that is NOT the init.
|
||||
const isReassigned = variable.references.some(ref => ref.isWrite() && !ref.init);
|
||||
if (isReassigned) return false;
|
||||
|
||||
// M3: ObjectPattern destructure — `const { mode } = ...`
|
||||
// The binding matches if the declarator id is an ObjectPattern AND
|
||||
// the destructured key for this identifier's name is 'mode'.
|
||||
if (decl.id && decl.id.type === 'ObjectPattern') {
|
||||
const modeProperty = decl.id.properties.find(
|
||||
prop =>
|
||||
prop.type === 'Property' &&
|
||||
prop.key &&
|
||||
((prop.key.type === 'Identifier' && prop.key.name === 'mode') ||
|
||||
(prop.key.type === 'Literal' && prop.key.value === 'mode')) &&
|
||||
prop.value &&
|
||||
prop.value.type === 'Identifier' &&
|
||||
prop.value.name === name
|
||||
);
|
||||
if (modeProperty) return true;
|
||||
return false; // ObjectPattern without matching 'mode' key
|
||||
}
|
||||
|
||||
// M2: Simple declarator — `const m = stat.mode` or `const m = stat['mode']`
|
||||
if (!decl.init) return false;
|
||||
return containsSyntacticModeExpression(decl.init);
|
||||
}
|
||||
s = s.upper;
|
||||
}
|
||||
|
||||
// No in-file binding found — conservative: do not flag.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `node` is or contains a file-mode expression.
|
||||
* Extends containsSyntacticModeExpression with M2/M3 scope-based resolution
|
||||
* for bare Identifiers.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isModeExpression(node) {
|
||||
if (!node) return false;
|
||||
|
||||
// Syntactic check first (M0, M1, M4)
|
||||
if (containsSyntacticModeExpression(node)) return true;
|
||||
|
||||
// M2/M3: bare Identifier — resolve via scope
|
||||
if (node.type === 'Identifier') {
|
||||
return resolveIdentifierToModeExpression(node);
|
||||
}
|
||||
|
||||
// BinaryExpression: recurse (picks up `m & 0o777` where m is a mode alias)
|
||||
if (node.type === 'BinaryExpression') {
|
||||
return isModeExpression(node.left) || isModeExpression(node.right);
|
||||
}
|
||||
|
||||
// M4: Number/parseInt — recurse into first argument
|
||||
if (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
(node.callee.name === 'Number' || node.callee.name === 'parseInt') &&
|
||||
node.arguments.length >= 1
|
||||
) {
|
||||
return isModeExpression(node.arguments[0]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `node` contains an octal literal anywhere within it.
|
||||
* This covers:
|
||||
* - 0o644 — direct octal literal
|
||||
* - x.mode & 0o777 — octal inside a BinaryExpression (the mask)
|
||||
* - Number(x.mode & 0o777) — octal inside a wrapper
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function containsOctalLiteral(node) {
|
||||
if (!node) return false;
|
||||
if (isOctalLiteral(node)) return true;
|
||||
if (node.type === 'BinaryExpression') {
|
||||
return containsOctalLiteral(node.left) || containsOctalLiteral(node.right);
|
||||
}
|
||||
// Also recurse into Number/parseInt wrappers for the octal check
|
||||
if (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
(node.callee.name === 'Number' || node.callee.name === 'parseInt') &&
|
||||
node.arguments.length >= 1
|
||||
) {
|
||||
return containsOctalLiteral(node.arguments[0]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the pair of operands represents a POSIX-mode-bit assertion:
|
||||
* - One operand is (or resolves to) a mode expression, AND
|
||||
* - An octal literal appears somewhere in either operand (as a mask or as
|
||||
* the comparison value).
|
||||
*
|
||||
* Both operand orderings are checked by the caller.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} a - first operand
|
||||
* @param {import('eslint').Rule.Node} b - second operand
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isModeBitViolation(a, b) {
|
||||
const aModeExpr = isModeExpression(a);
|
||||
const bModeExpr = isModeExpression(b);
|
||||
|
||||
if (!aModeExpr && !bModeExpr) return false;
|
||||
|
||||
// At least one operand contains a .mode expression.
|
||||
// Check if any octal literal appears in either operand.
|
||||
const aHasOctal = containsOctalLiteral(a);
|
||||
const bHasOctal = containsOctalLiteral(b);
|
||||
|
||||
return aHasOctal || bHasOctal;
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const callee = node.callee;
|
||||
|
||||
// ── assert.<method>(actual, expected) ──────────────────────────────
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'assert' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
ASSERT_EQUALITY_METHODS.has(callee.property.name)
|
||||
) {
|
||||
const args = node.arguments;
|
||||
if (args.length < 2) return;
|
||||
const actual = args[0];
|
||||
const expected = args[1];
|
||||
|
||||
if (isModeBitViolation(actual, expected) && !isWindowsExcludedNode(node, sourceCode)) {
|
||||
context.report({ node, messageId: 'posixModeBit' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── expect(actual).<matcher>(expected) ─────────────────────────────
|
||||
// Shape: CallExpression{ callee: MemberExpression{
|
||||
// object: CallExpression{callee: Identifier{expect}},
|
||||
// property: Identifier{<matcher>}
|
||||
// }}
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
EXPECT_MATCHERS.has(callee.property.name) &&
|
||||
callee.object.type === 'CallExpression' &&
|
||||
callee.object.callee.type === 'Identifier' &&
|
||||
callee.object.callee.name === 'expect' &&
|
||||
callee.object.arguments.length === 1
|
||||
) {
|
||||
const actual = callee.object.arguments[0]; // the arg to expect(...)
|
||||
const matcherArgs = node.arguments;
|
||||
if (matcherArgs.length < 1) return;
|
||||
const expected = matcherArgs[0];
|
||||
|
||||
if (isModeBitViolation(actual, expected) && !isWindowsExcludedNode(node, sourceCode)) {
|
||||
context.report({ node, messageId: 'posixModeBit' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
258
eslint-rules/no-unguarded-nonportable-exec.cjs
Normal file
258
eslint-rules/no-unguarded-nonportable-exec.cjs
Normal file
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-unguarded-nonportable-exec
|
||||
*
|
||||
* Flag test files that BOTH make a fixture executable via chmod (exec-bit set)
|
||||
* AND invoke it with `sh -c` / `bash -c` — without a Windows platform guard.
|
||||
*
|
||||
* ## Why
|
||||
*
|
||||
* Windows Git Bash (msys2) does not honour Node's chmod exec bit for
|
||||
* PATH-executing extension-less scripts. A test that (a) makes a fixture
|
||||
* executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass
|
||||
* on Mac/Linux but fail only in the CI `test (windows-latest, *)` /
|
||||
* `full test (windows-latest, *)` lanes, producing a hard-to-diagnose
|
||||
* false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY.
|
||||
*
|
||||
* ## What this enforces (Program-level co-occurrence)
|
||||
*
|
||||
* Within a single file, detects the combination:
|
||||
* - makesExecutable: any `chmod`/`chmodSync(path, 0oNNN)` call where the
|
||||
* octal 2nd arg has exec bits set (`0oNNN & 0o111 !== 0`)
|
||||
* - shellDashC: any `execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync`
|
||||
* call whose command arg is `sh`/`bash`/`/bin/sh`/`/bin/bash` with a `-c`
|
||||
* arg in array form — or a string literal arg containing `sh -c`/`bash -c`
|
||||
*
|
||||
* At Program:exit, reports each unguarded shellDashC node when the file also
|
||||
* contains a chmod-exec-bit call. Guarded means the node is inside an
|
||||
* `isWindowsExcludedNode` block (platform guard / early-return / hoisted isWindows).
|
||||
*
|
||||
* ## Remediation
|
||||
*
|
||||
* Gate the bare-command execution behind `if (process.platform !== 'win32')`,
|
||||
* or invoke via an explicit interpreter (`sh <path>` instead of `sh -c <path>`).
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs');
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow unguarded chmod exec-bit + sh/bash -c combinations in tests (fails on Windows Git Bash)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
nonportableExec:
|
||||
'chmod exec-bit + sh/bash -c without a Windows guard ' +
|
||||
'(DEFECT.WINDOWS-TEST-PORTABILITY): Windows Git Bash (msys2) ignores ' +
|
||||
"the exec bit for PATH-executed extension-less scripts. Gate the " +
|
||||
"execution on `if (process.platform !== 'win32')` or invoke via an " +
|
||||
'explicit interpreter `sh <path>` instead of `sh -c`.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
/**
|
||||
* Shell commands whose first argument is the shell name.
|
||||
* Matches execFileSync, spawnSync, spawn, exec, execSync.
|
||||
*/
|
||||
const SHELL_EXEC_FN_NAMES = new Set([
|
||||
'execFileSync',
|
||||
'spawnSync',
|
||||
'spawn',
|
||||
'exec',
|
||||
'execSync',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Bare shell names (possibly with /bin/ or /usr/bin/ prefix).
|
||||
* The path prefix is stripped when comparing.
|
||||
*/
|
||||
const SHELL_NAMES = new Set(['sh', 'bash']);
|
||||
|
||||
/**
|
||||
* Returns the string value of a node if it's a string literal, else null.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stringValue(node) {
|
||||
if (node && node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `name` (possibly /bin/sh or /usr/bin/bash etc.) is sh/bash.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isShellName(name) {
|
||||
// Strip /bin/ or /usr/bin/ prefix
|
||||
const bare = name.replace(/^(?:\/usr)?\/bin\//, '');
|
||||
return SHELL_NAMES.has(bare);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when this CallExpression is a `sh`/`bash -c` invocation in
|
||||
* array form:
|
||||
* execFileSync('bash', ['-c', ...])
|
||||
* spawnSync('/bin/sh', ['-c', ...])
|
||||
* etc.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isShellDashCArrayForm(node) {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
|
||||
// Callee must be one of our shell exec functions (possibly member expr)
|
||||
const callee = node.callee;
|
||||
let fnName = null;
|
||||
if (callee.type === 'Identifier') {
|
||||
fnName = callee.name;
|
||||
} else if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
fnName = callee.property.name;
|
||||
}
|
||||
if (!fnName || !SHELL_EXEC_FN_NAMES.has(fnName)) return false;
|
||||
|
||||
const args = node.arguments;
|
||||
if (!args || args.length < 2) return false;
|
||||
|
||||
// First arg: shell name
|
||||
const shellArg = stringValue(args[0]);
|
||||
if (!shellArg || !isShellName(shellArg)) return false;
|
||||
|
||||
// Second arg: must be an ArrayExpression containing '-c'
|
||||
const secondArg = args[1];
|
||||
if (!secondArg || secondArg.type !== 'ArrayExpression') return false;
|
||||
|
||||
// '-c' must be the FIRST element: sh/bash -c <cmd> → args = ['-c', <cmd>]
|
||||
// A script that happens to receive '-c' later (e.g. [fixturePath, '-c'])
|
||||
// is NOT a shell -c invocation.
|
||||
const firstEl = secondArg.elements[0];
|
||||
return stringValue(firstEl) === '-c';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when this CallExpression is a string-literal form containing
|
||||
* `sh -c` or `bash -c`:
|
||||
* exec('sh -c "run.sh"')
|
||||
* execSync('bash -c script')
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isShellDashCStringForm(node) {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
|
||||
const callee = node.callee;
|
||||
let fnName = null;
|
||||
if (callee.type === 'Identifier') {
|
||||
fnName = callee.name;
|
||||
} else if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
fnName = callee.property.name;
|
||||
}
|
||||
if (!fnName || !SHELL_EXEC_FN_NAMES.has(fnName)) return false;
|
||||
|
||||
const args = node.arguments;
|
||||
if (!args || args.length < 1) return false;
|
||||
|
||||
// First arg may be a string literal containing 'sh -c' or 'bash -c'
|
||||
const firstArg = stringValue(args[0]);
|
||||
if (!firstArg) return false;
|
||||
|
||||
// Anchor to the start of the command string (allowing leading whitespace and
|
||||
// an optional absolute-path prefix like /bin/ or /usr/bin/).
|
||||
// This prevents matching 'sh -c' embedded mid-string in data, e.g.
|
||||
// exec('printf "sh -c"') or exec('echo run sh -c later')
|
||||
return /^\s*(?:\/\S+\/)?(?:bash|sh)\s+-c\b/.test(firstArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the CallExpression is a chmod/chmodSync call whose
|
||||
* second arg is an octal literal with at least one exec bit set.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isChmodExecBit(node) {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
|
||||
const callee = node.callee;
|
||||
let fnName = null;
|
||||
if (callee.type === 'Identifier') {
|
||||
fnName = callee.name;
|
||||
} else if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
fnName = callee.property.name;
|
||||
}
|
||||
if (!fnName || (fnName !== 'chmod' && fnName !== 'chmodSync')) return false;
|
||||
|
||||
const args = node.arguments;
|
||||
if (!args || args.length < 2) return false;
|
||||
|
||||
const modeArg = args[1];
|
||||
if (!modeArg || modeArg.type !== 'Literal') return false;
|
||||
if (typeof modeArg.value !== 'number') return false;
|
||||
|
||||
// Check it's an octal literal (raw source starts with 0o or 0O)
|
||||
const raw = sourceCode.getText(modeArg);
|
||||
if (!raw.startsWith('0o') && !raw.startsWith('0O')) return false;
|
||||
|
||||
// Check exec bit is set
|
||||
return (modeArg.value & 0o111) !== 0;
|
||||
}
|
||||
|
||||
// ── Per-file state ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Whether the file contains at least one chmod exec-bit call. */
|
||||
let fileHasChmodExecBit = false;
|
||||
|
||||
/** Collection of sh/bash -c nodes found in this file. */
|
||||
const shellDashCNodes = [];
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (isChmodExecBit(node)) {
|
||||
fileHasChmodExecBit = true;
|
||||
}
|
||||
if (isShellDashCArrayForm(node) || isShellDashCStringForm(node)) {
|
||||
shellDashCNodes.push(node);
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!fileHasChmodExecBit) return;
|
||||
|
||||
for (const shellNode of shellDashCNodes) {
|
||||
if (!isWindowsExcludedNode(shellNode, sourceCode)) {
|
||||
context.report({ node: shellNode, messageId: 'nonportableExec' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
479
eslint-rules/normalize-path-in-content.cjs
Normal file
479
eslint-rules/normalize-path-in-content.cjs
Normal file
@@ -0,0 +1,479 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* normalize-path-in-content
|
||||
*
|
||||
* Flag: a path-returning function result (PATH_RETURNING_FNS call, or a
|
||||
* variable that scope-resolves to one) interpolated into a template literal
|
||||
* (`${ … }`) or string concatenation that is CONTENT — heuristic: the
|
||||
* template/string also contains a genuine reference marker (see shapes below)
|
||||
* WITHOUT the path flowing through a POSIX normalizer
|
||||
* (isPosixNormalizerCall: `.replace(/\\/g,'/')`, `toPosixPath`, etc.).
|
||||
*
|
||||
* The canonical defect is computePathPrefix returning `${resolvedTarget}/`
|
||||
* verbatim on Windows (PR #1622) — backslashes leaked into `@~/.claude/...`
|
||||
* markdown content, breaking cross-platform substring checks and producing
|
||||
* malformed @-references in Windsurf workflow files.
|
||||
*
|
||||
* References:
|
||||
* DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT (CONTEXT.md)
|
||||
* RULESET.CONTENT-PATH-NORMALIZATION (CONTEXT.md)
|
||||
*
|
||||
* Message:
|
||||
* Cite RULESET.CONTENT-PATH-NORMALIZATION: normalize at source
|
||||
* `String(<path>).replace(/\\/g,'/')` before interpolating into content.
|
||||
*
|
||||
* ── Known boundaries ────────────────────────────────────────────────────────
|
||||
*
|
||||
* (a) Name-based matching only. `path`, `os`, and the project resolver names in
|
||||
* PATH_RETURNING_FNS are recognized by spelling. A local variable that
|
||||
* shadows one of these names is out of scope.
|
||||
*
|
||||
* (b) Shallow expression inspection. Only the direct expression inside `${ }`
|
||||
* (or a concatenation operand) is checked, plus one level of String() cast.
|
||||
* Deeper wrapping (e.g. `.toLowerCase()` after a path call) is not detected
|
||||
* as a path-returning expression and will not trigger the rule.
|
||||
*
|
||||
* (c) Content heuristic — two shapes are recognized:
|
||||
*
|
||||
* Shape (a) — quasis contain an @-reference or home-dir prefix marker:
|
||||
* `@~/`, `@$`, `@/`, `$HOME`, `~/` anywhere in the template's static
|
||||
* parts. A bare `@` that is NOT immediately followed by `~`, `$`, or `/`
|
||||
* (e.g. an email address or attribution line) does NOT qualify.
|
||||
*
|
||||
* Shape (b) — per-expression: quasis[i+1].raw starts with a forward slash
|
||||
* and contains `.md` or `.json` at the end of a path component. This
|
||||
* catches `${computePathPrefix(t)}/commands/gsd/x.md` and
|
||||
* `@${getGlobalConfigDir()}/agents/foo.md` without requiring config-dir
|
||||
* markers in CONTENT_MARKERS.
|
||||
*
|
||||
* Config-dir substrings (e.g. `/.claude`, `/commands`, `/skills`, etc.)
|
||||
* are NOT content markers — they appeared in log/error/diagnostic strings
|
||||
* too often and generated false positives. Shape (b) covers the genuine
|
||||
* content-emit cases without those FPs.
|
||||
*
|
||||
* Bare `.md`/`.json` tokens in plain prose (e.g. "see PROJECT.md") do NOT
|
||||
* qualify — they carry no separator-bearing path context that could be
|
||||
* tainted by backslashes. Pure log messages, filesystem paths passed to
|
||||
* fs.* functions, and Error messages that lack these markers are NOT flagged.
|
||||
*
|
||||
* (d) Suppression by call context. A path expression inside a `fs.*` call
|
||||
* argument (readFileSync, writeFileSync, join, resolve, etc.), a
|
||||
* `console.*` call, `new Error(...)`, a bare `Error(...)` / `TypeError(...)`
|
||||
* / `RangeError(...)` etc. (any CallExpression whose callee is an Identifier
|
||||
* whose name ends in `Error`), or a `require(...)` is not flagged — these
|
||||
* are real FS paths or diagnostics, not content.
|
||||
*
|
||||
* (e) Indirect data-flow is NOT tracked. If a path-returning call result is
|
||||
* stored in a variable or object field and that variable is later
|
||||
* interpolated into a content template (e.g. `${globalSkillDir}/SKILL.md`
|
||||
* → `@${entry.ref}` as in src/init.cts), the rule DOES NOT detect the
|
||||
* violation — it only flags direct path-returning call expressions inside
|
||||
* `${ }`. Indirect content-path-leaks rely on
|
||||
* RULESET.CONTENT-PATH-NORMALIZATION discipline (normalize at source) and
|
||||
* code review. The one known indirect leak (src/init.cts cmdAgentSkills
|
||||
* `entry.ref` building) is fixed by normalizing at the content-emit site.
|
||||
*
|
||||
* (f) path.basename is excluded from PATH_RETURNING_FNS for this rule.
|
||||
* path.basename() returns only the final filename component — it cannot
|
||||
* contain directory separators, so it is safe to interpolate into content
|
||||
* without normalization. Only calls that produce separator-bearing paths
|
||||
* (path.join, path.resolve, path.dirname, path.relative, path.normalize,
|
||||
* os.homedir, os.tmpdir, and the project resolver functions) are flagged.
|
||||
*/
|
||||
|
||||
const {
|
||||
PATH_RETURNING_FNS,
|
||||
isPosixNormalizerCall,
|
||||
unwrapString,
|
||||
} = require('./lib/portability-vocab.cjs');
|
||||
|
||||
// ── Rule-local path-fn set: PATH_RETURNING_FNS minus path.basename ─────────
|
||||
//
|
||||
// path.basename() returns a filename with no directory separators, so it
|
||||
// cannot leak backslashes into content. All other entries in PATH_RETURNING_FNS
|
||||
// DO produce separator-bearing paths and ARE checked by this rule.
|
||||
//
|
||||
// Note: toPosixPath remains in this set intentionally (it IS a path-returning
|
||||
// function), but isContentPathReturningCall is never reached for a toPosixPath
|
||||
// call because isPosixNormalizerCall short-circuits first in isUnnormalizedPathExpression.
|
||||
const CONTENT_PATH_FNS = new Set(PATH_RETURNING_FNS.filter(fn => fn !== 'path.basename'));
|
||||
|
||||
/**
|
||||
* Returns true when `node` (a CallExpression) is a call to one of the
|
||||
* CONTENT_PATH_FNS entries (PATH_RETURNING_FNS minus path.basename).
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isContentPathReturningCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
|
||||
// Dotted call: path.join, os.homedir, etc.
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
const dotted = `${callee.object.name}.${callee.property.name}`;
|
||||
if (CONTENT_PATH_FNS.has(dotted)) return true;
|
||||
}
|
||||
|
||||
// Bare call: getGlobalConfigDir(), resolveKimiGlobalDir(), etc.
|
||||
if (callee.type === 'Identifier') {
|
||||
if (CONTENT_PATH_FNS.has(callee.name)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Content-heuristic markers ──────────────────────────────────────────────
|
||||
//
|
||||
// A template/string is considered "content" (markdown @-references, workflow
|
||||
// bodies, generated documentation) when its STATIC parts (quasis) contain at
|
||||
// least one genuine reference marker. Two shapes are recognized:
|
||||
//
|
||||
// Shape (a) — explicit @-reference / home-dir prefix in quasis:
|
||||
// '@~' → @~/.claude/ reference (home-dir @-reference form)
|
||||
// '@$' → @${prefix}/commands/... reference (interpolated @-reference)
|
||||
// '@/' → @/path/... reference (root-relative @-reference form)
|
||||
// '$HOME' → $HOME/.cursor/... in generated workflow content
|
||||
// '~/' → ~/. shorthand for home-dir references in content
|
||||
//
|
||||
// NOTE: bare '@' is deliberately excluded — it is too broad and would
|
||||
// match email addresses and attribution lines (@author), causing false
|
||||
// positives. Only the genuine @-reference shapes (@~, @$, @/) are matched.
|
||||
//
|
||||
// Shape (b) — path-returning interpolation immediately before a .md/.json
|
||||
// file reference (per-expression quasi check, not template-wide):
|
||||
// quasis[i+1].raw matches /^\/[^\s`]*\.(md|json)(\b|$|\/)/ — the text
|
||||
// immediately following expression `i` starts with `/...path.md` or
|
||||
// `/...path.json`, indicating the expression is a path prefix for a
|
||||
// content file reference. This catches `${computePathPrefix(t)}/commands/
|
||||
// gsd/x.md` and `@${getGlobalConfigDir('claude')}/commands/gsd/help.md`
|
||||
// without requiring config-dir markers in CONTENT_MARKERS.
|
||||
//
|
||||
// Deliberately excluded from CONTENT_MARKERS (were Tier 2 / Tier 3):
|
||||
// Config-dir substrings (`/.claude`, `/.cursor`, `/.gemini`, `/.config`,
|
||||
// etc.) and artifact-path segments (`/commands`, `/agents`, `/skills`,
|
||||
// `/workflows`, `/rules`, `/gsd`) — these are too broad as standalone
|
||||
// markers and generate false positives when interpolated into log/error/
|
||||
// diagnostic strings that mention config-dir paths. Shape (b) above covers
|
||||
// the genuine content-emit cases without the FP risk.
|
||||
//
|
||||
// '@' — too broad; matches email addresses and @author attributions.
|
||||
// Only the genuine @-reference prefixes (@~, @$, @/) are kept.
|
||||
// '.md' — too broad; appears in plain prose ("see PROJECT.md") with no
|
||||
// separator-bearing path context.
|
||||
// '.json' — same rationale as '.md'.
|
||||
const CONTENT_MARKERS = [
|
||||
// Shape (a) — @-reference / home-dir prefix markers
|
||||
'@~', // @~/.claude/ home-dir @-reference form
|
||||
'@$', // @${prefix}/... interpolated @-reference form
|
||||
'@/', // @/path/... root-relative @-reference form
|
||||
'$HOME', // $HOME/.cursor/ path prefix in content
|
||||
'~/', // ~/. shorthand in content
|
||||
];
|
||||
|
||||
// ── Shape (b): per-expression quasi marker ───────────────────────────────────
|
||||
//
|
||||
// Applied per-expression in TemplateLiteral: quasis[i+1].raw must start with
|
||||
// a forward slash and contain `.md` or `.json` before the next whitespace or
|
||||
// end of the quasi string. This matches `/commands/gsd/x.md`,
|
||||
// `/skills/foo/SKILL.md`, `/help.json`, etc. without requiring a config-dir
|
||||
// marker in CONTENT_MARKERS.
|
||||
//
|
||||
// The check is: /^\/[^\s`]*\.(md|json)(\b|\/|$)/ against the raw quasi text.
|
||||
// The `\b` / `\/` / end-of-string ensures the extension is a terminal component
|
||||
// (not a `.md` substring in the middle of a word).
|
||||
const QUASI_MD_JSON_RE = /^\/[^\s`]*\.(md|json)(\b|\/|$)/;
|
||||
|
||||
// ── FS-call suppression: callee names that indicate a real filesystem path ─
|
||||
const FS_OBJECT_NAMES = new Set(['fs', 'path', 'os']);
|
||||
const FS_METHOD_NAMES = new Set([
|
||||
'readFileSync', 'writeFileSync', 'existsSync', 'statSync',
|
||||
'mkdirSync', 'mkdtempSync', 'readdirSync', 'unlinkSync',
|
||||
'copyFileSync', 'renameSync', 'lstatSync', 'accessSync',
|
||||
'readFile', 'writeFile', 'mkdir', 'mkdtemp', 'stat', 'access',
|
||||
'cpSync', 'rmSync', 'openSync', 'fstatSync', 'realpathSync',
|
||||
'join', 'resolve', 'dirname', 'basename', 'relative', 'normalize',
|
||||
'homedir', 'tmpdir',
|
||||
]);
|
||||
const FS_BARE_NAMES = new Set(['require']);
|
||||
const LOG_OBJECT_NAMES = new Set(['console']);
|
||||
const LOG_METHOD_NAMES = new Set(['log', 'warn', 'error', 'info', 'debug', 'trace']);
|
||||
|
||||
/**
|
||||
* Returns true if any quasis in the TemplateLiteral contains a content marker.
|
||||
*/
|
||||
function isContentTemplate(templateLiteralNode) {
|
||||
const quasis = templateLiteralNode.quasis || [];
|
||||
for (const quasi of quasis) {
|
||||
const raw = quasi.value?.raw ?? quasi.value?.cooked ?? '';
|
||||
for (const marker of CONTENT_MARKERS) {
|
||||
if (raw.includes(marker)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `node` (a CallExpression) is a context where template
|
||||
* literals are real FS paths or diagnostic messages — NOT content.
|
||||
*
|
||||
* Checks:
|
||||
* - fs.method(templateLiteral, ...)
|
||||
* - path.method(templateLiteral, ...)
|
||||
* - console.method(...)
|
||||
* - new Error(...)
|
||||
* - require(...)
|
||||
*/
|
||||
function isInSuppressedCallContext(expressionNode) {
|
||||
const parent = expressionNode.parent;
|
||||
if (!parent) return false;
|
||||
|
||||
// Direct argument to a call expression
|
||||
if (parent.type === 'CallExpression') {
|
||||
const callee = parent.callee;
|
||||
|
||||
// fs.*, path.*, os.* calls → FS paths
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
FS_OBJECT_NAMES.has(callee.object.name) &&
|
||||
FS_METHOD_NAMES.has(callee.property.name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// console.log/warn/error → diagnostic
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
LOG_OBJECT_NAMES.has(callee.object.name) &&
|
||||
LOG_METHOD_NAMES.has(callee.property.name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// require(...) → not content
|
||||
if (callee.type === 'Identifier' && FS_BARE_NAMES.has(callee.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// W2: bare Error(...) / TypeError(...) / RangeError(...) etc. → diagnostic.
|
||||
// Handles the call-expression form (as opposed to `new Error(...)` which is
|
||||
// a NewExpression). Any Identifier callee whose name ends in 'Error' is
|
||||
// treated as a diagnostic constructor, not content production.
|
||||
if (callee.type === 'Identifier' && callee.name.endsWith('Error')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// new Error(...) → diagnostic
|
||||
if (parent.type === 'NewExpression') {
|
||||
const callee = parent.callee;
|
||||
if (callee.type === 'Identifier' && callee.name.endsWith('Error')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// throw statement containing the template → diagnostic
|
||||
if (parent.type === 'ThrowStatement') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `expressionNode` (the expression inside `${ }`) is a
|
||||
* content-path-returning call (PATH_RETURNING_FNS minus path.basename) that
|
||||
* is NOT POSIX-normalized.
|
||||
*
|
||||
* Checks:
|
||||
* 1. If it is a POSIX normalizer call → NOT a violation.
|
||||
* 2. Unwrap String() cast → check if inner is a content path call.
|
||||
* 3. Direct content path-returning call.
|
||||
*
|
||||
* Returns false if the expression has been POSIX-normalized.
|
||||
*/
|
||||
function isUnnormalizedPathExpression(exprNode) {
|
||||
if (!exprNode) return false;
|
||||
|
||||
// If it's already POSIX-normalized → not a violation
|
||||
if (isPosixNormalizerCall(exprNode)) return false;
|
||||
|
||||
// Unwrap String() cast
|
||||
const inner = unwrapString(exprNode);
|
||||
|
||||
// If the unwrapped inner is POSIX-normalized → not a violation
|
||||
if (isPosixNormalizerCall(inner)) return false;
|
||||
|
||||
// Direct content path-returning call (possibly wrapped in String())
|
||||
// Note: path.basename is excluded from CONTENT_PATH_FNS — it returns a
|
||||
// filename with no directory separators, so it cannot leak backslashes.
|
||||
if (isContentPathReturningCall(inner)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow path-returning calls interpolated into content (markdown/workflow) template ' +
|
||||
'literals without POSIX normalization (DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
pathInContent:
|
||||
'Path-returning call interpolated into content template literal without POSIX normalization ' +
|
||||
'(RULESET.CONTENT-PATH-NORMALIZATION). ' +
|
||||
"Normalize at source: String(<path>).replace(/\\\\\\\\/g, '/') before interpolating into content. " +
|
||||
'See DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT in CONTEXT.md.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
/**
|
||||
* Check TemplateLiteral expressions: `...${<expr>}...`
|
||||
*
|
||||
* For each expression inside the template, if:
|
||||
* 1. The template contains a content marker in its static parts
|
||||
* 2. The expression is a path-returning call without POSIX normalization
|
||||
* 3. The template is NOT in a suppressed call context (fs.*, console.*, Error)
|
||||
* → report a violation.
|
||||
*/
|
||||
TemplateLiteral(node) {
|
||||
// Check if the entire template is in a suppressed context
|
||||
if (isInSuppressedCallContext(node)) return;
|
||||
|
||||
const quasis = node.quasis || [];
|
||||
const exprs = node.expressions || [];
|
||||
|
||||
// Shape (a): any quasi contains a CONTENT_MARKERS marker → check all
|
||||
// expressions in this template for unnormalized path calls.
|
||||
if (isContentTemplate(node)) {
|
||||
for (const expr of exprs) {
|
||||
if (isUnnormalizedPathExpression(expr)) {
|
||||
context.report({ node: expr, messageId: 'pathInContent' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Shape (b): per-expression quasi check. For expression at index i,
|
||||
// quasis[i+1].raw starts with a forward slash followed by a path that
|
||||
// terminates in .md or .json — the expression is a path prefix being
|
||||
// interpolated directly before a content file reference. This catches
|
||||
// `${computePathPrefix(t)}/commands/gsd/x.md` and
|
||||
// `@${getGlobalConfigDir('claude')}/commands/gsd/help.md` without
|
||||
// requiring config-dir markers in CONTENT_MARKERS.
|
||||
for (let i = 0; i < exprs.length; i++) {
|
||||
const nextQuasi = quasis[i + 1];
|
||||
if (!nextQuasi) continue;
|
||||
const raw = nextQuasi.value?.raw ?? nextQuasi.value?.cooked ?? '';
|
||||
if (QUASI_MD_JSON_RE.test(raw) && isUnnormalizedPathExpression(exprs[i])) {
|
||||
context.report({ node: exprs[i], messageId: 'pathInContent' });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check BinaryExpression string concatenation: <path> + "/foo.md"
|
||||
*
|
||||
* For `left + right` or `right + left` where one side is a string
|
||||
* literal containing a content marker and the other is a path-returning
|
||||
* call without POSIX normalization.
|
||||
*
|
||||
* W3 (right-deep FN): when a content marker is present anywhere in the
|
||||
* concat tree, search the ENTIRE tree recursively for any unnormalized
|
||||
* path-returning call — not just the immediate sibling. This catches
|
||||
* '@~/' + (name + path.join(home, '.claude'))
|
||||
* where the path call is nested inside a right-side BinaryExpression.
|
||||
*/
|
||||
BinaryExpression(node) {
|
||||
if (node.operator !== '+') return;
|
||||
|
||||
const { left, right } = node;
|
||||
|
||||
// isContentString: recursively check if a node (or its concat
|
||||
// sub-tree) contains a Literal/TemplateLiteral quasi with a
|
||||
// content marker. Descends into nested BinaryExpression `+` chains.
|
||||
function isContentString(n) {
|
||||
if (!n) return false;
|
||||
// Plain string literal
|
||||
if (n.type === 'Literal' && typeof n.value === 'string') {
|
||||
return CONTENT_MARKERS.some((m) => n.value.includes(m));
|
||||
}
|
||||
// TemplateLiteral — check quasis (static parts)
|
||||
if (n.type === 'TemplateLiteral') {
|
||||
for (const quasi of (n.quasis || [])) {
|
||||
const raw = quasi.value?.raw ?? quasi.value?.cooked ?? '';
|
||||
if (CONTENT_MARKERS.some((m) => raw.includes(m))) return true;
|
||||
}
|
||||
}
|
||||
// Descend into nested + concatenations
|
||||
if (n.type === 'BinaryExpression' && n.operator === '+') {
|
||||
return isContentString(n.left) || isContentString(n.right);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const treeHasContent = isContentString(left) || isContentString(right);
|
||||
|
||||
// Suppress if the whole concatenation is in a suppressed context
|
||||
if (isInSuppressedCallContext(node)) return;
|
||||
|
||||
if (!treeHasContent) return;
|
||||
|
||||
// Only report at the TOP-LEVEL BinaryExpression for this concat chain
|
||||
// (i.e. when the parent is NOT also a `+` BinaryExpression) to avoid
|
||||
// duplicate reports on every node of a chained concatenation.
|
||||
const parentNode = node.parent;
|
||||
if (
|
||||
parentNode &&
|
||||
parentNode.type === 'BinaryExpression' &&
|
||||
parentNode.operator === '+'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively scan the full concat tree for unnormalized path calls
|
||||
// and report each one found.
|
||||
function scanAndReport(n) {
|
||||
if (!n) return;
|
||||
if (n.type === 'BinaryExpression' && n.operator === '+') {
|
||||
// Check left
|
||||
if (isUnnormalizedPathExpression(n.left)) {
|
||||
context.report({ node: n.left, messageId: 'pathInContent' });
|
||||
} else {
|
||||
scanAndReport(n.left);
|
||||
}
|
||||
// Check right
|
||||
if (isUnnormalizedPathExpression(n.right)) {
|
||||
context.report({ node: n.right, messageId: 'pathInContent' });
|
||||
} else {
|
||||
scanAndReport(n.right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanAndReport(node);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
336
eslint-rules/require-fs-op-fallback.cjs
Normal file
336
eslint-rules/require-fs-op-fallback.cjs
Normal file
@@ -0,0 +1,336 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* require-fs-op-fallback
|
||||
*
|
||||
* Flag: a bare fs.rename / fs.renameSync call (the atomic-publish primitive
|
||||
* named first in DEFECT.WINDOWS-FS-OPS.symptom) that is NOT either:
|
||||
*
|
||||
* (a) inside a try/catch whose catch handler BOTH references a transient
|
||||
* errno ('EPERM' / 'EBUSY' / 'EACCES', literally OR via a *RETRY_ERRNOS-
|
||||
* style set identifier) AND carries a retry signal (a loop `continue`
|
||||
* backedge or a `return <call>` delegation — NOT a bare rethrow: the
|
||||
* defect's cure is retry/fallback, not just errno recognition), OR
|
||||
* (b) control-dependent on a Windows platform guard
|
||||
* (process.platform !== 'win32' / early-return — isWindowsExcludedNode).
|
||||
*
|
||||
* The canonical defect: on Windows, when an antivirus scanner, indexer, or
|
||||
* concurrent reader transiently holds the target open, fs.renameSync throws
|
||||
* EPERM/EBUSY/EACCES. A bare renameSync (or one wrapped in a try/catch that
|
||||
* only cleans up + rethrows without distinguishing the transient errno) fails
|
||||
* on the windows-latest CI lane where macOS/Linux CI passed — the established
|
||||
* cure is the RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) retry
|
||||
* loop already present in five production modules.
|
||||
*
|
||||
* "never silently swallow": a catch (e) {} or catch (_) {} with no transient-
|
||||
* errno reference does NOT satisfy the defect's fix-forward and is still
|
||||
* flagged. The fix is to add the bounded retry (the RENAME_RETRY_ERRNOS
|
||||
* pattern) or gate behind a Windows platform check.
|
||||
*
|
||||
* copyFile / unlink are deliberately NOT flagged: per the defect's own
|
||||
* .fix-forward ("catch EPERM/EBUSY/EACCES, fall back to copy + unlink with
|
||||
* retry") they are the FALLBACK PRIMITIVES, not separate defect sites, and
|
||||
* unlink has many intentional best-effort try/catch-swallow cleanup sites.
|
||||
*
|
||||
* References:
|
||||
* DEFECT.WINDOWS-FS-OPS (CONTEXT.md)
|
||||
* ADR-1703 (docs/adr/1703-portability-enforcement-architecture.md)
|
||||
* issue #1740 (scope note: rename-only v1)
|
||||
*
|
||||
* Message:
|
||||
* Cite DEFECT.WINDOWS-FS-OPS: fs.renameSync can throw EPERM/EBUSY/EACCES on
|
||||
* Windows when a reader/AV transiently holds the target. Wrap in a bounded
|
||||
* retry on the transient errno (the RENAME_RETRY_ERRNOS pattern) or gate
|
||||
* behind a Windows platform check.
|
||||
*
|
||||
* ── Known boundaries ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* (a) Name-based matching only. The rule recognizes `fs.rename` / `fs.renameSync`
|
||||
* by spelling (MemberExpression: object=Identifier{fs}). A bare
|
||||
* `renameSync(...)` call (when `fs` is destructured or the function is
|
||||
* imported bare) is NOT matched — the production survey showed 100%
|
||||
* `fs.renameSync` dotted usage, so dotted-only is the v1 shape.
|
||||
*
|
||||
* (b) Retry delegated to a helper function is NOT statically traceable. A
|
||||
* bare `fs.renameSync` inside `atomicRenameWithRetry` IS detected as
|
||||
* compliant because that helper wraps it in its own try/catch with the
|
||||
* RENAME_RETRY_ERRNOS reference — but a call site that delegates via
|
||||
* `atomicRenameWithRetry(tmp, target)` (calling the helper, no bare
|
||||
* renameSync at the call site) has nothing to flag in the first place.
|
||||
*
|
||||
* (c) The catch-handler errno check is a subtree scan for transient-errno
|
||||
* string literals OR *RETRY_ERRNOS identifiers. A catch that builds the
|
||||
* errno set from a non-literal source (e.g. reading from config) is not
|
||||
* recognized — the established convention is a module-level Set literal.
|
||||
*/
|
||||
|
||||
const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs');
|
||||
|
||||
// fs mutation methods that are the atomic-publish transient-lock primitives.
|
||||
const RENAME_METHODS = new Set(['rename', 'renameSync']);
|
||||
|
||||
// Transient Windows lock errnos (the DEFECT.WINDOWS-FS-OPS.fix-forward set).
|
||||
const TRANSIENT_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
||||
|
||||
// Recognize retry-errno set identifiers by naming convention, e.g.
|
||||
// RENAME_RETRY_ERRNOS, WRITE_RETRY_ERRNOS. Matches the established pattern
|
||||
// across capability-ledger / capability-consent / shell-command-projection.
|
||||
const RETRY_ERRNO_SET_NAME_RE = /RETRY_ERRNOS$/;
|
||||
|
||||
/**
|
||||
* True if `node` is an `fs.rename` / `fs.renameSync` CallExpression.
|
||||
*/
|
||||
function isFsRenameCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'fs' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
RENAME_METHODS.has(callee.property.name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a catch-clause subtree looking for evidence the handler distinguishes
|
||||
* a transient errno. Recognized evidence:
|
||||
* - a string Literal whose value is in TRANSIENT_ERRNOS ('EPERM'/'EBUSY'/'EACCES')
|
||||
* - an Identifier (or MemberExpression object) whose name matches RETRY_ERRNO_SET_NAME_RE
|
||||
*
|
||||
* Skips `parent`/`tokens`/`comments` keys to avoid cycles.
|
||||
*/
|
||||
function catchHandlerReferencesTransientErrno(handlerNode) {
|
||||
if (!handlerNode || typeof handlerNode !== 'object') return false;
|
||||
// The CatchClause node has { type, param, body, parent }. Inspect body
|
||||
// (and param name — not needed, but walk body subtree).
|
||||
const seen = new WeakSet();
|
||||
function walk(n) {
|
||||
if (!n || typeof n !== 'object') return false;
|
||||
if (seen.has(n)) return false;
|
||||
seen.add(n);
|
||||
|
||||
// String literal errno: 'EPERM' / 'EBUSY' / 'EACCES'
|
||||
if (n.type === 'Literal' && typeof n.value === 'string' && TRANSIENT_ERRNOS.has(n.value)) {
|
||||
return true;
|
||||
}
|
||||
// *RETRY_ERRNOS identifier (bare or as a MemberExpression object)
|
||||
if (n.type === 'Identifier' && RETRY_ERRNO_SET_NAME_RE.test(n.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(n)) {
|
||||
if (key === 'parent' || key === 'tokens' || key === 'comments') continue;
|
||||
const child = n[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item && typeof item === 'object' && item.type) {
|
||||
if (walk(item)) return true;
|
||||
}
|
||||
}
|
||||
} else if (child && typeof child === 'object' && child.type) {
|
||||
if (walk(child)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return walk(handlerNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `handlerNode` (a CatchClause) contains a RETRY SIGNAL — evidence the
|
||||
* catch actually re-attempts the rename rather than merely observing the errno.
|
||||
*
|
||||
* Recognized retry signals:
|
||||
* - ContinueStatement — a loop backedge (`for { try{rename}catch{continue} }`)
|
||||
* - ReturnStatement whose argument is a CallExpression — delegation
|
||||
* (`return retry()`, `return atomicRenameWithRetry(...)`)
|
||||
*
|
||||
* This closes the "errno-check-then-rethrow" false-negative: a catch like
|
||||
* `catch (e) { if (e.code === 'EPERM') throw e; throw e; }` references the
|
||||
* errno but never retries, so it still fails on Windows transient locks. The
|
||||
* DEFECT.WINDOWS-FS-OPS fix-forward requires retry/fallback, not just recognition.
|
||||
*
|
||||
* Skips `parent`/`tokens`/`comments` keys to avoid cycles.
|
||||
*/
|
||||
function catchHandlerHasRetrySignal(handlerNode) {
|
||||
if (!handlerNode || typeof handlerNode !== 'object') return false;
|
||||
const seen = new WeakSet();
|
||||
function walk(n) {
|
||||
if (!n || typeof n !== 'object') return false;
|
||||
if (seen.has(n)) return false;
|
||||
seen.add(n);
|
||||
// Loop backedge: `continue` re-enters the enclosing retry loop.
|
||||
if (n.type === 'ContinueStatement') return true;
|
||||
// Delegation: `return retry()` / `return atomicRenameWithRetry(...)` hands
|
||||
// the rename off to a helper that performs its own bounded retry.
|
||||
if (
|
||||
n.type === 'ReturnStatement' &&
|
||||
n.argument != null &&
|
||||
n.argument.type === 'CallExpression'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
for (const key of Object.keys(n)) {
|
||||
if (key === 'parent' || key === 'tokens' || key === 'comments') continue;
|
||||
const child = n[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item && typeof item === 'object' && item.type) {
|
||||
if (walk(item)) return true;
|
||||
}
|
||||
}
|
||||
} else if (child && typeof child === 'object' && child.type) {
|
||||
if (walk(child)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return walk(handlerNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `renameNode` is protected by a transient-errno retry: i.e. the
|
||||
* NEAREST enclosing TryStatement WITH A CATCH HANDLER whose `block` contains
|
||||
* the rename has a handler that BOTH references a transient errno AND carries a
|
||||
* retry signal (loop backedge or delegation return).
|
||||
*
|
||||
* Walks bottom-up and STOPS at the first TryStatement that (a) contains the
|
||||
* rename in its `block` and (b) has a `handler`. A try with only a `finally`
|
||||
* (no handler) does not intercept the rename error — it is skipped and the
|
||||
* climb continues. The nearest catching try is where the rename's error lands;
|
||||
* an outer catch is UNREACHABLE once the nearest catch intercepts (it may
|
||||
* swallow, transform, or rethrow-as-other), so walking past it would be
|
||||
* unsound (a false negative — see the nested-try case). An errno reference
|
||||
* alone is insufficient; the handler must also retry (see catchHandlerHasRetrySignal).
|
||||
*/
|
||||
function isInsideTransientErrnoTryCatch(renameNode, sourceCode) {
|
||||
const ancestors = _getAncestors(renameNode, sourceCode);
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const anc = ancestors[i];
|
||||
if (anc.type !== 'TryStatement') continue;
|
||||
if (!_containsNode(anc.block, renameNode)) continue;
|
||||
if (!anc.handler) continue; // try-finally: error propagates, keep climbing
|
||||
// Nearest catching try found — its handler is authoritative. An outer
|
||||
// catch cannot protect the rename if this one intercepts first.
|
||||
return (
|
||||
catchHandlerReferencesTransientErrno(anc.handler) &&
|
||||
catchHandlerHasRetrySignal(anc.handler)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── AST traversal helpers (mirror platform-guard.cjs internals) ──────────────
|
||||
|
||||
function _getAncestors(node, sourceCode) {
|
||||
if (sourceCode && typeof sourceCode.getAncestors === 'function') {
|
||||
try {
|
||||
return sourceCode.getAncestors(node);
|
||||
} catch (_) {
|
||||
// fall through to manual walk
|
||||
}
|
||||
}
|
||||
return _findAncestors(sourceCode.ast, node);
|
||||
}
|
||||
|
||||
function _findAncestors(root, target) {
|
||||
const chain = [];
|
||||
function walk(node, ancestors) {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
if (node === target) {
|
||||
chain.push(...ancestors);
|
||||
return true;
|
||||
}
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'parent' || key === 'tokens' || key === 'comments') continue;
|
||||
const child = node[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item && typeof item === 'object' && item.type) {
|
||||
if (walk(item, [...ancestors, node])) return true;
|
||||
}
|
||||
}
|
||||
} else if (child && typeof child === 'object' && child.type) {
|
||||
if (walk(child, [...ancestors, node])) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
walk(root, []);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function _containsNode(container, target) {
|
||||
if (!container || typeof container !== 'object') return false;
|
||||
if (container === target) return true;
|
||||
const seen = new WeakSet();
|
||||
function walk(n) {
|
||||
if (!n || typeof n !== 'object') return false;
|
||||
if (seen.has(n)) return false;
|
||||
seen.add(n);
|
||||
if (n === target) return true;
|
||||
for (const key of Object.keys(n)) {
|
||||
if (key === 'parent' || key === 'tokens' || key === 'comments') continue;
|
||||
const child = n[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item && typeof item === 'object' && item.type) {
|
||||
if (walk(item)) return true;
|
||||
}
|
||||
}
|
||||
} else if (child && typeof child === 'object' && child.type) {
|
||||
if (walk(child)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return walk(container);
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Require fs.rename/fs.renameSync to carry a transient-errno fallback (EPERM/EBUSY/EACCES) ' +
|
||||
'or a Windows platform guard (DEFECT.WINDOWS-FS-OPS)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
requireFsOpFallback:
|
||||
'Unguarded fs.rename/fs.renameSync: on Windows a concurrent reader or antivirus scanner ' +
|
||||
'can transiently hold the target open, throwing EPERM/EBUSY/EACCES ' +
|
||||
'(DEFECT.WINDOWS-FS-OPS). Wrap in a bounded retry on the transient errno ' +
|
||||
"(the RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) pattern) " +
|
||||
"or gate behind if (process.platform !== 'win32').",
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (!isFsRenameCall(node)) return;
|
||||
|
||||
// (a) inside a try/catch whose catch handles a transient errno
|
||||
if (isInsideTransientErrnoTryCatch(node, sourceCode)) return;
|
||||
|
||||
// (b) control-dependent on a Windows platform guard
|
||||
if (isWindowsExcludedNode(node, sourceCode)) return;
|
||||
|
||||
// Otherwise: unguarded atomic-publish rename — report.
|
||||
context.report({ node, messageId: 'requireFsOpFallback' });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
114
eslint-rules/require-userprofile-with-home.cjs
Normal file
114
eslint-rules/require-userprofile-with-home.cjs
Normal file
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* require-userprofile-with-home
|
||||
*
|
||||
* Flag test files that assign `process.env.HOME` without also referencing
|
||||
* `USERPROFILE` anywhere in the file.
|
||||
*
|
||||
* ## What this enforces (G6)
|
||||
*
|
||||
* Program-level: collect assignments to `process.env.HOME`
|
||||
* (`process.env.HOME = …` / `process.env['HOME'] = …`); track whether
|
||||
* `USERPROFILE` appears anywhere in the file (any reference). At
|
||||
* `Program:exit`, if HOME is assigned and `USERPROFILE` never appears, report
|
||||
* each HOME assignment.
|
||||
*
|
||||
* Message: Windows uses `USERPROFILE`, not `HOME` — set
|
||||
* `process.env.USERPROFILE` alongside.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Require process.env.USERPROFILE to be set alongside process.env.HOME (Windows portability)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
missingUserProfile:
|
||||
'Assigning process.env.HOME without process.env.USERPROFILE is not portable ' +
|
||||
'(DEFECT.WINDOWS-TEST-PORTABILITY): Windows uses USERPROFILE as the home ' +
|
||||
'directory environment variable, not HOME. Set process.env.USERPROFILE ' +
|
||||
'alongside process.env.HOME.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
/** Collected HOME assignment nodes */
|
||||
const homeAssignments = [];
|
||||
|
||||
/** Whether a real process.env.USERPROFILE = … assignment exists in the file */
|
||||
let userProfileAssigned = false;
|
||||
|
||||
/**
|
||||
* Returns true if node is an assignment to process.env[key] or
|
||||
* process.env.key for the given key name.
|
||||
*
|
||||
* Recognized shapes (as the left-hand side of AssignmentExpression):
|
||||
* process.env.KEY — MemberExpression(MemberExpression, Identifier)
|
||||
* process.env['KEY'] — MemberExpression(MemberExpression, Literal, computed=true)
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} lhs — left side of AssignmentExpression
|
||||
* @param {string} key — the env var name to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProcessEnvAssignment(lhs, key) {
|
||||
if (!lhs || lhs.type !== 'MemberExpression') return false;
|
||||
const obj = lhs.object;
|
||||
if (!obj || obj.type !== 'MemberExpression') return false;
|
||||
|
||||
// obj must be process.env
|
||||
if (
|
||||
obj.computed ||
|
||||
obj.object.type !== 'Identifier' ||
|
||||
obj.object.name !== 'process' ||
|
||||
obj.property.type !== 'Identifier' ||
|
||||
obj.property.name !== 'env'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Property must be key (identifier or string literal)
|
||||
if (!lhs.computed) {
|
||||
return lhs.property.type === 'Identifier' && lhs.property.name === key;
|
||||
} else {
|
||||
return (
|
||||
lhs.property.type === 'Literal' && lhs.property.value === key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
AssignmentExpression(node) {
|
||||
if (isProcessEnvAssignment(node.left, 'HOME')) {
|
||||
homeAssignments.push(node);
|
||||
}
|
||||
// Track actual USERPROFILE assignments (not mere text/comment mentions)
|
||||
if (isProcessEnvAssignment(node.left, 'USERPROFILE')) {
|
||||
userProfileAssigned = true;
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (homeAssignments.length === 0) return;
|
||||
|
||||
// Only suppress if USERPROFILE is actually ASSIGNED (not just mentioned in a comment)
|
||||
if (userProfileAssigned) return;
|
||||
|
||||
for (const node of homeAssignments) {
|
||||
context.report({ node, messageId: 'missingUserProfile' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
@@ -15,6 +15,15 @@ import noElapsedAssertion from './eslint-rules/no-elapsed-assertion.cjs';
|
||||
import noRawRmsyncInTests from './eslint-rules/no-raw-rmsync-in-tests.cjs';
|
||||
import noTautologicalAssert from './eslint-rules/no-tautological-assert.cjs';
|
||||
import noAdhocMarkdownParsing from './eslint-rules/no-adhoc-markdown-parsing.cjs';
|
||||
import noPathLiteralInAssert from './eslint-rules/no-path-literal-in-assert.cjs';
|
||||
import noPosixModeBitAssert from './eslint-rules/no-posix-mode-bit-assert.cjs';
|
||||
import noUnguardedNonportableExec from './eslint-rules/no-unguarded-nonportable-exec.cjs';
|
||||
import noCrlfFragileSplit from './eslint-rules/no-crlf-fragile-split.cjs';
|
||||
import noHardcodedTmp from './eslint-rules/no-hardcoded-tmp.cjs';
|
||||
import noBareNpmExec from './eslint-rules/no-bare-npm-exec.cjs';
|
||||
import requireUserprofileWithHome from './eslint-rules/require-userprofile-with-home.cjs';
|
||||
import normalizePathInContent from './eslint-rules/normalize-path-in-content.cjs';
|
||||
import requireFsOpFallback from './eslint-rules/require-fs-op-fallback.cjs';
|
||||
|
||||
const localPlugin = {
|
||||
rules: {
|
||||
@@ -24,6 +33,15 @@ const localPlugin = {
|
||||
'no-raw-rmsync-in-tests': noRawRmsyncInTests,
|
||||
'no-tautological-assert': noTautologicalAssert,
|
||||
'no-adhoc-markdown-parsing': noAdhocMarkdownParsing,
|
||||
'no-path-literal-in-assert': noPathLiteralInAssert,
|
||||
'no-posix-mode-bit-assert': noPosixModeBitAssert,
|
||||
'no-unguarded-nonportable-exec': noUnguardedNonportableExec,
|
||||
'no-crlf-fragile-split': noCrlfFragileSplit,
|
||||
'no-hardcoded-tmp': noHardcodedTmp,
|
||||
'no-bare-npm-exec': noBareNpmExec,
|
||||
'require-userprofile-with-home': requireUserprofileWithHome,
|
||||
'normalize-path-in-content': normalizePathInContent,
|
||||
'require-fs-op-fallback': requireFsOpFallback,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,6 +57,8 @@ export default tseslint.config(
|
||||
'**/*.generated.cjs',
|
||||
// ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs.
|
||||
'gsd-core/bin/lib/semver-compare.cjs',
|
||||
'gsd-core/bin/lib/host-integration.cjs',
|
||||
'gsd-core/bin/lib/install-engine.cjs',
|
||||
'gsd-core/bin/lib/capability-loader.cjs',
|
||||
'gsd-core/bin/lib/capability-source.cjs',
|
||||
'gsd-core/bin/lib/capability-ledger.cjs',
|
||||
@@ -172,6 +192,8 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/git-base-branch.cjs',
|
||||
// ADR-1213: tsc-generated runtime artifact — lint the src/capability-writer.cts source.
|
||||
'gsd-core/bin/lib/capability-writer.cjs',
|
||||
// issue #1754: tsc-generated runtime artifact — lint the src/cli-skew-check.cts source.
|
||||
'gsd-core/bin/lib/cli-skew-check.cjs',
|
||||
// issue #1355: tsc-generated runtime artifact — lint the src/teams-status.cts source.
|
||||
'gsd-core/bin/lib/teams-status.cjs',
|
||||
// ADR-1372: tsc-generated runtime artifact — lint the src/markdown-sectionizer.cts source.
|
||||
@@ -200,6 +222,42 @@ export default tseslint.config(
|
||||
// ADR-1372 T7: enforce use of the markdown-sectionizer seam; grandfather
|
||||
// pre-migration sites with // allow-adhoc-markdown: <reason>
|
||||
'local/no-adhoc-markdown-parsing': 'error',
|
||||
// ADR-1703 Phase 5: flag path-returning calls interpolated into content
|
||||
// (markdown @-references, workflow files, generated docs) without POSIX
|
||||
// normalization. Promoted to 'error' after precision review (path.basename
|
||||
// excluded; content heuristic tightened to genuine reference/config-dir
|
||||
// markers). See RULESET.CONTENT-PATH-NORMALIZATION in CONTEXT.md.
|
||||
'local/normalize-path-in-content': 'error',
|
||||
// ADR-1703 Phase 6: flag an unguarded fs.rename/fs.renameSync (the
|
||||
// atomic-publish primitive) that lacks a transient-errno fallback
|
||||
// (EPERM/EBUSY/EACCES retry or a Windows platform guard). See
|
||||
// DEFECT.WINDOWS-FS-OPS in CONTEXT.md.
|
||||
'local/require-fs-op-fallback': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// ── bin/install.js + scripts/build-hooks.js — ADR-1703 Phase 6 glob expansion ─
|
||||
// The top-level `bin/install.js` (generated installer) and `scripts/build-hooks.js`
|
||||
// (the build-side atomic-replace helper) are the two production surfaces named by
|
||||
// DEFECT.WINDOWS-FS-OPS that were NOT covered by the src/**/*.cts / gsd-core/bin/**/*.cjs
|
||||
// globs (ADR-1703 L124-126). This block brings them under the two production
|
||||
// portability rules. It deliberately does NOT apply the full js.recommended set —
|
||||
// bin/install.js is ~12k lines of generated code; the ADR's mandate is the
|
||||
// portability defect surface, not a broader generated-code style sweep.
|
||||
{
|
||||
files: ['bin/install.js', 'scripts/build-hooks.js'],
|
||||
plugins: {
|
||||
local: localPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'local/normalize-path-in-content': 'error',
|
||||
'local/require-fs-op-fallback': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -264,6 +322,20 @@ export default tseslint.config(
|
||||
'local/no-tautological-assert': 'error',
|
||||
// Ban source-grep pattern in tests — use require() + behavior assertions instead
|
||||
'local/no-source-grep': 'error',
|
||||
// Ban path-returning calls compared to hardcoded POSIX-slash literals (fails on Windows)
|
||||
'local/no-path-literal-in-assert': 'error',
|
||||
// Ban POSIX mode-bit assertions compared to octal literals (fails on Windows)
|
||||
'local/no-posix-mode-bit-assert': 'error',
|
||||
// Ban unguarded chmod exec-bit + sh/bash -c combos (fails on Windows Git Bash)
|
||||
'local/no-unguarded-nonportable-exec': 'error',
|
||||
// Ban CRLF-fragile file-content splits and regex patterns (ADR-1703 Phase 4)
|
||||
'local/no-crlf-fragile-split': 'error',
|
||||
// Ban hardcoded /tmp/ paths in fs.* calls (ADR-1703 Phase 4)
|
||||
'local/no-hardcoded-tmp': 'error',
|
||||
// Ban bare npm exec without shell:true (ADR-1703 Phase 4)
|
||||
'local/no-bare-npm-exec': 'error',
|
||||
// Require USERPROFILE alongside HOME assignments (ADR-1703 Phase 4)
|
||||
'local/require-userprofile-with-home': 'error',
|
||||
// Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
|
||||
@@ -919,7 +919,7 @@
|
||||
{
|
||||
"id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention",
|
||||
"klass": "DEFECT",
|
||||
"value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (lint-windows-test-portability) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit",
|
||||
"value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; run npm run lint:ci (local/no-unguarded-nonportable-exec now enforces this via ESLint) before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit",
|
||||
"line": 735
|
||||
},
|
||||
{
|
||||
@@ -931,7 +931,7 @@
|
||||
{
|
||||
"id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect",
|
||||
"klass": "DEFECT",
|
||||
"value": "npm run lint:windows-test-portability (tripwire: flags tests combining chmod exec-bit with sh/bash -c and no platform guard); watch CI windows matrix green before declaring a PR done",
|
||||
"value": "local/no-unguarded-nonportable-exec ESLint rule (enforced in tests/**/*.test.cjs via npm run lint); flags tests combining chmod exec-bit with sh/bash -c and no platform guard; watch CI windows matrix green before declaring a PR done",
|
||||
"line": 727
|
||||
},
|
||||
{
|
||||
@@ -943,7 +943,7 @@
|
||||
{
|
||||
"id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward",
|
||||
"klass": "DEFECT",
|
||||
"value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; annotate // windows-portability-ok: <reason> when a bypass is intentional",
|
||||
"value": "gate platform-specific execution with if (process.platform !== 'win32') — there is no opt-out annotation; structure any platform-specific code behind this guard; normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; see local/no-unguarded-nonportable-exec ESLint rule and docs/how-to/windows-portability.md for details",
|
||||
"line": 728
|
||||
},
|
||||
{
|
||||
|
||||
@@ -204,6 +204,24 @@ const projectRoot = require('./lib/project-root.cjs');
|
||||
// against any require/load-ordering edge where the export isn't bound yet
|
||||
// when this entrypoint is first required (#604).
|
||||
const findProjectRoot = (...args) => projectRoot.findProjectRoot(...args);
|
||||
|
||||
// #1754: CLI skew detection — warn (stderr, non-blocking) if this gsd-tools.cjs
|
||||
// is NOT the project-local install while a project-local install exists. Catches
|
||||
// the shadowing scenario from #1748 (stale global canary shadowing project-local).
|
||||
try {
|
||||
const _skew = require('./lib/cli-skew-check.cjs');
|
||||
const _skewRoot = findProjectRoot(process.cwd());
|
||||
if (_skewRoot) {
|
||||
const _skewLocal = path.join(_skewRoot, '.claude', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const _skewWarn = _skew.checkCliSkew({
|
||||
resolvedPath: path.resolve(__filename),
|
||||
projectRoot: _skewRoot,
|
||||
projectLocalExists: fs.existsSync(_skewLocal),
|
||||
});
|
||||
if (_skewWarn) process.stderr.write(_skewWarn + '\n');
|
||||
}
|
||||
} catch { /* advisory — never block */ }
|
||||
|
||||
const { getActiveWorkstream } = require('./lib/planning-workspace.cjs');
|
||||
const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/active-workstream-store.cjs');
|
||||
const state = require('./lib/state.cjs');
|
||||
@@ -1238,6 +1256,56 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dispatch-should-flatten': {
|
||||
// #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule.
|
||||
//
|
||||
// Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'),
|
||||
// looks up registry.runtimes[id].runtime.hostIntegration.dispatch, and
|
||||
// calls shouldFlattenDispatch(dispatch) from host-integration.cjs.
|
||||
//
|
||||
// Fail-closed: any unknown runtime, missing dispatch, or thrown error
|
||||
// yields `true` (inline — the always-safe default).
|
||||
//
|
||||
// Output:
|
||||
// --raw → prints exactly `true` or `false`
|
||||
// --json → prints { runtime, shouldFlatten, dispatch }
|
||||
// default → same as --raw
|
||||
try {
|
||||
// Resolve runtime using the same precedence as `config-get runtime`.
|
||||
const { resolveRuntime } = require('./lib/runtime-slash.cjs');
|
||||
const runtimeId = resolveRuntime(cwd);
|
||||
|
||||
// Look up dispatch from the capability registry.
|
||||
const registry = require('./lib/capability-registry.cjs');
|
||||
const runtimeEntry = registry.runtimes != null
|
||||
? registry.runtimes[runtimeId]
|
||||
: null;
|
||||
const dispatch = runtimeEntry?.runtime?.hostIntegration?.dispatch ?? null;
|
||||
|
||||
// Call shouldFlattenDispatch from host-integration.cjs.
|
||||
const hostIntegration = require('./lib/host-integration.cjs');
|
||||
const shouldFlat = dispatch !== null
|
||||
? hostIntegration.shouldFlattenDispatch(dispatch)
|
||||
: true; // fail-closed: unknown runtime → inline
|
||||
|
||||
const jsonIdx = args.indexOf('--json');
|
||||
if (jsonIdx !== -1) {
|
||||
output({
|
||||
runtime: runtimeId,
|
||||
shouldFlatten: shouldFlat,
|
||||
dispatch: dispatch,
|
||||
}, raw);
|
||||
} else {
|
||||
// --raw or default: print exactly true or false
|
||||
process.stdout.write(shouldFlat ? 'true' : 'false');
|
||||
}
|
||||
} catch {
|
||||
// Fail-closed on any error: inline is always safe.
|
||||
process.stdout.write('true');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'config-new-project': {
|
||||
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
|
||||
const handled = _dispatchNonFamily({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -713,6 +713,16 @@ const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-
|
||||
const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']);
|
||||
const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']);
|
||||
|
||||
// ADR-1239 Phase A: hostIntegration axes (MUST stay parity-identical to HOST_INTEGRATION_AXES in src/host-integration.cts)
|
||||
const VALID_EMBEDDING_MODES = new Set(['imperative', 'declarative']);
|
||||
const VALID_COMMAND_SURFACES = new Set(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']);
|
||||
const VALID_MODEL_MODES = new Set(['active', 'passive']);
|
||||
const VALID_HOOK_BUSES = new Set(['host', 'engine', 'none']);
|
||||
const VALID_STATE_IO = new Set(['filesystem', 'sandboxed-storage', 'session-log-append']);
|
||||
const VALID_TRANSPORTS = new Set(['mcp', 'native-extension']);
|
||||
const VALID_HOST_RUNTIMES = new Set(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']);
|
||||
const VALID_SUBAGENT_TOOLKITS = new Set(['full', 'read-only']);
|
||||
|
||||
// GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant)
|
||||
// Derived from the actual pairings in the 16 real runtime descriptors.
|
||||
const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
|
||||
@@ -1020,6 +1030,20 @@ function validateRuntimeBody(cap) {
|
||||
);
|
||||
}
|
||||
|
||||
// localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679)
|
||||
// Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry
|
||||
// generator catches any descriptor missing the field before regenerating.
|
||||
if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
|
||||
errors.push(
|
||||
'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
|
||||
'got: ' + JSON.stringify(r.localConfigDir),
|
||||
);
|
||||
} else if (!r.localConfigDir.startsWith('.')) {
|
||||
errors.push(
|
||||
'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
|
||||
);
|
||||
}
|
||||
|
||||
// extendedHookEvents — required array; every element must be in closed enum
|
||||
if (!Array.isArray(r.extendedHookEvents)) {
|
||||
errors.push(
|
||||
@@ -1037,6 +1061,162 @@ function validateRuntimeBody(cap) {
|
||||
}
|
||||
}
|
||||
|
||||
// hostIntegration — ADR-1239 Phase A: required object with closed-enum axes
|
||||
if (typeof r.hostIntegration !== 'object' || r.hostIntegration === null || Array.isArray(r.hostIntegration)) {
|
||||
errors.push('runtime.hostIntegration is required and must be an object');
|
||||
} else {
|
||||
const hi = r.hostIntegration;
|
||||
|
||||
// S2b: reserved-OWN-KEY guard on hostIntegration (CodeQL barrier — inline literal comparisons)
|
||||
if (Object.prototype.hasOwnProperty.call(hi, '__proto__')) {
|
||||
errors.push('runtime.hostIntegration must not contain reserved key "__proto__"');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(hi, 'constructor')) {
|
||||
errors.push('runtime.hostIntegration must not contain reserved key "constructor"');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(hi, 'prototype')) {
|
||||
errors.push('runtime.hostIntegration must not contain reserved key "prototype"');
|
||||
}
|
||||
|
||||
// embeddingMode
|
||||
if (hi.embeddingMode === '__proto__' || hi.embeddingMode === 'constructor' || hi.embeddingMode === 'prototype') {
|
||||
errors.push('runtime.hostIntegration.embeddingMode "' + hi.embeddingMode + '" is a reserved name');
|
||||
} else if (hi.embeddingMode !== 'undocumented' && !VALID_EMBEDDING_MODES.has(hi.embeddingMode)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.embeddingMode must be one of: ' + [...VALID_EMBEDDING_MODES].join(', ') +
|
||||
' (or "undocumented") (got: ' + JSON.stringify(hi.embeddingMode) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// commandSurface
|
||||
if (hi.commandSurface === '__proto__' || hi.commandSurface === 'constructor' || hi.commandSurface === 'prototype') {
|
||||
errors.push('runtime.hostIntegration.commandSurface "' + hi.commandSurface + '" is a reserved name');
|
||||
} else if (hi.commandSurface !== 'undocumented' && !VALID_COMMAND_SURFACES.has(hi.commandSurface)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.commandSurface must be one of: ' + [...VALID_COMMAND_SURFACES].join(', ') +
|
||||
' (or "undocumented") (got: ' + JSON.stringify(hi.commandSurface) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// modelMode
|
||||
if (hi.modelMode === '__proto__' || hi.modelMode === 'constructor' || hi.modelMode === 'prototype') {
|
||||
errors.push('runtime.hostIntegration.modelMode "' + hi.modelMode + '" is a reserved name');
|
||||
} else if (hi.modelMode !== 'undocumented' && !VALID_MODEL_MODES.has(hi.modelMode)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.modelMode must be one of: ' + [...VALID_MODEL_MODES].join(', ') +
|
||||
' (or "undocumented") (got: ' + JSON.stringify(hi.modelMode) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// hookBus
|
||||
if (hi.hookBus === '__proto__' || hi.hookBus === 'constructor' || hi.hookBus === 'prototype') {
|
||||
errors.push('runtime.hostIntegration.hookBus "' + hi.hookBus + '" is a reserved name');
|
||||
} else if (hi.hookBus !== 'undocumented' && !VALID_HOOK_BUSES.has(hi.hookBus)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.hookBus must be one of: ' + [...VALID_HOOK_BUSES].join(', ') +
|
||||
' (or "undocumented") (got: ' + JSON.stringify(hi.hookBus) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// stateIO
|
||||
if (hi.stateIO === '__proto__' || hi.stateIO === 'constructor' || hi.stateIO === 'prototype') {
|
||||
errors.push('runtime.hostIntegration.stateIO "' + hi.stateIO + '" is a reserved name');
|
||||
} else if (hi.stateIO !== 'undocumented' && !VALID_STATE_IO.has(hi.stateIO)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.stateIO must be one of: ' + [...VALID_STATE_IO].join(', ') +
|
||||
' (or "undocumented") (got: ' + JSON.stringify(hi.stateIO) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// transport
|
||||
if (hi.transport === '__proto__' || hi.transport === 'constructor' || hi.transport === 'prototype') {
|
||||
errors.push('runtime.hostIntegration.transport "' + hi.transport + '" is a reserved name');
|
||||
} else if (hi.transport !== 'undocumented' && !VALID_TRANSPORTS.has(hi.transport)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.transport must be one of: ' + [...VALID_TRANSPORTS].join(', ') +
|
||||
' (or "undocumented") (got: ' + JSON.stringify(hi.transport) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// runtime (axis)
|
||||
if (hi.runtime === '__proto__' || hi.runtime === 'constructor' || hi.runtime === 'prototype') {
|
||||
errors.push('runtime.hostIntegration.runtime "' + hi.runtime + '" is a reserved name');
|
||||
} else if (hi.runtime !== 'undocumented' && !VALID_HOST_RUNTIMES.has(hi.runtime)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.runtime must be one of: ' + [...VALID_HOST_RUNTIMES].join(', ') +
|
||||
' (or "undocumented") (got: ' + JSON.stringify(hi.runtime) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// dispatch — required object
|
||||
if (typeof hi.dispatch !== 'object' || hi.dispatch === null || Array.isArray(hi.dispatch)) {
|
||||
errors.push('runtime.hostIntegration.dispatch must be an object');
|
||||
} else {
|
||||
const d = hi.dispatch;
|
||||
|
||||
// S2b: reserved-OWN-KEY guard on dispatch (CodeQL barrier — inline literal comparisons)
|
||||
if (Object.prototype.hasOwnProperty.call(d, '__proto__')) {
|
||||
errors.push('runtime.hostIntegration.dispatch must not contain reserved key "__proto__"');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(d, 'constructor')) {
|
||||
errors.push('runtime.hostIntegration.dispatch must not contain reserved key "constructor"');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(d, 'prototype')) {
|
||||
errors.push('runtime.hostIntegration.dispatch must not contain reserved key "prototype"');
|
||||
}
|
||||
|
||||
// namedDispatch — boolean or 'undocumented'
|
||||
if (typeof d.namedDispatch !== 'boolean' && d.namedDispatch !== 'undocumented') {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.dispatch.namedDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.namedDispatch) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// nested — boolean or 'undocumented'
|
||||
if (typeof d.nested !== 'boolean' && d.nested !== 'undocumented') {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.dispatch.nested must be a boolean or "undocumented" (got: ' + JSON.stringify(d.nested) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// background — boolean or 'undocumented'
|
||||
if (typeof d.background !== 'boolean' && d.background !== 'undocumented') {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.dispatch.background must be a boolean or "undocumented" (got: ' + JSON.stringify(d.background) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// subagentToolkit — closed enum or 'undocumented'
|
||||
if (d.subagentToolkit === '__proto__' || d.subagentToolkit === 'constructor' || d.subagentToolkit === 'prototype') {
|
||||
errors.push('runtime.hostIntegration.dispatch.subagentToolkit "' + d.subagentToolkit + '" is a reserved name');
|
||||
} else if (d.subagentToolkit !== 'undocumented' && !VALID_SUBAGENT_TOOLKITS.has(d.subagentToolkit)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.dispatch.subagentToolkit must be one of: ' + [...VALID_SUBAGENT_TOOLKITS].join(', ') +
|
||||
' (or "undocumented") (got: ' + JSON.stringify(d.subagentToolkit) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// maxDepth — integer >= -1 or 'undocumented'
|
||||
if (d.maxDepth !== 'undocumented' && (!Number.isInteger(d.maxDepth) || d.maxDepth < -1)) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.dispatch.maxDepth must be an integer >= -1 or "undocumented" (got: ' + JSON.stringify(d.maxDepth) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// backgroundDispatch — REQUIRED (all 16 runtime descriptors carry it, matching the sibling fields
|
||||
// namedDispatch/nested/background/subagentToolkit/maxDepth which are all required).
|
||||
if (!Object.prototype.hasOwnProperty.call(d, 'backgroundDispatch')) {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.dispatch.backgroundDispatch is required (must be a boolean or "undocumented")',
|
||||
);
|
||||
} else if (typeof d.backgroundDispatch !== 'boolean' && d.backgroundDispatch !== 'undocumented') {
|
||||
errors.push(
|
||||
'runtime.hostIntegration.dispatch.backgroundDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.backgroundDispatch) + ')',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX)
|
||||
// Only check if both fields are valid strings (individual field validators above report type errors).
|
||||
if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') {
|
||||
@@ -2052,6 +2232,24 @@ module.exports = {
|
||||
VALID_INSTALL_SURFACES,
|
||||
VALID_PERMISSION_WRITERS,
|
||||
VALID_EXTENDED_HOOK_EVENTS,
|
||||
VALID_EMBEDDING_MODES,
|
||||
VALID_COMMAND_SURFACES,
|
||||
VALID_MODEL_MODES,
|
||||
VALID_HOOK_BUSES,
|
||||
VALID_STATE_IO,
|
||||
VALID_TRANSPORTS,
|
||||
VALID_HOST_RUNTIMES,
|
||||
VALID_SUBAGENT_TOOLKITS,
|
||||
_HOST_INTEGRATION_VOCAB: {
|
||||
embeddingMode: [...VALID_EMBEDDING_MODES],
|
||||
commandSurface: [...VALID_COMMAND_SURFACES],
|
||||
modelMode: [...VALID_MODEL_MODES],
|
||||
hookBus: [...VALID_HOOK_BUSES],
|
||||
stateIO: [...VALID_STATE_IO],
|
||||
transport: [...VALID_TRANSPORTS],
|
||||
runtime: [...VALID_HOST_RUNTIMES],
|
||||
subagentToolkit: [...VALID_SUBAGENT_TOOLKITS],
|
||||
},
|
||||
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
|
||||
GEMINI_AGENT_EVENTS,
|
||||
CLAUDE_FAMILY_EVENTS,
|
||||
|
||||
@@ -9,6 +9,30 @@
|
||||
// In .cts (CommonJS output) files, `require` is available as a global.
|
||||
const _require = require;
|
||||
const path = _require('node:path');
|
||||
/**
|
||||
* Asserts that `destSubpath` resolves to a path inside `configDir`.
|
||||
*
|
||||
* Rejects any path that escapes the configDir root (e.g. "../../etc") and any
|
||||
* path containing a NUL byte. This is a security gate for Phase B of
|
||||
* ADR-1239: third-party descriptors must never be able to write outside the
|
||||
* designated config home directory.
|
||||
*
|
||||
* @param configDir - The root config directory (e.g. ~/.claude).
|
||||
* @param destSubpath - The relative path declared by the runtime descriptor.
|
||||
* @returns The resolved absolute path under configDir.
|
||||
* @throws {Error} if destSubpath escapes configDir or contains a NUL byte.
|
||||
*/
|
||||
function assertDestWithinConfigHome(configDir, destSubpath) {
|
||||
if (destSubpath.includes('\0')) {
|
||||
throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`);
|
||||
}
|
||||
const root = path.resolve(configDir);
|
||||
const resolved = path.resolve(configDir, destSubpath);
|
||||
if (resolved === root || !resolved.startsWith(root + path.sep)) {
|
||||
throw new Error(`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function errorMessage(err) {
|
||||
if (err instanceof Error)
|
||||
return err.message;
|
||||
@@ -36,10 +60,34 @@ function createRuntimeArtifactInstallPlan(args) {
|
||||
platform,
|
||||
resolveAttribution,
|
||||
};
|
||||
// ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply
|
||||
// the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter
|
||||
// → normalize). This mirrors the exact per-file order in the inline agent loop
|
||||
// in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg
|
||||
// to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
|
||||
// NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
|
||||
const os = _require('node:os');
|
||||
const homedirFn = homedir ?? (() => os.homedir());
|
||||
const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
|
||||
const homeDir = homedirFn().replace(/\\/g, '/');
|
||||
const isGlobal = scope === 'global';
|
||||
const isOpencode = layout.runtime === 'opencode';
|
||||
const isWindowsHost = (platform ?? process.platform) === 'win32';
|
||||
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
|
||||
const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
|
||||
const agentCtx = { runtime: layout.runtime, pathPrefix, attribution };
|
||||
for (const kind of layout.kinds) {
|
||||
let stagedDir;
|
||||
try {
|
||||
stagedDir = kind.stage(resolvedProfile);
|
||||
if (kind.kind === 'agents') {
|
||||
// ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies
|
||||
// the full inline-loop order: pathRewrites → attribution → converter → normalize.
|
||||
// The cross-cutting is now PRE-converter (inside staging), not POST.
|
||||
stagedDir = kind.stage(resolvedProfile, agentCtx);
|
||||
}
|
||||
else {
|
||||
stagedDir = kind.stage(resolvedProfile);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
|
||||
@@ -54,6 +102,8 @@ function createRuntimeArtifactInstallPlan(args) {
|
||||
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
|
||||
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
|
||||
}
|
||||
// agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx.
|
||||
// No POST-step needed. sourceDir stays as stagedDir.
|
||||
}
|
||||
catch (err) {
|
||||
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
|
||||
@@ -61,7 +111,7 @@ function createRuntimeArtifactInstallPlan(args) {
|
||||
items.push({
|
||||
kind: kind.kind,
|
||||
sourceDir,
|
||||
destDir: path.join(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
});
|
||||
}
|
||||
return { ok: true, plan: { items, cleanupDirs } };
|
||||
@@ -70,8 +120,8 @@ function createRuntimeArtifactUninstallPlan(layout) {
|
||||
return {
|
||||
items: layout.kinds.map((kind) => ({
|
||||
kind: kind.kind,
|
||||
destDir: path.join(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
})),
|
||||
};
|
||||
}
|
||||
module.exports = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
|
||||
module.exports = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
|
||||
|
||||
@@ -61,7 +61,7 @@ fi
|
||||
|
||||
When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies.
|
||||
|
||||
When `--interactive` is set, discuss runs inline with questions. On Codex, where a backgrounded agent can still spawn subagents, plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. On every other runtime (Claude Code and all other non-Codex runtimes), backgrounded agents cannot reliably nest subagents, so plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions.
|
||||
When `--interactive` is set, discuss runs inline with questions. When `dispatch-should-flatten` returns `false` (e.g. codex, cursor — runtimes where a backgrounded agent can still spawn subagents), plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. When `dispatch-should-flatten` returns `true` (e.g. claude and other runtimes where backgrounded agents cannot reliably nest subagents), plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions.
|
||||
|
||||
When `PLAN_STRATEGY=converge`, the planning step MUST invoke the plan-review convergence workflow instead of `gsd-plan-phase`. `--cross-ai` is an alias for `--converge`. Forward `CONVERGENCE_ARGS` exactly as parsed so reviewer flags and `--max-cycles N` retain the same meaning as they have on `/gsd:plan-review-convergence`.
|
||||
|
||||
@@ -358,13 +358,13 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
|
||||
|
||||
**3b. Plan**
|
||||
|
||||
**If `INTERACTIVE` is set:** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. Resolve the runtime first:
|
||||
**If `INTERACTIVE` is set:** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. Resolve first:
|
||||
|
||||
```bash
|
||||
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
|
||||
FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true")
|
||||
```
|
||||
|
||||
- **If `RUNTIME` is `codex`:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4).
|
||||
- **If `FLATTEN` is `false`:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4).
|
||||
|
||||
- If `PLAN_STRATEGY=converge`, print: `◆ Spawning background plan-convergence loop for phase ${PHASE_NUM}... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)`
|
||||
|
||||
@@ -388,7 +388,7 @@ RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null ||
|
||||
|
||||
Store the agent task_id. After discuss for the next phase completes (or if no next phase), wait for the plan agent to finish before proceeding to execute.
|
||||
|
||||
- **Otherwise (Claude Code or any other non-Codex runtime):** Run plan **inline** (do NOT background) so the plan-checker runs. The next phase's discuss does not overlap planning here — correctness over overlap.
|
||||
- **Otherwise (`FLATTEN` is `true` — run inline):** Run plan **inline** (do NOT background) so the plan-checker runs. The next phase's discuss does not overlap planning here — correctness over overlap.
|
||||
|
||||
- If `PLAN_STRATEGY=converge`:
|
||||
|
||||
@@ -420,13 +420,13 @@ Verify plan produced output — re-run `init phase-op` and check `has_plans`. If
|
||||
|
||||
**3c. Execute**
|
||||
|
||||
**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already) and verify plans exist. Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. Resolve the runtime first:
|
||||
**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already) and verify plans exist. Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`. Resolve first:
|
||||
|
||||
```bash
|
||||
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
|
||||
FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true")
|
||||
```
|
||||
|
||||
- **If `RUNTIME` is `codex`:** Dispatch execute as a background agent:
|
||||
- **If `FLATTEN` is `false`:** Dispatch execute as a background agent:
|
||||
|
||||
```
|
||||
Agent(
|
||||
@@ -438,7 +438,7 @@ Agent(
|
||||
|
||||
Store the agent task_id. The workflow can now start discussing the next phase while this phase executes in the background. Before starting post-execution routing for this phase, wait for the execute agent to complete.
|
||||
|
||||
- **Otherwise (Claude Code or any other non-Codex runtime):** Run execute **inline** (do NOT background) so worktree isolation and verification run:
|
||||
- **Otherwise (`FLATTEN` is `true` — run inline):** Run execute **inline** (do NOT background) so worktree isolation and verification run:
|
||||
|
||||
```
|
||||
Skill(skill="gsd-execute-phase", args="${PHASE_NUM} --no-transition")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<purpose>
|
||||
|
||||
Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and runs plan/execute inline (backgrounded only on Codex), and loops back to the dashboard after each action. Enables parallel phase work from one terminal.
|
||||
Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and runs plan/execute inline (backgrounded when dispatch-should-flatten returns false), and loops back to the dashboard after each action. Enables parallel phase work from one terminal.
|
||||
|
||||
</purpose>
|
||||
|
||||
@@ -45,7 +45,7 @@ Display startup banner:
|
||||
{milestone_version} — {milestone_name}
|
||||
{phase_count} phases · {completed_count} complete
|
||||
|
||||
✓ Discuss → inline ◆ Plan/Execute → inline (background on Codex)
|
||||
✓ Discuss → inline ◆ Plan/Execute → inline (background when FLATTEN=false)
|
||||
Dashboard auto-refreshes when background work is active.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
```
|
||||
@@ -222,10 +222,10 @@ Go to exit step.
|
||||
|
||||
### Compound Action (background + inline)
|
||||
|
||||
When the user selects a compound option, behavior depends on the runtime — the Plan Phase N / Execute Phase N handlers below resolve it via `gsd_run query config-get runtime`:
|
||||
When the user selects a compound option, behavior depends on whether the runtime supports background dispatch of nesting-capable orchestrators — the Plan Phase N / Execute Phase N handlers below resolve it via `gsd_run query dispatch-should-flatten` (#1708):
|
||||
|
||||
- **On Codex:** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run verification actions, then run the inline discuss; the background agents continue while you verify/discuss.
|
||||
- **On Claude Code or any other non-Codex runtime:** run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run verification actions, then run the inline discuss. There is no overlap.
|
||||
- **If `FLATTEN` is `false` (the host can background a nesting-capable orchestrator — e.g. codex, cursor):** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run verification actions, then run the inline discuss; the background agents continue while you verify/discuss.
|
||||
- **Otherwise (`FLATTEN` is `true` — run inline):** run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run verification actions, then run the inline discuss. There is no overlap.
|
||||
|
||||
Inline verification:
|
||||
|
||||
@@ -254,13 +254,13 @@ After discuss completes, loop back to dashboard step.
|
||||
|
||||
### Plan Phase N
|
||||
|
||||
Planning runs autonomously. **First resolve the runtime.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background.
|
||||
Planning runs autonomously. **First resolve whether background dispatch is safe.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`.
|
||||
|
||||
```bash
|
||||
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
|
||||
FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true")
|
||||
```
|
||||
|
||||
**If `RUNTIME` is `codex`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags:
|
||||
**If `FLATTEN` is `false`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags:
|
||||
|
||||
```
|
||||
Agent(
|
||||
@@ -282,7 +282,7 @@ Important: You are running in the background. Do NOT use AskUserQuestion — mak
|
||||
)
|
||||
```
|
||||
|
||||
> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any planning work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume planning-related work when the subagent result is available.
|
||||
> **ORCHESTRATOR RULE — BACKGROUND DISPATCH**: After calling Agent() above with `run_in_background=true`, do NOT do any planning work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume planning-related work when the subagent result is available.
|
||||
|
||||
Display:
|
||||
|
||||
@@ -292,7 +292,7 @@ Display:
|
||||
|
||||
Loop back to dashboard step.
|
||||
|
||||
**Otherwise (Claude Code or any other non-Codex runtime):** Run plan inline so the plan-checker and quality gates actually run — do NOT wrap it in `Agent(run_in_background=true, …)`:
|
||||
**Otherwise (`FLATTEN` is `true` — run inline):** Run plan inline so the plan-checker and quality gates actually run — do NOT wrap it in `Agent(run_in_background=true, …)`:
|
||||
|
||||
```
|
||||
Skill(skill="gsd-plan-phase", args="{N} --auto {manager_flags.plan}")
|
||||
@@ -308,13 +308,13 @@ Then loop back to dashboard step.
|
||||
|
||||
### Execute Phase N
|
||||
|
||||
Execution runs autonomously. **First resolve the runtime.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background.
|
||||
Execution runs autonomously. **First resolve whether background dispatch is safe.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). This is determined from the documentation-sourced dispatch capability in the registry (#1708); Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except where `dispatch-should-flatten` returns `false`.
|
||||
|
||||
```bash
|
||||
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
|
||||
FLATTEN=$(gsd_run query dispatch-should-flatten --raw 2>/dev/null || echo "true")
|
||||
```
|
||||
|
||||
**If `RUNTIME` is `codex`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags:
|
||||
**If `FLATTEN` is `false`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags:
|
||||
|
||||
```
|
||||
Agent(
|
||||
@@ -336,7 +336,7 @@ Important: You are running in the background. Do NOT use AskUserQuestion — mak
|
||||
)
|
||||
```
|
||||
|
||||
> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any execution work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume execution-related work when the subagent result is available.
|
||||
> **ORCHESTRATOR RULE — BACKGROUND DISPATCH**: After calling Agent() above with `run_in_background=true`, do NOT do any execution work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume execution-related work when the subagent result is available.
|
||||
|
||||
Display:
|
||||
|
||||
@@ -346,7 +346,7 @@ Display:
|
||||
|
||||
Loop back to dashboard step.
|
||||
|
||||
**Otherwise (Claude Code or any other non-Codex runtime):** Run execute inline so worktree isolation and the verifier actually run — do NOT wrap it in `Agent(run_in_background=true, …)`:
|
||||
**Otherwise (`FLATTEN` is `true` — run inline):** Run execute inline so worktree isolation and the verifier actually run — do NOT wrap it in `Agent(run_in_background=true, …)`:
|
||||
|
||||
```
|
||||
Skill(skill="gsd-execute-phase", args="{N} {manager_flags.execute}")
|
||||
|
||||
@@ -94,9 +94,8 @@
|
||||
"pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
|
||||
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-windows-test-portability.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs",
|
||||
"lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs",
|
||||
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
|
||||
"lint:windows-test-portability": "node scripts/lint-windows-test-portability.cjs",
|
||||
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
|
||||
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
||||
"lint:skill-deps": "node scripts/lint-skill-deps.cjs",
|
||||
|
||||
@@ -118,6 +118,7 @@ const RULES = [
|
||||
tests: [
|
||||
'tests/semver-compare.test.cjs',
|
||||
'tests/bug-10-semver-policy-consolidation.test.cjs',
|
||||
'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard)
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -134,6 +135,7 @@ const RULES = [
|
||||
'tests/install-path-detection.test.cjs',
|
||||
'tests/release-tarball-smoke.install.test.cjs',
|
||||
'tests/runtime-artifact-layout.test.cjs',
|
||||
'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard)
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -212,17 +214,44 @@ const RULES = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'configuration',
|
||||
match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)),
|
||||
name: 'configuration',
|
||||
match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)),
|
||||
tests: [
|
||||
'tests/config.test.cjs',
|
||||
'tests/config-get-default.test.cjs',
|
||||
'tests/configuration-migrate-config.test.cjs',
|
||||
'tests/model-catalog-runtime-defaults.test.cjs',
|
||||
'tests/model-profiles.test.cjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
// ADR-1703 portability lint surface. Editing a rule, the shared vocab/guard
|
||||
// helpers, or the eslint config that wires them must re-run the rule suites
|
||||
// + the disable-ban. The disable-ban also scans bin/install.js and
|
||||
// scripts/build-hooks.js (the Phase 6 glob-expansion surface), so changes
|
||||
// to those files re-run it too.
|
||||
name: 'portability lint rules (ADR-1703)',
|
||||
match: path => path.startsWith('eslint-rules/') ||
|
||||
path === 'eslint.config.mjs' ||
|
||||
path === 'bin/install.js' ||
|
||||
path === 'scripts/build-hooks.js',
|
||||
tests: [
|
||||
'tests/config.test.cjs',
|
||||
'tests/config-get-default.test.cjs',
|
||||
'tests/configuration-migrate-config.test.cjs',
|
||||
'tests/model-catalog-runtime-defaults.test.cjs',
|
||||
'tests/model-profiles.test.cjs',
|
||||
'tests/portability-rule-disable-ban.test.cjs',
|
||||
'tests/portability-vocab-drift.test.cjs',
|
||||
// All nine RuleTester suites (P1–P6) — editing any rule / the shared
|
||||
// vocab+guard helpers / the eslint config re-runs the full rule family.
|
||||
'tests/no-path-literal-in-assert.rule.test.cjs',
|
||||
'tests/no-posix-mode-bit-assert.rule.test.cjs',
|
||||
'tests/no-unguarded-nonportable-exec.rule.test.cjs',
|
||||
'tests/no-crlf-fragile-split.rule.test.cjs',
|
||||
'tests/no-hardcoded-tmp.rule.test.cjs',
|
||||
'tests/no-bare-npm-exec.rule.test.cjs',
|
||||
'tests/require-userprofile-with-home.rule.test.cjs',
|
||||
'tests/normalize-path-in-content.rule.test.cjs',
|
||||
'tests/require-fs-op-fallback.rule.test.cjs',
|
||||
],
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
@@ -329,7 +358,7 @@ function classify(files) {
|
||||
// Determine if this file is product/pipeline code.
|
||||
// docs/ and root-level .md files are intentionally excluded.
|
||||
if (
|
||||
['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) ||
|
||||
['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/', 'eslint-rules/'].some(p => file.startsWith(p)) ||
|
||||
file === 'package.json' || file === 'package-lock.json' ||
|
||||
(file.startsWith('tsconfig') && file.endsWith('.json')) ||
|
||||
file.startsWith('.github/rulesets/')
|
||||
|
||||
@@ -313,7 +313,6 @@
|
||||
"tests/verify-test-quality.test.cjs :: source-text-is-the-product",
|
||||
"tests/verify-work-auto-transition.test.cjs :: source-text-is-the-product",
|
||||
"tests/windows-robustness.test.cjs :: source-text-is-the-product",
|
||||
"tests/windows-test-parity-guard.test.cjs :: structural-regression-guard",
|
||||
"tests/workflow-compat.test.cjs :: source-text-is-the-product",
|
||||
"tests/workflow-guard-registration.test.cjs :: structural-regression-guard",
|
||||
"tests/workflow-maintainer-skip.test.cjs :: source-text-is-the-product",
|
||||
|
||||
@@ -142,9 +142,10 @@
|
||||
"install-regressions.test.cjs",
|
||||
"install-runtime-artifacts.test.cjs",
|
||||
"install-update-marker.test.cjs",
|
||||
"install-write-confinement.test.cjs",
|
||||
"install.test.cjs"
|
||||
],
|
||||
"issue": "TBD"
|
||||
"issue": "1679"
|
||||
},
|
||||
"validate": {
|
||||
"files": [
|
||||
@@ -184,6 +185,14 @@
|
||||
"fix-1464-docs-manifest-validation.test.cjs"
|
||||
],
|
||||
"issue": "1496"
|
||||
},
|
||||
"host-integration": {
|
||||
"files": [
|
||||
"host-integration.test.cjs",
|
||||
"host-integration-validator-parity.test.cjs",
|
||||
"host-integration-descriptors.test.cjs"
|
||||
],
|
||||
"issue": "1684"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* lint-windows-test-portability.cjs — flag tests that combine chmod exec-bit
|
||||
* with bare sh/bash -c without a platform guard.
|
||||
*
|
||||
* ## Why
|
||||
*
|
||||
* Windows Git Bash (msys2) does not honour Node's chmod exec bit for
|
||||
* PATH-executing extension-less scripts. A test that (a) makes a fixture
|
||||
* executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass
|
||||
* on Mac/Linux but fail only in the CI `test (windows-latest, *)` /
|
||||
* `full test (windows-latest, *)` lanes, producing a hard-to-diagnose
|
||||
* false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY.
|
||||
*
|
||||
* ## What this enforces
|
||||
*
|
||||
* For every file in tests/**\/*.test.cjs (recursive, excluding node_modules):
|
||||
* - makesExecutable: contains chmodSync?( with an exec-bit octal literal
|
||||
* - shellDashC: contains a sh/bash -c invocation (array form or string literal)
|
||||
* - guarded: contains a process.platform / os.platform() / win32 / isWindows guard
|
||||
* - optOut: contains the literal `windows-portability-ok`
|
||||
* VIOLATION = makesExecutable && shellDashC && !guarded && !optOut
|
||||
*
|
||||
* ## Remediation
|
||||
*
|
||||
* Gate the bare-command execution with `if (process.platform !== 'win32')`,
|
||||
* or invoke via an explicit interpreter (`sh <path>`), or annotate
|
||||
* `// windows-portability-ok: <reason>`.
|
||||
*
|
||||
* ## Export contract (for unit tests)
|
||||
*
|
||||
* When required as a module (`require.main !== module`) this file exports
|
||||
* `scanContent(source)` → { makesExecutable, shellDashC, guarded, optOut, violation }.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ─── Detection regexes ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Match chmod/chmodSync( calls with an octal mode literal whose exec bits are
|
||||
* set, e.g. `fs.chmodSync(p, 0o755)` or `chmod(file, 0o111)`.
|
||||
*/
|
||||
const CHMOD_RE = /chmod(?:Sync)?\s*\([^,;]+,\s*0o([0-7]{3})\b/g;
|
||||
|
||||
/**
|
||||
* Array form: execFileSync/spawnSync/exec* with 'sh' or 'bash' (optionally
|
||||
* prefixed) as the first arg and '-c' as an element of the args array.
|
||||
* e.g. execFileSync('bash', ['-c', ...]) or spawnSync('/bin/sh', ['-c', ...])
|
||||
*/
|
||||
const SHELL_ARRAY_RE =
|
||||
/(?:execFile(?:Sync)?|spawnSync|spawn|exec)\s*\(\s*['"`](?:\/(?:usr\/)?bin\/)?(?:bash|sh)['"`]\s*,\s*\[[^\]]*['"]-c['"]/;
|
||||
|
||||
/**
|
||||
* String-literal form: any string containing `bash -c` or `sh -c`.
|
||||
*/
|
||||
const SHELL_STRING_RE = /['"`][^'"`\n]*(?:bash|sh)\s+-c[^'"`\n]*['"`]/;
|
||||
|
||||
/** Platform guard presence. */
|
||||
const GUARD_RE = /process\.platform|os\.platform\s*\(|\bwin32\b|\bisWindows\b/;
|
||||
|
||||
/** Opt-out annotation. */
|
||||
const OPT_OUT_RE = /windows-portability-ok/;
|
||||
|
||||
// ─── Pure scanning function (exported for unit tests) ────────────────────────
|
||||
|
||||
/**
|
||||
* Scan a single file's source text and return detection flags.
|
||||
*
|
||||
* @param {string} source - The file contents as a string.
|
||||
* @returns {{ makesExecutable: boolean, shellDashC: boolean, guarded: boolean, optOut: boolean, violation: boolean }}
|
||||
*/
|
||||
function scanContent(source) {
|
||||
// Reset stateful regex before use.
|
||||
CHMOD_RE.lastIndex = 0;
|
||||
|
||||
let makesExecutable = false;
|
||||
let match;
|
||||
while ((match = CHMOD_RE.exec(source)) !== null) {
|
||||
const oct = match[1];
|
||||
if ((parseInt(oct, 8) & 0o111) !== 0) {
|
||||
makesExecutable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const shellDashC = SHELL_ARRAY_RE.test(source) || SHELL_STRING_RE.test(source);
|
||||
const guarded = GUARD_RE.test(source);
|
||||
const optOut = OPT_OUT_RE.test(source);
|
||||
const violation = makesExecutable && shellDashC && !guarded && !optOut;
|
||||
|
||||
return { makesExecutable, shellDashC, guarded, optOut, violation };
|
||||
}
|
||||
|
||||
// ─── Filesystem walker ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively collect all *.test.cjs files under `dir`, excluding node_modules.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @param {string[]} [acc]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function collectTestFiles(dir, acc) {
|
||||
acc = acc || [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectTestFiles(full, acc);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.test.cjs')) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const TESTS_DIR = path.join(ROOT, 'tests');
|
||||
|
||||
const files = collectTestFiles(TESTS_DIR);
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
let source;
|
||||
try {
|
||||
source = fs.readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const { violation } = scanContent(source);
|
||||
if (violation) {
|
||||
const rel = path.relative(ROOT, file).replace(/\\/g, '/');
|
||||
violations.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
for (const rel of violations) {
|
||||
process.stderr.write(
|
||||
`${rel}: chmod-executable + sh/bash -c with no platform guard\n`,
|
||||
);
|
||||
}
|
||||
process.stderr.write(
|
||||
'\nWindows Git Bash does not honor Node\'s chmod exec bit for ' +
|
||||
'PATH-executing extension-less scripts ' +
|
||||
'(CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY). ' +
|
||||
'Gate the bare-command execution with ' +
|
||||
'`if (process.platform !== \'win32\')`, or invoke via an explicit ' +
|
||||
'interpreter (`sh <path>`), or annotate ' +
|
||||
'`// windows-portability-ok: <reason>`.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`ok lint-windows-test-portability: ${files.length} file(s) scanned, no violations`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Module boundary ─────────────────────────────────────────────────────────
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
} else {
|
||||
module.exports = { scanContent };
|
||||
}
|
||||
@@ -30,10 +30,52 @@
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
const { readFileSync } = require('fs');
|
||||
const fs = require('fs');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
|
||||
// ── Resilient stdin reader ────────────────────────────────────────────────────
|
||||
// On macOS, libuv sets the stdin pipe fd to non-blocking mode. A synchronous
|
||||
// readFileSync(process.stdin.fd) can therefore throw EAGAIN ("resource
|
||||
// temporarily unavailable") when the writer hasn't yet filled the pipe — this
|
||||
// is intermittent under heavy CI shard load and causes a spurious status 2
|
||||
// exit. We work around it by calling fs.readSync in a loop and retrying on
|
||||
// EAGAIN with a 1 ms synchronous pause (Atomics.wait on a fresh SharedArrayBuffer
|
||||
// — no hot spin, no real-clock dependency, works under --experimental-vm-modules).
|
||||
/**
|
||||
* Read all of stdin synchronously, retrying on EAGAIN.
|
||||
*
|
||||
* @returns {string} UTF-8 decoded full stdin content.
|
||||
*/
|
||||
function readStdinSync() {
|
||||
const BUF_SIZE = 64 * 1024; // 64 KB chunks
|
||||
const buf = Buffer.allocUnsafe(BUF_SIZE);
|
||||
const chunks = [];
|
||||
|
||||
for (;;) {
|
||||
let bytesRead;
|
||||
try {
|
||||
bytesRead = fs.readSync(process.stdin.fd, buf, 0, BUF_SIZE, null);
|
||||
} catch (err) {
|
||||
if (err.code === 'EAGAIN') {
|
||||
// Non-blocking pipe not yet ready — yield for ~1 ms then retry.
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1);
|
||||
continue;
|
||||
}
|
||||
if (err.code === 'EOF') {
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (bytesRead === 0) {
|
||||
break; // Clean EOF
|
||||
}
|
||||
chunks.push(Buffer.from(buf.slice(0, bytesRead)));
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
// ── Per-module mutation score ratchet ─────────────────────────────────────────
|
||||
// ADR-456 / issue #1187: every covered module declares a minScore floor.
|
||||
//
|
||||
@@ -195,7 +237,7 @@ function resolveChangedFiles(args) {
|
||||
// When --base is absent AND stdin is not a TTY (isTTY is falsy / undefined),
|
||||
// read a newline-delimited file list from stdin.
|
||||
if (!args.base && process.stdin.isTTY !== true) {
|
||||
const raw = readFileSync(process.stdin.fd, 'utf8');
|
||||
const raw = readStdinSync();
|
||||
return raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -318,6 +360,6 @@ function resolveMutationBreak(raw) {
|
||||
|
||||
// Export internals for programmatic use (tests/mutation-matrix-ratchet.test.cjs).
|
||||
// The require.main guard prevents main() from running when this file is require()d.
|
||||
module.exports = { COVERED, TARGET_MUTATION_SCORE, resolveMutationBreak };
|
||||
module.exports = { COVERED, TARGET_MUTATION_SCORE, resolveMutationBreak, readStdinSync };
|
||||
|
||||
if (require.main === module) runMain(main);
|
||||
|
||||
@@ -94,6 +94,14 @@ ALLOWLIST=(
|
||||
# real injection payloads to prove the validator rejects them. See
|
||||
# DEFECT.PROMPT-INJECTION-SCAN-COLLISION in CONTEXT.md.
|
||||
'tests/windsurf-conversion.test.cjs'
|
||||
# RuleTester fixtures for the local/no-unguarded-nonportable-exec ESLint rule
|
||||
# contain shell-exec command strings (exec("sh -c …"), execFileSync('bash',['-c',…]))
|
||||
# as test DATA the rule must lint — not attack vectors. ADR-1703 Phase 3 (#1720).
|
||||
'tests/no-unguarded-nonportable-exec.rule.test.cjs'
|
||||
# RuleTester fixtures for the local/no-bare-npm-exec ESLint rule contain npm
|
||||
# exec command strings (execFileSync('npm', ['install'])) as test DATA the rule
|
||||
# must lint — not attack vectors. ADR-1703 Phase 4 (#1726).
|
||||
'tests/no-bare-npm-exec.rule.test.cjs'
|
||||
)
|
||||
|
||||
is_allowlisted() {
|
||||
|
||||
@@ -83,8 +83,9 @@ const lockMod = require('./capability-lock.cjs') as {
|
||||
_setLockProbes: (probes: Partial<{ isPidAlive: (pid: number) => boolean; getProcessStartTime: (pid: number) => string | null }>) => void;
|
||||
_resetLockProbes: () => void;
|
||||
};
|
||||
const { platformWriteSync } = require('./shell-command-projection.cjs') as {
|
||||
const { platformWriteSync, retryRenameSync } = require('./shell-command-projection.cjs') as {
|
||||
platformWriteSync: (filePath: string, content: string) => void;
|
||||
retryRenameSync: (fromPath: string, toPath: string) => void;
|
||||
};
|
||||
// #1463: numeric major.minor.patch comparison for the outdated check (the SAME compare the resolver
|
||||
// and capability list use). -1 (a<b), 0 (equal), 1 (a>b).
|
||||
@@ -506,14 +507,14 @@ function promoteStagingToFinal(
|
||||
? path.join(parent, backupName)
|
||||
// CONC-3: a random nonce in the unnamed-branch backup name prevents same-ms cross-process collision.
|
||||
: path.join(parent, newBackupName(path.basename(finalDir)));
|
||||
fs.renameSync(finalDir, backupDir);
|
||||
retryRenameSync(finalDir, backupDir);
|
||||
// DUR-3: fsync the parent dir so the old→backup rename is durable BEFORE the second rename —
|
||||
// a crash here must not lose the backup (the only recovery path for reconcile).
|
||||
fsyncDir(parent);
|
||||
try {
|
||||
fs.renameSync(stagingDir, finalDir);
|
||||
retryRenameSync(stagingDir, finalDir);
|
||||
} catch (err) {
|
||||
try { fs.renameSync(backupDir, finalDir); } catch { /* best-effort restore */ }
|
||||
try { retryRenameSync(backupDir, finalDir); } catch { /* best-effort restore */ }
|
||||
throw err;
|
||||
}
|
||||
// DUR-3: fsync the parent dir again so the staging→final rename is durable too.
|
||||
@@ -521,7 +522,7 @@ function promoteStagingToFinal(
|
||||
return { backupDir };
|
||||
}
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
fs.renameSync(stagingDir, finalDir);
|
||||
retryRenameSync(stagingDir, finalDir);
|
||||
fsyncDir(parent); // DUR-3: durable fresh-install promotion.
|
||||
return { backupDir: null };
|
||||
}
|
||||
@@ -1535,8 +1536,8 @@ function reconcileCapabilities(opts: { runtimeDir: string; scope?: 'global' | 'p
|
||||
// - crash after step (a): backup still present + `_pending` still references it → retry.
|
||||
// - crash after step (b): old bundle live at finalDir; only the aside copy leaks → swept.
|
||||
const discard = `${finalDir}.discard-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
if (fs.existsSync(finalDir)) fs.renameSync(finalDir, discard); // (a) set the new dir aside
|
||||
fs.renameSync(backupDir, finalDir); // (b) restore the old bundle
|
||||
if (fs.existsSync(finalDir)) retryRenameSync(finalDir, discard); // (a) set the new dir aside
|
||||
retryRenameSync(backupDir, finalDir); // (b) restore the old bundle
|
||||
fsyncDir(root); // make the restore durable
|
||||
try { fs.rmSync(discard, { recursive: true, force: true }); } catch { /* swept later */ }
|
||||
restored = true;
|
||||
|
||||
@@ -42,12 +42,13 @@ import crypto from 'node:crypto';
|
||||
const ledgerMod = require('./capability-ledger.cjs') as {
|
||||
readSmallRegularFile: (filePath: string, maxBytes: number) => string | null;
|
||||
};
|
||||
const { execTool } = require('./shell-command-projection.cjs') as {
|
||||
const { execTool, retryRenameSync } = require('./shell-command-projection.cjs') as {
|
||||
execTool: (
|
||||
program: string,
|
||||
args: string[],
|
||||
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number },
|
||||
) => { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null; error: Error | null };
|
||||
retryRenameSync: (fromPath: string, toPath: string) => void;
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
@@ -494,7 +495,7 @@ function acquireLock(lockPath: string, opts?: { maxAttempts?: number; waitForFre
|
||||
|
||||
// Steal atomically (only one racer can rename the inode).
|
||||
const stolen = `${lockPath}.stale-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
try { fs.renameSync(lockPath, stolen); } catch { return null; } // another process won the steal
|
||||
try { retryRenameSync(lockPath, stolen); } catch { return null; } // another process won the steal
|
||||
try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ }
|
||||
if (attempt + 1 < maxAttempts) lockBackoff();
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const shellSeam = require('./shell-command-projection.cjs') as {
|
||||
execGit: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult;
|
||||
execNpm: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult;
|
||||
execTool: (program: string, args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult;
|
||||
retryRenameSync: (fromPath: string, toPath: string) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -752,16 +753,16 @@ function stageValidated(opts: {
|
||||
// lives in capability-lifecycle.cjs and uses promote:false above.)
|
||||
if (fs.existsSync(finalDir)) {
|
||||
const backupDir = `${finalDir}.old-${process.pid}-${Date.now()}`;
|
||||
fs.renameSync(finalDir, backupDir);
|
||||
shellSeam.retryRenameSync(finalDir, backupDir);
|
||||
try {
|
||||
fs.renameSync(stagingDir, finalDir);
|
||||
shellSeam.retryRenameSync(stagingDir, finalDir);
|
||||
} catch (err) {
|
||||
try { fs.renameSync(backupDir, finalDir); } catch { /* best-effort restore */ }
|
||||
try { shellSeam.retryRenameSync(backupDir, finalDir); } catch { /* best-effort restore */ }
|
||||
throw err;
|
||||
}
|
||||
try { fs.rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
} else {
|
||||
fs.renameSync(stagingDir, finalDir);
|
||||
shellSeam.retryRenameSync(stagingDir, finalDir);
|
||||
}
|
||||
|
||||
const version = typeof cap['version'] === 'string' ? cap['version'] : '';
|
||||
|
||||
47
src/cli-skew-check.cts
Normal file
47
src/cli-skew-check.cts
Normal file
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cli-skew-check.cts — CLI version-skew detection (#1754).
|
||||
*
|
||||
* Pure function: compares the resolved gsd-tools.cjs path to the project root.
|
||||
* If the resolved CLI is OUTSIDE the project root while a project-local install
|
||||
* EXISTS, returns a warning string (the caller writes it to stderr). Non-blocking.
|
||||
*
|
||||
* Catches the shadowing scenario from #1748: a stale global canary CLI (e.g.
|
||||
* from the retired @gsd-build/sdk) shadowing the project-local GSD install.
|
||||
*
|
||||
* The function is PURE (no I/O) — the caller provides the resolved path, the
|
||||
* project root, and whether a project-local install exists. This makes it
|
||||
* trivially testable without filesystem setup.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Check for CLI version skew.
|
||||
*
|
||||
* @param opts.resolvedPath - The absolute path of the running gsd-tools.cjs (__filename).
|
||||
* @param opts.projectRoot - The project root (from findProjectRoot), or null if no project.
|
||||
* @param opts.projectLocalExists - Whether a project-local gsd-tools.cjs exists.
|
||||
* @returns A warning string if skew is detected, or null if no skew.
|
||||
*/
|
||||
export function checkCliSkew(opts: {
|
||||
resolvedPath: string;
|
||||
projectRoot: string | null;
|
||||
projectLocalExists: boolean;
|
||||
}): string | null {
|
||||
const { resolvedPath, projectRoot, projectLocalExists } = opts;
|
||||
|
||||
// No project context or no project-local install → no skew possible.
|
||||
if (!projectRoot || !projectLocalExists) return null;
|
||||
|
||||
// If the resolved CLI is under the project root, it IS a project-local install.
|
||||
const rel = path.relative(projectRoot, resolvedPath);
|
||||
if (!rel.startsWith('..')) return null;
|
||||
|
||||
// Resolved CLI is outside project root while a project-local install exists → SKEW.
|
||||
const hint = resolvedPath.includes('@gsd-build')
|
||||
? ' If @gsd-build/sdk: npm uninstall -g @gsd-build/sdk'
|
||||
: '';
|
||||
return `⚠ GSD: ${resolvedPath} may shadow project-local GSD.${hint}`;
|
||||
}
|
||||
530
src/host-integration.cts
Normal file
530
src/host-integration.cts
Normal file
@@ -0,0 +1,530 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Host Integration module — ADR-1239 Phase A.
|
||||
*
|
||||
* Pure, additive, no-I/O module providing a closed vocabulary for host
|
||||
* integration axes, degradation ladder, profile classification, and
|
||||
* capability negotiation.
|
||||
*
|
||||
* The SINGLE source of truth for integration axes and degradation levels.
|
||||
* All functions are pure (no side effects, no I/O).
|
||||
*
|
||||
* Per-CLI sourced axis VALUES (with citations) live in docs/reference/host-integration-capability-matrix.md — every value is documented or explicitly 'undocumented'.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROTOCOL_VERSION = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Undocumented sentinel — fail-closed when a host omits CLI docs for an axis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sentinel value used when a host descriptor's CLI docs do not state a value
|
||||
* for an axis. It VALIDATES (accepted by the validator) but NEVER propagates
|
||||
* into effective axes — it fails closed exactly like an unknown/missing value.
|
||||
*
|
||||
* Do NOT add to HOST_INTEGRATION_AXES (which is the documented vocabulary).
|
||||
*/
|
||||
const UNDOCUMENTED = 'undocumented';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Closed vocabulary — axes and interface points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HOST_INTEGRATION_AXES = Object.freeze({
|
||||
embeddingMode: Object.freeze(['imperative', 'declarative'] as const),
|
||||
commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only'] as const),
|
||||
modelMode: Object.freeze(['active', 'passive'] as const),
|
||||
hookBus: Object.freeze(['host', 'engine', 'none'] as const),
|
||||
stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append'] as const),
|
||||
transport: Object.freeze(['mcp', 'native-extension'] as const),
|
||||
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other'] as const),
|
||||
subagentToolkit: Object.freeze(['full', 'read-only'] as const),
|
||||
});
|
||||
|
||||
const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact'] as const);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EmbeddingMode = 'imperative' | 'declarative';
|
||||
type CommandSurface = 'slash-file' | 'slash-programmatic' | 'slash-toml' | 'palette' | 'prose-only';
|
||||
type ModelMode = 'active' | 'passive';
|
||||
type HookBus = 'host' | 'engine' | 'none';
|
||||
type StateIO = 'filesystem' | 'sandboxed-storage' | 'session-log-append';
|
||||
type Transport = 'mcp' | 'native-extension';
|
||||
type HostRuntime = 'node' | 'bun' | 'sandboxed-web' | 'python' | 'go' | 'rust' | 'electron' | 'other';
|
||||
type SubagentToolkit = 'full' | 'read-only';
|
||||
type DegradationLevel = 'full' | 'degraded' | 'absent';
|
||||
type InterfacePoint = 'command' | 'dispatch' | 'model' | 'hooks' | 'state' | 'artifact';
|
||||
|
||||
interface DispatchCapability {
|
||||
namedDispatch: boolean;
|
||||
nested: boolean;
|
||||
maxDepth: number;
|
||||
background: boolean;
|
||||
subagentToolkit: SubagentToolkit;
|
||||
backgroundDispatch: boolean;
|
||||
}
|
||||
|
||||
interface HostIntegrationAxes {
|
||||
embeddingMode: EmbeddingMode;
|
||||
commandSurface: CommandSurface;
|
||||
dispatch: DispatchCapability;
|
||||
modelMode: ModelMode;
|
||||
hookBus: HookBus;
|
||||
stateIO: StateIO;
|
||||
transport: Transport;
|
||||
runtime: HostRuntime;
|
||||
}
|
||||
|
||||
interface DegradationResult {
|
||||
level: DegradationLevel;
|
||||
fallback: string;
|
||||
unknown?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile baselines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fail-closed floor: the most restrictive known value per axis, injected when a host omits an axis (degrade-closed, never assume capability).
|
||||
const SAFE_DEFAULTS: HostIntegrationAxes = {
|
||||
embeddingMode: 'declarative',
|
||||
commandSurface: 'prose-only',
|
||||
dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'read-only', backgroundDispatch: false },
|
||||
modelMode: 'passive',
|
||||
hookBus: 'none',
|
||||
stateIO: 'session-log-append',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
};
|
||||
|
||||
const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli' | 'ide', HostIntegrationAxes>> =
|
||||
Object.freeze({
|
||||
'programmatic-cli': Object.freeze({
|
||||
embeddingMode: 'imperative',
|
||||
commandSurface: 'slash-file',
|
||||
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true }),
|
||||
modelMode: 'passive',
|
||||
hookBus: 'host',
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
} as HostIntegrationAxes),
|
||||
'declarative-cli': Object.freeze({
|
||||
embeddingMode: 'declarative',
|
||||
commandSurface: 'slash-file',
|
||||
dispatch: Object.freeze({ namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full', backgroundDispatch: false }),
|
||||
modelMode: 'passive',
|
||||
hookBus: 'host',
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
} as HostIntegrationAxes),
|
||||
'ide': Object.freeze({
|
||||
embeddingMode: 'imperative',
|
||||
commandSurface: 'palette',
|
||||
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full', backgroundDispatch: true }),
|
||||
modelMode: 'active',
|
||||
hookBus: 'engine',
|
||||
stateIO: 'sandboxed-storage',
|
||||
transport: 'mcp',
|
||||
runtime: 'sandboxed-web',
|
||||
} as HostIntegrationAxes),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// degradationFor — plain data-table lookup (NOT clever code)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Look up the degradation level for a given interface point and partial axes.
|
||||
*
|
||||
* NEVER throws. Returns { level:'absent', fallback:'...', unknown:true } for
|
||||
* any missing or unrecognised axis value.
|
||||
*/
|
||||
function degradationFor(point: InterfacePoint, axes: Partial<HostIntegrationAxes>): DegradationResult {
|
||||
const UNKNOWN: DegradationResult = {
|
||||
level: 'absent',
|
||||
fallback: 'unknown capability — degraded closed',
|
||||
unknown: true,
|
||||
};
|
||||
|
||||
switch (point) {
|
||||
case 'command': {
|
||||
const cs = (axes as Record<string, unknown>).commandSurface;
|
||||
if (cs === 'slash-file' || cs === 'slash-programmatic') return { level: 'full', fallback: '' };
|
||||
if (cs === 'slash-toml' || cs === 'palette') return { level: 'degraded', fallback: 'toml/palette surface — limited command routing' };
|
||||
if (cs === 'prose-only') return { level: 'absent', fallback: 'AGENTS.md prose + skills menu' };
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
case 'dispatch': {
|
||||
const d = (axes as Record<string, unknown>).dispatch;
|
||||
if (!d || typeof d !== 'object') return UNKNOWN;
|
||||
const disp = d as Record<string, unknown>;
|
||||
if (disp.namedDispatch !== true || disp.maxDepth === 0) {
|
||||
return { level: 'absent', fallback: 'single-agent inline / SDK sub-session' };
|
||||
}
|
||||
// maxDepth < 0 means unbounded
|
||||
const isUnbounded = typeof disp.maxDepth === 'number' && Number.isFinite(disp.maxDepth) && disp.maxDepth < 0;
|
||||
const depth = (typeof disp.maxDepth === 'number' && Number.isFinite(disp.maxDepth)) ? disp.maxDepth : 0;
|
||||
const isFullDepth = isUnbounded || (disp.nested === true && depth >= 2);
|
||||
if (isFullDepth) {
|
||||
// Fail-closed: return 'full' ONLY when subagentToolkit is explicitly 'full';
|
||||
// any other value (read-only, undocumented, unknown, missing) → degraded.
|
||||
if (disp.subagentToolkit === 'full') {
|
||||
return { level: 'full', fallback: '' };
|
||||
}
|
||||
return { level: 'degraded', fallback: 'restricted/undocumented subagent toolkit — limited dispatch surface' };
|
||||
}
|
||||
// flat (maxDepth===1)
|
||||
return { level: 'degraded', fallback: 'flat dispatch — waves run inline' };
|
||||
}
|
||||
|
||||
case 'model': {
|
||||
const mm = (axes as Record<string, unknown>).modelMode;
|
||||
if (mm === 'active') return { level: 'full', fallback: '' };
|
||||
if (mm === 'passive') return { level: 'degraded', fallback: 'instruction-injection / per-agent model field' };
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
case 'hooks': {
|
||||
const hb = (axes as Record<string, unknown>).hookBus;
|
||||
if (hb === 'host') return { level: 'full', fallback: '' };
|
||||
if (hb === 'engine') return { level: 'degraded', fallback: 'engine-owned bus' };
|
||||
if (hb === 'none') return { level: 'absent', fallback: 'rule-text instructions' };
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
case 'state': {
|
||||
const si = (axes as Record<string, unknown>).stateIO;
|
||||
if (si === 'filesystem') return { level: 'full', fallback: '' };
|
||||
if (si === 'sandboxed-storage') return { level: 'degraded', fallback: 'sandboxed storage' };
|
||||
if (si === 'session-log-append') return { level: 'degraded', fallback: 'append-only session log' };
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
case 'artifact': {
|
||||
const cs = (axes as Record<string, unknown>).commandSurface;
|
||||
if (cs === 'slash-file' || cs === 'slash-programmatic') return { level: 'full', fallback: '' };
|
||||
if (cs === 'slash-toml' || cs === 'prose-only') return { level: 'degraded', fallback: 'menu / @-only' };
|
||||
if (cs === 'palette') return { level: 'absent', fallback: 'palette + chat participant; skills become LM tools' };
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
default:
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// profileOf
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Classify a partial set of integration axes into a named profile.
|
||||
* Returns null when no profile can be determined.
|
||||
*/
|
||||
function profileOf(axes: Partial<HostIntegrationAxes>): 'programmatic-cli' | 'declarative-cli' | 'ide' | null {
|
||||
const a = axes as Record<string, unknown>;
|
||||
if (a.embeddingMode === 'imperative' && a.runtime === 'sandboxed-web') return 'ide';
|
||||
if (a.embeddingMode === 'imperative') return 'programmatic-cli';
|
||||
if (a.embeddingMode === 'declarative') return 'declarative-cli';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EngineCapabilities + DEFAULT_ENGINE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EngineCapabilities {
|
||||
protocolVersion: number;
|
||||
axes: HostIntegrationAxes;
|
||||
known: typeof HOST_INTEGRATION_AXES;
|
||||
}
|
||||
|
||||
const DEFAULT_ENGINE: EngineCapabilities = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
axes: {
|
||||
embeddingMode: 'imperative',
|
||||
commandSurface: 'slash-file',
|
||||
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true },
|
||||
modelMode: 'active',
|
||||
hookBus: 'host',
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
},
|
||||
known: HOST_INTEGRATION_AXES,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NegotiationResult
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NegotiationResult {
|
||||
protocolVersion: number;
|
||||
effective: HostIntegrationAxes;
|
||||
points: Record<InterfacePoint, { hostLevel: DegradationLevel; effectiveLevel: DegradationLevel; fallback: string }>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// negotiateHostCapabilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Negotiate host integration capabilities against an engine.
|
||||
*
|
||||
* POST-CONDITION: every effective scalar axis value is in engine.known[axis].
|
||||
* effective never contains a value the host didn't declare AND the engine
|
||||
* cannot drive.
|
||||
*
|
||||
* NEVER throws. Returns a fresh object each call (mutation-safe).
|
||||
*/
|
||||
function negotiateHostCapabilities(
|
||||
host: Partial<HostIntegrationAxes> & { protocolVersion?: number },
|
||||
engine: EngineCapabilities = DEFAULT_ENGINE,
|
||||
): NegotiationResult {
|
||||
const warnings: string[] = [];
|
||||
const h = host as Record<string, unknown>;
|
||||
// Warn if protocolVersion is present but not a finite number
|
||||
if (h.protocolVersion !== undefined && (typeof h.protocolVersion !== 'number' || !Number.isFinite(h.protocolVersion))) {
|
||||
warnings.push(`host protocolVersion is not a finite number — using engine version ${engine.protocolVersion}`);
|
||||
}
|
||||
const hostPV = (typeof h.protocolVersion === 'number' && Number.isFinite(h.protocolVersion)) ? h.protocolVersion : engine.protocolVersion;
|
||||
const enginePV = engine.protocolVersion;
|
||||
|
||||
// Warn if host declares a newer protocol version
|
||||
if (hostPV > enginePV) {
|
||||
warnings.push(
|
||||
`host protocolVersion ${hostPV} newer than engine ${enginePV} — capabilities beyond version ${enginePV} not trusted`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: negotiate a single scalar axis
|
||||
// ---------------------------------------------------------------------------
|
||||
function negotiateScalar<K extends keyof typeof HOST_INTEGRATION_AXES>(
|
||||
axis: K,
|
||||
): (typeof HOST_INTEGRATION_AXES)[K][number] {
|
||||
type V = (typeof HOST_INTEGRATION_AXES)[K][number];
|
||||
const knownValues: ReadonlyArray<V> = engine.known[axis];
|
||||
const hostVal = h[axis] as V | undefined;
|
||||
const engineVal = engine.axes[axis as keyof HostIntegrationAxes] as V;
|
||||
const safeDefault = SAFE_DEFAULTS[axis as keyof HostIntegrationAxes] as V;
|
||||
|
||||
if (hostVal === undefined || hostVal === null) {
|
||||
// Host did not declare this axis
|
||||
warnings.push(`host did not declare '${axis}'`);
|
||||
return safeDefault;
|
||||
}
|
||||
if ((hostVal as unknown) === UNDOCUMENTED) {
|
||||
// Host declared the undocumented sentinel — treat as fail-closed (degrade to safe default)
|
||||
warnings.push(`host axis '${axis}' is undocumented — degraded closed`);
|
||||
return safeDefault;
|
||||
}
|
||||
if (!knownValues.includes(hostVal)) {
|
||||
// Host declared an unknown/future value — NEVER copy into effective
|
||||
warnings.push(
|
||||
`host declared unknown '${axis}' value '${String(hostVal)}' — not trusted (host protocolVersion ${hostPV} vs engine ${enginePV})`,
|
||||
);
|
||||
return safeDefault;
|
||||
}
|
||||
// Engine capability cap: if the engine can't drive the host's value,
|
||||
// use the engine's lesser capability.
|
||||
// For modelMode: 'active' > 'passive' — if host wants active but engine
|
||||
// is passive, cap to passive.
|
||||
if (axis === 'modelMode') {
|
||||
if (hostVal === 'active' && engineVal === 'passive') return 'passive';
|
||||
}
|
||||
return hostVal;
|
||||
}
|
||||
|
||||
// Negotiate all scalar axes
|
||||
const effectiveEmbeddingMode = negotiateScalar('embeddingMode');
|
||||
const effectiveCommandSurface = negotiateScalar('commandSurface');
|
||||
const effectiveModelMode = negotiateScalar('modelMode');
|
||||
const effectiveHookBus = negotiateScalar('hookBus');
|
||||
const effectiveStateIO = negotiateScalar('stateIO');
|
||||
const effectiveTransport = negotiateScalar('transport');
|
||||
const effectiveRuntime = negotiateScalar('runtime');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch struct negotiation
|
||||
// ---------------------------------------------------------------------------
|
||||
const hostDispatch = (typeof h.dispatch === 'object' && h.dispatch !== null)
|
||||
? h.dispatch as Record<string, unknown>
|
||||
: null;
|
||||
const engineDispatch = engine.axes.dispatch;
|
||||
|
||||
let effectiveNamedDispatch: boolean;
|
||||
let effectiveNested: boolean;
|
||||
let effectiveBackground: boolean;
|
||||
let effectiveBackgroundDispatch: boolean;
|
||||
let effectiveSubagentToolkit: SubagentToolkit;
|
||||
let effectiveMaxDepth: number;
|
||||
|
||||
if (hostDispatch === null) {
|
||||
// Host didn't declare dispatch at all — fail-closed to most-restrictive values
|
||||
warnings.push(`host did not declare 'dispatch'`);
|
||||
effectiveNamedDispatch = false;
|
||||
effectiveNested = false;
|
||||
effectiveBackground = false;
|
||||
effectiveBackgroundDispatch = false;
|
||||
effectiveSubagentToolkit = 'read-only';
|
||||
effectiveMaxDepth = 0;
|
||||
} else {
|
||||
// N1: observability warnings for 'undocumented' sentinel on dispatch fields
|
||||
if (hostDispatch.namedDispatch === 'undocumented') {
|
||||
warnings.push(`dispatch.namedDispatch is undocumented — degraded closed`);
|
||||
}
|
||||
if (hostDispatch.nested === 'undocumented') {
|
||||
warnings.push(`dispatch.nested is undocumented — degraded closed`);
|
||||
}
|
||||
if (hostDispatch.background === 'undocumented') {
|
||||
warnings.push(`dispatch.background is undocumented — degraded closed`);
|
||||
}
|
||||
if (hostDispatch.subagentToolkit === 'undocumented') {
|
||||
warnings.push(`dispatch.subagentToolkit is undocumented — degraded closed (read-only)`);
|
||||
}
|
||||
if (hostDispatch.backgroundDispatch === 'undocumented') {
|
||||
warnings.push(`dispatch.backgroundDispatch is undocumented — degraded closed`);
|
||||
}
|
||||
|
||||
effectiveNamedDispatch = (hostDispatch.namedDispatch === true) && engineDispatch.namedDispatch;
|
||||
effectiveNested = (hostDispatch.nested === true) && engineDispatch.nested;
|
||||
effectiveBackground = (hostDispatch.background === true) && engineDispatch.background;
|
||||
effectiveBackgroundDispatch = (hostDispatch.backgroundDispatch === true) && engineDispatch.backgroundDispatch;
|
||||
|
||||
// subagentToolkit: fail closed to read-only unless explicitly 'full'
|
||||
// (an 'undocumented' or 'read-only' value → read-only)
|
||||
const hostToolkit = hostDispatch.subagentToolkit === 'full' ? 'full' : 'read-only';
|
||||
const engineToolkit = engineDispatch.subagentToolkit === 'read-only' ? 'read-only' : 'full';
|
||||
effectiveSubagentToolkit = (hostToolkit === 'read-only' || engineToolkit === 'read-only') ? 'read-only' : 'full';
|
||||
|
||||
// maxDepth: missing/non-number/non-finite → 0 + warning
|
||||
let hostMaxDepth: number;
|
||||
if (typeof hostDispatch.maxDepth !== 'number' || !Number.isFinite(hostDispatch.maxDepth)) {
|
||||
warnings.push(`host dispatch.maxDepth is missing or not a number — treating as 0`);
|
||||
hostMaxDepth = 0;
|
||||
} else {
|
||||
hostMaxDepth = hostDispatch.maxDepth;
|
||||
}
|
||||
|
||||
// Treat negative as +Infinity for the min, then if result is +Infinity emit -1
|
||||
const hDepthNum = hostMaxDepth < 0 ? Infinity : hostMaxDepth;
|
||||
const eDepthNum = engineDispatch.maxDepth < 0 ? Infinity : engineDispatch.maxDepth;
|
||||
const minDepth = Math.min(hDepthNum, eDepthNum);
|
||||
effectiveMaxDepth = minDepth === Infinity ? -1 : minDepth;
|
||||
|
||||
// If namedDispatch is false, cap maxDepth/nested/background/backgroundDispatch to 0/false/false/false (struct consistency)
|
||||
if (!effectiveNamedDispatch) {
|
||||
effectiveMaxDepth = 0;
|
||||
effectiveNested = false;
|
||||
effectiveBackground = false;
|
||||
effectiveBackgroundDispatch = false;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveDispatch: DispatchCapability = {
|
||||
namedDispatch: effectiveNamedDispatch,
|
||||
nested: effectiveNested,
|
||||
maxDepth: effectiveMaxDepth,
|
||||
background: effectiveBackground,
|
||||
subagentToolkit: effectiveSubagentToolkit,
|
||||
backgroundDispatch: effectiveBackgroundDispatch,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assemble effective axes
|
||||
// ---------------------------------------------------------------------------
|
||||
const effective: HostIntegrationAxes = {
|
||||
embeddingMode: effectiveEmbeddingMode,
|
||||
commandSurface: effectiveCommandSurface,
|
||||
dispatch: effectiveDispatch,
|
||||
modelMode: effectiveModelMode,
|
||||
hookBus: effectiveHookBus,
|
||||
stateIO: effectiveStateIO,
|
||||
transport: effectiveTransport,
|
||||
runtime: effectiveRuntime,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compute points (fresh objects — mutation-safe)
|
||||
// ---------------------------------------------------------------------------
|
||||
const points = {} as Record<InterfacePoint, { hostLevel: DegradationLevel; effectiveLevel: DegradationLevel; fallback: string }>;
|
||||
for (const point of INTERFACE_POINTS) {
|
||||
const hostDeg = degradationFor(point, host);
|
||||
const effectiveDeg = degradationFor(point, effective);
|
||||
points[point] = {
|
||||
hostLevel: hostDeg.level,
|
||||
effectiveLevel: effectiveDeg.level,
|
||||
fallback: effectiveDeg.fallback,
|
||||
};
|
||||
}
|
||||
|
||||
// protocolVersion: min of host and engine
|
||||
const resultProtocolVersion = Math.min(hostPV, enginePV);
|
||||
|
||||
return {
|
||||
protocolVersion: resultProtocolVersion,
|
||||
effective,
|
||||
points,
|
||||
warnings: [...warnings], // fresh copy
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldFlattenDispatch — ADR-1239 Phase B / #1708
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true when the orchestrator MUST run inline (flatten); false when it
|
||||
* may be backgrounded.
|
||||
*
|
||||
* A host may background only if it can reliably background a nesting-capable
|
||||
* orchestrator — i.e. both `background` AND `backgroundDispatch` are
|
||||
* explicitly `true`. Any other value (false, missing, 'undocumented') fails
|
||||
* closed to inline (the always-safe path).
|
||||
*
|
||||
* This graduates the #853 prose rule (originally `RUNTIME === 'codex'`, then
|
||||
* extended to cursor) to a typed, documentation-sourced decision; codex AND
|
||||
* cursor are both background-eligible in the registry. See
|
||||
* docs/reference/host-integration-capability-matrix.md.
|
||||
*
|
||||
* Null-safety: if dispatch is null, undefined, or not an object, returns true
|
||||
* (inline, fail-closed) instead of throwing.
|
||||
*/
|
||||
type UnvalidatedDispatch = (Partial<DispatchCapability> & { background?: unknown; backgroundDispatch?: unknown }) | null | undefined;
|
||||
|
||||
function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean {
|
||||
if (!dispatch || typeof dispatch !== 'object') return true;
|
||||
const canBackground = dispatch.background === true && dispatch.backgroundDispatch === true;
|
||||
return !canBackground;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module export (CommonJS — matches existing src/*.cts pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export = {
|
||||
PROTOCOL_VERSION,
|
||||
UNDOCUMENTED,
|
||||
HOST_INTEGRATION_AXES,
|
||||
INTERFACE_POINTS,
|
||||
PROFILE_BASELINES,
|
||||
DEFAULT_ENGINE,
|
||||
degradationFor,
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
shouldFlattenDispatch,
|
||||
};
|
||||
@@ -2183,7 +2183,7 @@ function buildAgentSkillsBlock(
|
||||
if (entry.kind === 'directive') {
|
||||
return `- Load the \`${entry.name}\` skill via the Skill tool before proceeding (plugin-provided).`;
|
||||
}
|
||||
return `- @${entry.ref}`;
|
||||
return `- @${String(entry.ref).replace(/\\/g, '/')}`;
|
||||
}).join('\n');
|
||||
return `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
|
||||
}
|
||||
|
||||
822
src/install-engine.cts
Normal file
822
src/install-engine.cts
Normal file
@@ -0,0 +1,822 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,
|
||||
@typescript-eslint/no-unsafe-assignment,
|
||||
@typescript-eslint/no-unsafe-member-access,
|
||||
@typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-call,
|
||||
@typescript-eslint/no-unsafe-argument,
|
||||
@typescript-eslint/no-require-imports */
|
||||
// Mechanical extraction from bin/install.js; keep behavior parity before typing.
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Install Engine Module — ADR-1239 Phase B.
|
||||
*
|
||||
* Runtime-artifact install/uninstall cluster extracted from bin/install.js.
|
||||
* bin/install.js imports this module for the layout-driven install/uninstall
|
||||
* orchestrators and their private helpers. getCommitAttribution STAYS in
|
||||
* bin/install.js (impure install-time config I/O); it is injected via the
|
||||
* `resolveAttribution` parameter at each call site.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
|
||||
import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
|
||||
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
|
||||
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
|
||||
|
||||
const { processAttribution } = runtimeArtifactConversion;
|
||||
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
|
||||
// test stubs that monkeypatch the module's exports are seen at call time.
|
||||
const { getDirName } = runtimeNamePolicy;
|
||||
// assertDestWithinConfigHome: must be accessed via module ref at call time for
|
||||
// test-stub compatibility (monkeypatching the module property works; a local
|
||||
// const binding from destructure would capture the pre-stub value).
|
||||
// These are only called from functions that are not stubbed, but we use the
|
||||
// module ref pattern consistently for correctness.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (loose — minimal annotations for strict mode compliance)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ResolveAttribution = (runtime: string) => any;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// USER_OWNED_ARTIFACTS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Single source of truth for user-owned artifacts inside gsd-core/.
|
||||
*
|
||||
* These files are created/refreshed by user-facing workflows (e.g.
|
||||
* /gsd-profile-user) and must be preserved across reinstalls. Critically, they
|
||||
* MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches()
|
||||
* will compare a refreshed file against a stale manifest hash and emit a
|
||||
* spurious "locally modified GSD file" warning (bug #2771).
|
||||
*
|
||||
* Invariant: a file is either distribution (manifest-tracked, diff'd against
|
||||
* manifest) or user artifact (preserved across installs, never diff'd). Never
|
||||
* both. Both preserveUserArtifacts call sites and writeManifest must agree on
|
||||
* this list, which is why it lives here as a single constant.
|
||||
*
|
||||
* Paths are relative to the gsd-core/ directory.
|
||||
*/
|
||||
const USER_OWNED_ARTIFACTS: string[] = ['USER-PROFILE.md'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply per-runtime path-prefix rewrites for OpenCode-family skill bodies.
|
||||
* Replaces ~/.claude/, $HOME/.claude/, ./.claude/ and OpenCode-variant paths
|
||||
* with the computed pathPrefix for the install.
|
||||
*/
|
||||
function applyOpencodeFamilyPathPrefix(content: string, runtime: string, pathPrefix: string): string {
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
|
||||
content = content.replace(/~\/\.opencode\//g, pathPrefix);
|
||||
content = content.replace(/~\/\.kilo\//g, pathPrefix);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
|
||||
* The canonical OpenCode-family writer lives in runtime-artifact-conversion.cjs
|
||||
* (single source of truth — avoids a duplicate writer drifting per
|
||||
* DEFECT.GENERATIVE-FIX); this thin wrapper delegates to it.
|
||||
*/
|
||||
function convertClaudeCommandToOpencodeSkill(content: string, skillName: string): string {
|
||||
return (runtimeArtifactConversion as any).convertClaudeCommandToOpencodeSkill(content, skillName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Claude command (.md) to a Kilo skill (SKILL.md).
|
||||
* Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
|
||||
*/
|
||||
function convertClaudeCommandToKiloSkill(content: string, skillName: string): string {
|
||||
return (runtimeArtifactConversion as any).convertClaudeCommandToKiloSkill(content, skillName);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User-artifact preservation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save user-generated files from destDir to an in-memory map before a wipe.
|
||||
*
|
||||
* @param destDir - Directory that is about to be wiped
|
||||
* @param fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
|
||||
* @returns Map of fileName → file content (only entries that existed)
|
||||
*/
|
||||
function preserveUserArtifacts(destDir: string, fileNames: string[]): Map<string, string> {
|
||||
const saved = new Map<string, string>();
|
||||
for (const name of fileNames) {
|
||||
const fullPath = path.join(destDir, name);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
saved.set(name, fs.readFileSync(fullPath, 'utf8'));
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore user-generated files saved by preserveUserArtifacts after a wipe.
|
||||
*
|
||||
* @param destDir - Directory that was wiped and recreated
|
||||
* @param saved - Map returned by preserveUserArtifacts
|
||||
*/
|
||||
function restoreUserArtifacts(destDir: string, saved: Map<string, string>): void {
|
||||
for (const [name, content] of saved) {
|
||||
const fullPath = path.join(destDir, name);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
} catch { /* skip unwritable paths */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symlink-escape guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if any path component between `root` and `fullPath` is a
|
||||
* symbolic link (which could redirect writes outside the install root).
|
||||
*/
|
||||
function hasExistingSymlinkBetween(root: string, fullPath: string): boolean {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolvedFullPath = path.resolve(fullPath);
|
||||
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let cursor = resolvedRoot;
|
||||
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const relative = path.relative(resolvedRoot, resolvedFullPath);
|
||||
for (const segment of relative.split(path.sep)) {
|
||||
if (!segment) continue;
|
||||
cursor = path.join(cursor, segment);
|
||||
if (!fs.existsSync(cursor)) return false;
|
||||
if (fs.lstatSync(cursor).isSymbolicLink()) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// migrateLegacyDevPreferencesToSkill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the
|
||||
* runtime-aware SKILL.md location used by the writer after #2973.
|
||||
*
|
||||
* For runtimes with a nested skills layout (e.g. Hermes: skills/gsd/<stem>/),
|
||||
* the target is <configDir>/skills/gsd/dev-preferences/SKILL.md.
|
||||
* For runtimes with a flat skills layout (prefix='gsd-'), the target is
|
||||
* <configDir>/skills/gsd-dev-preferences/SKILL.md.
|
||||
*
|
||||
* Skips silently if no legacy file was preserved, or if a SKILL.md already
|
||||
* exists at the new location (don't clobber user-customized skill content
|
||||
* — they may have edited the new file directly). Returns true on actual
|
||||
* migration so callers can log a one-line confirmation.
|
||||
*
|
||||
* @param targetDir - Resolved runtime config directory (e.g. ~/.claude)
|
||||
* @param saved - Map returned by preserveUserArtifacts
|
||||
* @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
|
||||
* @param scope - install scope
|
||||
* @returns true if a file was migrated, false otherwise
|
||||
*/
|
||||
function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string, string>, runtime?: string, scope: string = 'global'): boolean {
|
||||
if (!saved || !saved.has('dev-preferences.md')) return false;
|
||||
let skillDir: string;
|
||||
if (runtime) {
|
||||
const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope as any);
|
||||
const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills');
|
||||
if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local)
|
||||
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
|
||||
skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName);
|
||||
} else {
|
||||
// Legacy fallback for callers that have not yet been updated to pass runtime
|
||||
skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
|
||||
}
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
if (fs.existsSync(skillFile)) return false;
|
||||
// Symlink-escape guard: reject if any path component between targetDir and
|
||||
// skillDir is a symlink that would redirect writes outside the config root.
|
||||
if (hasExistingSymlinkBetween(path.resolve(targetDir), skillDir)) {
|
||||
throw new Error(
|
||||
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(skillFile, saved.get('dev-preferences.md')!, 'utf8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _copyStaged
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Copy a staged directory's contents into destDir.
|
||||
* Additive — does not prune (surface.cjs handles pruning).
|
||||
*
|
||||
* For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy
|
||||
* the whole dir into destDir.
|
||||
* For commands/agents kind: iterate .md files and write them into destDir.
|
||||
* - commands: write as `${prefix}${stem}.md` unless destSubpath already
|
||||
* encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in
|
||||
* which case write as `${stem}.md` (directory IS the namespace).
|
||||
* - agents: write as-is (files already carry their own `gsd-` prefix).
|
||||
* For kimi-agents kind: recursively copy generated YAML/prompt files.
|
||||
*/
|
||||
function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: string): void {
|
||||
// Defense-in-depth: verify destDir is within the install root even if the
|
||||
// upstream assertDestWithinConfigHome check was somehow bypassed. This guards
|
||||
// the actual write site against any future call-site drift.
|
||||
// Fail-closed: every _copyStaged write must declare its install root so the gate
|
||||
// can confine it. All callers pass configDir; an omitted root is a bug, not a copy.
|
||||
if (configDir === undefined) {
|
||||
throw new Error(
|
||||
'_copyStaged: configDir (install root) is required to confine writes — refusing to write',
|
||||
);
|
||||
}
|
||||
// Strict-subpath + NUL containment via the canonical gate (shared with the
|
||||
// layout-driven install plan); throws if destDir escapes the install root.
|
||||
// destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir.
|
||||
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, destDir);
|
||||
// Symlink-escape guard: reject if any path component between configDir and
|
||||
// destDir is a symlink that would redirect writes outside configDir.
|
||||
if (hasExistingSymlinkBetween(path.resolve(configDir), resolvedDest)) {
|
||||
throw new Error(
|
||||
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`,
|
||||
);
|
||||
}
|
||||
// Use the validated absolute path for the actual writes below.
|
||||
destDir = resolvedDest;
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
if (kind.kind === 'skills') {
|
||||
// Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
|
||||
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const src = path.join(stagedDir, entry.name);
|
||||
const dest = path.join(destDir, entry.name);
|
||||
fs.cpSync(src, dest, { recursive: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind.kind === 'kimi-agents') {
|
||||
fs.cpSync(stagedDir, destDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// commands or agents
|
||||
const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
|
||||
// For commands: apply prefix unless the destSubpath's last segment already
|
||||
// represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
|
||||
const destLast = path.basename(kind.destSubpath);
|
||||
const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : '';
|
||||
const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3); // strip .md
|
||||
|
||||
let destName: string;
|
||||
if (kind.kind === 'agents') {
|
||||
// Agent files already carry the gsd- prefix in the source dir
|
||||
destName = entry.name;
|
||||
} else if (namespacedByDir) {
|
||||
// Directory is the namespace; don't double-prefix the filename
|
||||
destName = entry.name;
|
||||
} else {
|
||||
// Flat commands directory (e.g. command/ for opencode/kilo)
|
||||
destName = `${kind.prefix}${stem}.md`;
|
||||
}
|
||||
|
||||
fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _removeGsdEntries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Remove GSD-prefixed entries from destDir matching kind.prefix.
|
||||
* For the prefix='' case: the destSubpath IS the namespace — remove the entire
|
||||
* destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
|
||||
* as a defensive guard for future runtimes.)
|
||||
*/
|
||||
function _removeGsdEntries(destDir: string, kind: any): void {
|
||||
if (!fs.existsSync(destDir)) return;
|
||||
if (kind.kind === 'kimi-agents') {
|
||||
for (const fileName of ['gsd.yaml', 'gsd.md']) {
|
||||
fs.rmSync(path.join(destDir, fileName), { force: true });
|
||||
}
|
||||
const subagentsDir = path.join(destDir, 'subagents');
|
||||
if (fs.existsSync(subagentsDir)) {
|
||||
for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
|
||||
fs.rmSync(path.join(subagentsDir, entry.name), { force: true });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind.prefix === '') {
|
||||
// Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
|
||||
// The directory itself is the GSD namespace, so remove it entirely.
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
for (const entry of fs.readdirSync(destDir, { withFileTypes: true })) {
|
||||
if (!entry.name.startsWith(kind.prefix)) continue;
|
||||
fs.rmSync(path.join(destDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _snapshotDir / _restoreDir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deep-snapshot a directory tree into a Map<relPath, Buffer>.
|
||||
* Returns an empty Map if the directory doesn't exist.
|
||||
*/
|
||||
function _snapshotDir(dir: string): Map<string, Buffer> {
|
||||
const files = new Map<string, Buffer>();
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
const walk = (relPath: string, absPath: string) => {
|
||||
for (const e of fs.readdirSync(absPath, { withFileTypes: true })) {
|
||||
const childRel = relPath ? path.join(relPath, e.name) : e.name;
|
||||
const childAbs = path.join(absPath, e.name);
|
||||
if (e.isDirectory()) walk(childRel, childAbs);
|
||||
else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs));
|
||||
}
|
||||
};
|
||||
walk('', dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a directory tree from a Map<relPath, Buffer> produced by _snapshotDir.
|
||||
*/
|
||||
function _restoreDir(dir: string, snapshot: Map<string, Buffer>): void {
|
||||
for (const [relPath, buf] of snapshot) {
|
||||
const absPath = path.join(dir, relPath);
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, buf);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _removeHermesBareStemDirs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* After the layout-driven install loop writes new gsd-<stem>/ dirs to
|
||||
* skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd/<stem>/)
|
||||
* that correspond to the newly installed gsd-<stem> entries.
|
||||
*
|
||||
* @param nestedGsdDir absolute path to skills/gsd/ category dir
|
||||
*/
|
||||
function _removeHermesBareStemDirs(nestedGsdDir: string): void {
|
||||
if (!fs.existsSync(nestedGsdDir)) return;
|
||||
const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
|
||||
|
||||
// Collect the set of stems that were installed as gsd-<stem>/ this run.
|
||||
const installedStems = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
||||
installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences'
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
|
||||
fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy migration helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run legacy install migrations that must execute BEFORE the layout-driven
|
||||
* copy so stale artifacts are cleaned up before new ones are written.
|
||||
*
|
||||
* @param runtime
|
||||
* @param configDir resolved runtime config directory
|
||||
* @param scope
|
||||
*/
|
||||
function _runLegacyInstallMigrations(runtime: string, configDir: string, scope: string = 'global'): void {
|
||||
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
|
||||
|
||||
// Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences
|
||||
// for migration. The actual migration call is deferred to after all layout cleanup so
|
||||
// that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
|
||||
// created skills/gsd-dev-preferences/ skill dir.
|
||||
let savedLegacyArtifacts: Map<string, string> | null = null;
|
||||
if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') {
|
||||
if (fs.existsSync(legacyCommandsGsd)) {
|
||||
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
|
||||
fs.rmSync(legacyCommandsGsd, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside
|
||||
// the new skills/gsd/ nested layout.
|
||||
if (runtime === 'hermes') {
|
||||
const flatSkillsDir = path.join(configDir, 'skills');
|
||||
if (fs.existsSync(flatSkillsDir)) {
|
||||
for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
||||
fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes: bare-stem skills/gsd/<stem>/ cleanup is deferred to AFTER the
|
||||
// layout-driven install loop in installRuntimeArtifacts, where the exact set
|
||||
// of staged gsd-<stem>/ dirs is known. Removing here (before staging) would
|
||||
// require readGsdCommandNames() which misses skills like 'dev-preferences'
|
||||
// that are not in the commands directory. See _removeHermesBareStemDirs().
|
||||
}
|
||||
|
||||
// Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
|
||||
// Done after all layout cleanup so Hermes flat-dir removal does not delete the
|
||||
// newly created skill dir. No-op if skill file already exists.
|
||||
if (savedLegacyArtifacts) {
|
||||
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run legacy uninstall cleanup that must execute BEFORE the layout-driven
|
||||
* removal so old-format entries are also cleaned up.
|
||||
*
|
||||
* @param runtime
|
||||
* @param configDir resolved runtime config directory
|
||||
* @param scope
|
||||
* @returns saved legacy artifacts for post-removal migration, or null
|
||||
*/
|
||||
function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: string = 'global'): Map<string, string> | null {
|
||||
// commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
|
||||
// Prior to #1367 fix, Claude-local used commands/gsd/<cmd>.md (colon-namespaced).
|
||||
// After #1367, Claude-local uses flat commands/gsd-<cmd>.md. The inline uninstall
|
||||
// block (1c) handles removal of flat files; this function handles the legacy
|
||||
// commands/gsd/ directory for all Claude scopes (global was already included,
|
||||
// local is now added since that layout is also legacy post-#1367).
|
||||
// #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md
|
||||
// before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md
|
||||
// is deferred and returned so the caller can apply it AFTER layout-driven
|
||||
// removal — this prevents the layout's gsd-* prefix removal from wiping the
|
||||
// freshly created skill dir (same pattern as _runLegacyInstallMigrations).
|
||||
let savedLegacyArtifacts: Map<string, string> | null = null;
|
||||
// commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
|
||||
// Claude local is intentionally excluded: the inline uninstall block (1c) handles
|
||||
// commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
|
||||
// to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
|
||||
// (which would redirect to skills/) conflicts with the test contract for local installs.
|
||||
const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
|
||||
if (isLegacyCommandsGsd) {
|
||||
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
|
||||
if (fs.existsSync(legacyCommandsGsd)) {
|
||||
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
|
||||
fs.rmSync(legacyCommandsGsd, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes: pre-#2841 flat skills/gsd-*/ entries
|
||||
if (runtime === 'hermes') {
|
||||
const flatSkillsDir = path.join(configDir, 'skills');
|
||||
if (fs.existsSync(flatSkillsDir)) {
|
||||
for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
||||
fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes: pre-#947 bare-stem skills/gsd/<stem>/ entries (dirs that do NOT
|
||||
// start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills
|
||||
// had bare names (e.g. skills/gsd/help/). These are stale on uninstall.
|
||||
const nestedGsdDirForUninstall = path.join(configDir, 'skills', 'gsd');
|
||||
if (fs.existsSync(nestedGsdDirForUninstall)) {
|
||||
for (const entry of fs.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith('gsd-')) {
|
||||
fs.rmSync(path.join(nestedGsdDirForUninstall, entry.name), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return saved artifacts so the caller can migrate after layout-driven removal.
|
||||
return savedLegacyArtifacts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// installRuntimeArtifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Layout-driven install orchestrator.
|
||||
* Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to
|
||||
* determine what artifact kinds to write and where.
|
||||
*
|
||||
* @param runtime canonical runtime ID
|
||||
* @param configDir resolved runtime config directory
|
||||
* @param scope
|
||||
* @param resolvedProfile from resolveProfile() / resolveEffectiveProfile()
|
||||
* @param resolveAttribution injection: (runtime) => attribution string | undefined
|
||||
*/
|
||||
function installRuntimeArtifacts(
|
||||
runtime: string,
|
||||
configDir: string,
|
||||
scope: string,
|
||||
resolvedProfile: any,
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
): void {
|
||||
// Legacy cleanup before layout-driven writes
|
||||
_runLegacyInstallMigrations(runtime, configDir, scope);
|
||||
|
||||
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as 'global' | 'local');
|
||||
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
|
||||
// `Layout` is structurally identical across the layout/install-plan .cjs
|
||||
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
|
||||
layout: layout as any,
|
||||
resolvedProfile,
|
||||
homedir: () => os.homedir(),
|
||||
platform: process.platform,
|
||||
resolveAttribution,
|
||||
});
|
||||
|
||||
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
|
||||
try {
|
||||
if (!planResult.ok) {
|
||||
throw new Error(planResult.message);
|
||||
}
|
||||
|
||||
const kindsByName = new Map<string, any>(layout.kinds.map((kind: any) => [kind.kind as string, kind]));
|
||||
for (const item of planResult.plan.items) {
|
||||
const kind: any = kindsByName.get(item.kind);
|
||||
if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
|
||||
const dest = item.destDir;
|
||||
// Symlink-escape guard: reject before mkdir if dest (or any component
|
||||
// between configDir and dest) is a symlink pointing outside configDir.
|
||||
// mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
|
||||
if (hasExistingSymlinkBetween(path.resolve(configDir), dest)) {
|
||||
throw new Error(
|
||||
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`,
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
if (kind.kind === 'skills' && fs.existsSync(dest)) {
|
||||
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
|
||||
// then restore after. This preserves user dirs across a wipe-and-replace
|
||||
// install (#2973 / #3664).
|
||||
//
|
||||
// All runtimes (incl. Hermes after #947) use prefix='gsd-'.
|
||||
// _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
|
||||
// untouched. Preserve the explicit user-owned GSD-prefixed skill
|
||||
// gsd-dev-preferences, which GSD does not reinstall from source but must
|
||||
// survive the prune (#2973).
|
||||
const toPreserve = new Map<string, Map<string, Buffer>>(); // dirName -> Map<relPath, Buffer>
|
||||
|
||||
{
|
||||
// Preserve explicitly user-owned GSD-prefixed skill dirs.
|
||||
// gsd-dev-preferences is the sole user-customisable skill in this category.
|
||||
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
||||
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
||||
const skillDir = path.join(dest, dirName);
|
||||
if (!fs.existsSync(skillDir)) continue;
|
||||
const snap = _snapshotDir(skillDir);
|
||||
if (snap.size > 0) toPreserve.set(dirName, snap);
|
||||
}
|
||||
}
|
||||
|
||||
_removeGsdEntries(dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind, configDir);
|
||||
|
||||
// Restore user-owned dirs after the prune+copy
|
||||
for (const [dirName, snap] of toPreserve) {
|
||||
_restoreDir(path.join(dest, dirName), snap);
|
||||
}
|
||||
} else {
|
||||
// For non-skills kinds (commands, agents): no user content to preserve;
|
||||
// just prune stale gsd-* entries and copy new ones.
|
||||
_removeGsdEntries(dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind, configDir);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const dir of cleanupDirs) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
|
||||
// skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
|
||||
// correspond to the newly installed gsd-<stem> entries. This is the robust
|
||||
// replacement for the readGsdCommandNames()-based pre-install cleanup that
|
||||
// missed skills like 'dev-preferences' (#947 adversarial review).
|
||||
//
|
||||
// We run this AFTER the install loop so the installed set is authoritative:
|
||||
// every gsd-<stem>/ present now was written this run (or was there before
|
||||
// with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
|
||||
// are untouched.
|
||||
if (runtime === 'hermes') {
|
||||
const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
|
||||
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// installOpencodeFamilySkills
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
|
||||
*
|
||||
* These runtimes do NOT go through installRuntimeArtifacts (their commands use a
|
||||
* bespoke flattened-command writer), so this writes ONLY the skills kind
|
||||
* alongside their existing command/ + agents/ surfaces. Uninstall is already
|
||||
* layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
|
||||
* skills/ dir is cleaned up automatically once the layout declares it.
|
||||
*
|
||||
* @param runtime - 'opencode' or 'kilo'
|
||||
* @param targetDir - resolved runtime config directory
|
||||
* @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
|
||||
* @param pathPrefix - computed config-path prefix for body rewrites
|
||||
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
||||
* @returns number of gsd-* skill directories written
|
||||
*/
|
||||
function installOpencodeFamilySkills(
|
||||
runtime: string,
|
||||
targetDir: string,
|
||||
rawCommandsDir: string,
|
||||
pathPrefix: string,
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
): number {
|
||||
const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir);
|
||||
const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills');
|
||||
if (!skillsKindEntry) return 0;
|
||||
const rawDir = rawCommandsDir;
|
||||
if (!rawDir || !fs.existsSync(rawDir)) return 0;
|
||||
|
||||
const converter = runtime === 'kilo'
|
||||
? convertClaudeCommandToKiloSkill
|
||||
: convertClaudeCommandToOpencodeSkill;
|
||||
|
||||
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
|
||||
// Symlink-escape guard: reject if any path component between targetDir and
|
||||
// dest is a symlink that would redirect writes outside the config root.
|
||||
if (hasExistingSymlinkBetween(path.resolve(targetDir), dest)) {
|
||||
throw new Error(
|
||||
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
|
||||
// gsd-dev-preferences is generated by the user (via generate-dev-preferences)
|
||||
// and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
|
||||
// would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts
|
||||
// (#2973).
|
||||
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
||||
const toPreserve = new Map<string, Map<string, Buffer>>(); // dirName -> Map<relPath, Buffer>
|
||||
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
||||
const skillDir = path.join(dest, dirName);
|
||||
if (!fs.existsSync(skillDir)) continue;
|
||||
const snap = _snapshotDir(skillDir);
|
||||
if (snap.size > 0) toPreserve.set(dirName, snap);
|
||||
}
|
||||
|
||||
_removeGsdEntries(dest, skillsKindEntry);
|
||||
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
||||
let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
|
||||
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
||||
content = processAttribution(content, resolveAttribution(runtime));
|
||||
content = converter(content, skillName);
|
||||
const skillDir = path.join(dest, skillName);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
count++;
|
||||
}
|
||||
|
||||
// Restore user-owned dirs after the prune+copy.
|
||||
for (const [dirName, snap] of toPreserve) {
|
||||
_restoreDir(path.join(dest, dirName), snap);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uninstallRuntimeArtifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Layout-driven uninstall orchestrator.
|
||||
* Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
|
||||
* determine which GSD-owned entries to remove.
|
||||
*
|
||||
* @param runtime canonical runtime ID
|
||||
* @param configDir resolved runtime config directory
|
||||
* @param scope
|
||||
*/
|
||||
function uninstallRuntimeArtifacts(runtime: string, configDir: string, scope: string): void {
|
||||
// Legacy cleanup before layout-driven removal (scope-aware to avoid
|
||||
// removing Claude local commands/gsd/ which is the primary install dir).
|
||||
// Returns saved user artifacts so we can migrate AFTER layout removal
|
||||
// (the layout's gsd-* prefix pass would wipe a skill dir created here).
|
||||
const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
|
||||
|
||||
const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as any);
|
||||
const plan: any = runtimeArtifactInstallPlan.createRuntimeArtifactUninstallPlan(layout);
|
||||
const kindsByName = new Map<string, any>(layout.kinds.map((kind: any) => [kind.kind as string, kind]));
|
||||
for (const item of plan.items) {
|
||||
const kind: any = kindsByName.get(item.kind);
|
||||
if (!kind) {
|
||||
throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`);
|
||||
}
|
||||
_removeGsdEntries(item.destDir, kind);
|
||||
}
|
||||
|
||||
// Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
|
||||
// the GSD-managed DESCRIPTION.md and then the category dir itself if it
|
||||
// contains no user content (#947). _removeGsdEntries removed gsd-* dirs
|
||||
// but left the category container and DESCRIPTION.md intact.
|
||||
if (runtime === 'hermes') {
|
||||
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
|
||||
if (fs.existsSync(nestedGsdDir)) {
|
||||
// Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
|
||||
fs.rmSync(path.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true });
|
||||
// Remove the category dir if empty (no user content remaining)
|
||||
const remaining = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
|
||||
if (remaining.length === 0) {
|
||||
fs.rmSync(nestedGsdDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
|
||||
// runtime-aware SKILL.md location after all layout-driven removal is
|
||||
// complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
|
||||
if (savedLegacyArtifacts) {
|
||||
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export = {
|
||||
installRuntimeArtifacts,
|
||||
uninstallRuntimeArtifacts,
|
||||
installOpencodeFamilySkills,
|
||||
_copyStaged,
|
||||
hasExistingSymlinkBetween,
|
||||
preserveUserArtifacts,
|
||||
restoreUserArtifacts,
|
||||
migrateLegacyDevPreferencesToSkill,
|
||||
applyOpencodeFamilyPathPrefix,
|
||||
convertClaudeCommandToOpencodeSkill,
|
||||
convertClaudeCommandToKiloSkill,
|
||||
USER_OWNED_ARTIFACTS,
|
||||
_runLegacyInstallMigrations,
|
||||
_runLegacyUninstallCleanup,
|
||||
_removeGsdEntries,
|
||||
_snapshotDir,
|
||||
_restoreDir,
|
||||
_removeHermesBareStemDirs,
|
||||
};
|
||||
@@ -11,6 +11,19 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import conversionModule = require('./runtime-artifact-conversion.cjs');
|
||||
const {
|
||||
applyAgentPathRewrites: _applyAgentPathRewrites,
|
||||
processAttribution: _processAttribution,
|
||||
normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime,
|
||||
readGsdCommandNames: _readGsdCommandNames,
|
||||
} = conversionModule as {
|
||||
applyAgentPathRewrites: (content: string, runtime: string, pathPrefix: string) => string;
|
||||
processAttribution: (content: string, attribution: string | null | undefined) => string;
|
||||
normalizeAgentBodyForRuntime: (content: string, runtime: string, cmdNames: string[]) => string;
|
||||
readGsdCommandNames: () => string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile definitions
|
||||
@@ -530,6 +543,18 @@ function stageSkillsForRuntimeAsSkills(
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1).
|
||||
* When present, stageAgentsForRuntimeWithConverter applies the full inline-loop
|
||||
* sequence per agent: pathRewrites → attribution → converter → normalize.
|
||||
* The field names mirror the inline loop's available identifiers.
|
||||
*/
|
||||
interface AgentCtx {
|
||||
runtime: string;
|
||||
pathPrefix: string;
|
||||
attribution: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a converted copy of the agents directory for a given runtime.
|
||||
*
|
||||
@@ -546,24 +571,39 @@ function stageSkillsForRuntimeAsSkills(
|
||||
* For tiered profiles, only agents whose full stem is in `resolvedProfile.agents`
|
||||
* are staged (mirrors `stageAgentsForProfile` behaviour).
|
||||
*
|
||||
* ADR-1235 §1: when `agentCtx` is provided, the per-file order matches the inline
|
||||
* agent loop in bin/install.js exactly:
|
||||
* 1. applyAgentPathRewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)
|
||||
* 2. processAttribution (Co-Authored-By policy)
|
||||
* 3. converter (runtime-specific frontmatter/body transform)
|
||||
* 4. normalizeAgentBodyForRuntime (colon→hyphen refs; no-op for trivial group)
|
||||
* When `agentCtx` is absent, only the converter is applied (backward-compat for
|
||||
* the feat-1173 synthetic-descriptor tests and the copilot/antigravity paths
|
||||
* that handle cross-cutting inside their converters).
|
||||
*
|
||||
* @param srcAgentsDir source agents directory (e.g. agents/)
|
||||
* @param resolvedProfile profile filter from resolveProfile()
|
||||
* @param converter (content: string, isGlobal?: boolean) → string per-file
|
||||
* converter; scope-aware converters (copilot/antigravity)
|
||||
* read isGlobal, single-arg converters ignore it (#1173)
|
||||
* @param isGlobal install scope passed through to the converter
|
||||
* @param agentCtx optional cross-cutting context (ADR-1235 §1); when absent,
|
||||
* only the converter is applied (backward compat)
|
||||
*/
|
||||
function stageAgentsForRuntimeWithConverter(
|
||||
srcAgentsDir: string,
|
||||
resolvedProfile: ResolvedProfile,
|
||||
converter: (content: string, isGlobal?: boolean) => string,
|
||||
isGlobal = false,
|
||||
agentCtx?: AgentCtx,
|
||||
): string {
|
||||
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-agents-'));
|
||||
try {
|
||||
const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true });
|
||||
// Resolve cmdNames once per staging call (not per file) for performance.
|
||||
const cmdNames = agentCtx ? _readGsdCommandNames() : [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
@@ -574,9 +614,22 @@ function stageAgentsForRuntimeWithConverter(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8');
|
||||
const converted = converter(content, isGlobal);
|
||||
fs.writeFileSync(path.join(stageDir, entry.name), converted, 'utf8');
|
||||
let content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8');
|
||||
if (agentCtx) {
|
||||
// ADR-1235 §1: pre-converter cross-cutting (matches inline loop order exactly)
|
||||
// Step 1: path rewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)
|
||||
content = _applyAgentPathRewrites(content, agentCtx.runtime, agentCtx.pathPrefix);
|
||||
// Step 2: attribution
|
||||
content = _processAttribution(content, agentCtx.attribution);
|
||||
// Step 3: converter (runtime-specific frontmatter/body transform)
|
||||
content = converter(content, isGlobal);
|
||||
// Step 4: normalize colon→hyphen refs (no-op for trivial group)
|
||||
content = _normalizeAgentBodyForRuntime(content, agentCtx.runtime, cmdNames);
|
||||
} else {
|
||||
// Backward-compat: only apply the converter (no cross-cutting)
|
||||
content = converter(content, isGlobal);
|
||||
}
|
||||
fs.writeFileSync(path.join(stageDir, entry.name), content, 'utf8');
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type MigrationRecord,
|
||||
type MigrationAction,
|
||||
} from './installer-migration-authoring.cjs';
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
import { platformWriteSync, retryRenameSync } from './shell-command-projection.cjs';
|
||||
import { realClock, type Clock } from './clock.cjs';
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
@@ -105,7 +105,7 @@ function atomicWriteInstallState(configDir: string, content: string): void {
|
||||
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, content, 'utf8');
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
retryRenameSync(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
try { fs.rmSync(tmpPath, { force: true }); } catch { /* best-effort */ }
|
||||
throw error;
|
||||
|
||||
@@ -14,7 +14,7 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
||||
import stateMod = require('./state.cjs');
|
||||
import { platformWriteSync, platformEnsureDir, execGit } from './shell-command-projection.cjs';
|
||||
import { platformWriteSync, platformEnsureDir, execGit, retryRenameSync } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
@@ -184,6 +184,13 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
})();
|
||||
while ((pm = phasePattern.exec(scopedContent)) !== null) {
|
||||
const phaseNum = pm[1];
|
||||
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not
|
||||
// real phases — they legitimately have no directory and must not block
|
||||
// milestone completion. Mirrors the engine-wide sentinel convention
|
||||
// (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
|
||||
// the #1445 /^999/ progress filters). (#1580)
|
||||
const major = parseInt(phaseNum, 10);
|
||||
if (major === 0 || major === 999) continue;
|
||||
const normalized = normalizePhaseName(phaseNum);
|
||||
// A phase has disk_status: 'no_directory' when no phase directory
|
||||
// with a matching token exists on disk. Use the same phaseTokenMatches
|
||||
@@ -276,7 +283,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
// Archive audit file if exists
|
||||
const auditFile = path.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`);
|
||||
if (fs.existsSync(auditFile)) {
|
||||
fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
|
||||
retryRenameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
|
||||
}
|
||||
|
||||
// Create/append MILESTONES.md entry
|
||||
@@ -357,7 +364,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
let archivedCount = 0;
|
||||
for (const dir of phaseDirNames) {
|
||||
if (!isDirInMilestone(dir)) continue;
|
||||
fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
|
||||
retryRenameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
|
||||
archivedCount++;
|
||||
}
|
||||
phasesArchived = archivedCount > 0;
|
||||
|
||||
@@ -49,7 +49,7 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
||||
import stateMod = require('./state.cjs');
|
||||
import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
@@ -1068,12 +1068,12 @@ function renameDecimalPhases(
|
||||
const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
|
||||
const newPhaseId = `${baseInt}.${newDecimal}`;
|
||||
const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
|
||||
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
|
||||
retryRenameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
|
||||
renamedDirs.push({ from: item.dir, to: newDirName });
|
||||
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
|
||||
if (f.includes(oldPhaseId)) {
|
||||
const newFileName = f.replace(oldPhaseId, newPhaseId);
|
||||
fs.renameSync(
|
||||
retryRenameSync(
|
||||
path.join(phasesDir, newDirName, f),
|
||||
path.join(phasesDir, newDirName, newFileName),
|
||||
);
|
||||
@@ -1120,12 +1120,12 @@ function renameIntegerPhases(
|
||||
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
|
||||
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
|
||||
const newDirName = `${newPrefix}-${item.slug}`;
|
||||
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
|
||||
retryRenameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
|
||||
renamedDirs.push({ from: item.dir, to: newDirName });
|
||||
for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
|
||||
if (f.startsWith(oldPrefix)) {
|
||||
const newFileName = newPrefix + f.slice(oldPrefix.length);
|
||||
fs.renameSync(
|
||||
retryRenameSync(
|
||||
path.join(phasesDir, newDirName, f),
|
||||
path.join(phasesDir, newDirName, newFileName),
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
import type { Clock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -276,7 +276,7 @@ function withPlanningLock<T>(cwd: string, fn: () => T, clock?: Clock): T {
|
||||
// we must NOT fall through to a delete — back off and retry the create.
|
||||
const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_planningStealSeq++);
|
||||
let renamed = false;
|
||||
try { fs.renameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ }
|
||||
try { retryRenameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ }
|
||||
if (renamed) {
|
||||
try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ }
|
||||
continue; // dead/garbage/expired holder freed — retry immediately to grab it.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { retryRenameSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -523,7 +524,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
|
||||
const oldPath = path.join(phasesDir, phaseEntry.oldDir);
|
||||
const newPath = path.join(phasesDir, phaseEntry.newDir);
|
||||
if (fs.existsSync(oldPath)) {
|
||||
fs.renameSync(oldPath, newPath);
|
||||
retryRenameSync(oldPath, newPath);
|
||||
performedRenames.push({ oldPath, newPath });
|
||||
renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
|
||||
}
|
||||
@@ -597,7 +598,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
|
||||
for (let i = performedRenames.length - 1; i >= 0; i--) {
|
||||
const { oldPath, newPath } = performedRenames[i];
|
||||
try {
|
||||
if (fs.existsSync(newPath)) fs.renameSync(newPath, oldPath);
|
||||
if (fs.existsSync(newPath)) retryRenameSync(newPath, oldPath);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
for (const [filePath, backup] of fileBackups) {
|
||||
|
||||
@@ -318,6 +318,16 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
}> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
|
||||
// phases. They legitimately have no directory and must never be surfaced as
|
||||
// current/next phase or counted in phase_count. Mirrors the engine-wide
|
||||
// sentinel convention (phase-id getMilestoneFromPhaseId, roadmap-command-router
|
||||
// SENTINELS, the #1445 /^999/ progress filters). (#1580)
|
||||
const isSentinelPhase = (num: string): boolean => {
|
||||
const major = parseInt(num, 10);
|
||||
return major === 0 || major === 999;
|
||||
};
|
||||
|
||||
// Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
|
||||
const _phaseDirNames = (() => {
|
||||
try {
|
||||
@@ -329,6 +339,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
|
||||
while ((match = phasePattern.exec(content)) !== null) {
|
||||
const phaseNum = match[1];
|
||||
if (isSentinelPhase(phaseNum)) continue;
|
||||
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
|
||||
|
||||
// Extract goal from the section
|
||||
@@ -437,7 +448,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
checklistPhases.add(checklistMatch[1]);
|
||||
}
|
||||
const detailPhases = new Set(phases.map(p => p.number));
|
||||
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p));
|
||||
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhase(p));
|
||||
|
||||
const result = {
|
||||
milestones,
|
||||
|
||||
@@ -23,6 +23,7 @@ import commandRoster = require('./command-roster.cjs');
|
||||
const { readGsdCommandNames, transformContentToHyphen } = commandRoster;
|
||||
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
|
||||
const { getDirName } = runtimeNamePolicy;
|
||||
import capabilityRegistry = require('./capability-registry.cjs');
|
||||
|
||||
// #1383: resolve GSD's version WITHOUT a top-level
|
||||
// `require('../../../package.json')`. That require ran at module load on every
|
||||
@@ -2199,16 +2200,15 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
|
||||
|
||||
/**
|
||||
* Canonical list of every non-Claude runtime that gsd-core emits artifacts for.
|
||||
* Exported so test files can import this single source of truth rather than
|
||||
* maintaining divergent hand-rolled arrays (#1521).
|
||||
*
|
||||
* Keep in sync with the runtime flags in bin/install.js and getDirName().
|
||||
* DERIVED from the capability registry (ADR-1239 Phase B, #1679) — the registry's
|
||||
* `runtimes` map is the single source of truth for runtime identity, so the
|
||||
* non-Claude set is its key set minus 'claude'. This replaces a hand-maintained
|
||||
* literal that had to be kept in sync with bin/install.js and getDirName(), and
|
||||
* can no longer drift from the registry. Exported so tests import one source (#1521).
|
||||
*/
|
||||
const NON_CLAUDE_RUNTIMES: string[] = [
|
||||
'codex', 'opencode', 'kilo', 'gemini', 'copilot', 'antigravity',
|
||||
'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'kimi',
|
||||
'codebuddy', 'cline',
|
||||
];
|
||||
const NON_CLAUDE_RUNTIMES: string[] = Object.keys(capabilityRegistry.runtimes)
|
||||
.filter((id) => id !== 'claude')
|
||||
.sort();
|
||||
|
||||
/**
|
||||
* #1521: Every non-Claude runtime resolves its own runtime identity from a
|
||||
@@ -2551,6 +2551,60 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
|
||||
return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtimes that use the hyphen-namespace form `/gsd-<cmd>` in agent bodies.
|
||||
* claude/qwen/hermes use hyphen-name:`...` frontmatter; cursor/windsurf/etc
|
||||
* self-convert. Mirrors the `HYPHEN_NAME_AGENT_RUNTIMES` set in bin/install.js.
|
||||
*
|
||||
* @private — export normalizeAgentBodyForRuntime for callers.
|
||||
*/
|
||||
const HYPHEN_NAME_AGENT_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'qwen', 'hermes']);
|
||||
|
||||
/**
|
||||
* Normalize `/gsd:<cmd>` colon refs in the agent body to `/gsd-<cmd>` for
|
||||
* hyphen-`name:` runtimes (claude / qwen / hermes). No-op for all other
|
||||
* runtimes. Mirrors the per-file call in bin/install.js line 9400.
|
||||
*
|
||||
* @param content raw agent file content (post-converter)
|
||||
* @param runtime canonical runtime ID
|
||||
* @param cmdNames gsd command names from readGsdCommandNames()
|
||||
*/
|
||||
function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames: string[]): string {
|
||||
if (!HYPHEN_NAME_AGENT_RUNTIMES.has(runtime)) return content;
|
||||
return transformContentToHyphen(content, cmdNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the 4 base `~/.claude/` path-prefix rewrites to a single agent content
|
||||
* string. Mirrors the inline agent loop in bin/install.js lines 9330-9340:
|
||||
* ~/\.claude/ → pathPrefix
|
||||
* $HOME/\.claude/ → pathPrefix
|
||||
* ~/\.claude\b → normalizedPathPrefix
|
||||
* $HOME/\.claude\b → normalizedPathPrefix
|
||||
*
|
||||
* Skipped for copilot and antigravity (which do NOT do path rewrites in the
|
||||
* inline loop). NO stamp (_stampNonClaudeRuntimeDefaults) — agents are NOT
|
||||
* stamped in the inline loop.
|
||||
*
|
||||
* ADR-1235 §1: pre-converter cross-cutting for descriptor-driven agent pipeline.
|
||||
* Exported as `applyAgentPathRewrites` for testing and for injection into
|
||||
* stageAgentsForRuntimeWithConverter via agentCtx.
|
||||
*
|
||||
* @param content raw agent file content
|
||||
* @param runtime canonical runtime ID
|
||||
* @param pathPrefix trailing-slash path prefix (e.g. '$HOME/.cursor/')
|
||||
* @returns content with path-prefix rewrites applied (or unchanged for copilot/antigravity)
|
||||
*/
|
||||
function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string {
|
||||
if (runtime === 'copilot' || runtime === 'antigravity') return content;
|
||||
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── End rewrite engine ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -2644,6 +2698,9 @@ export = {
|
||||
// High-level wrappers (derive pathPrefix + attribution from opts):
|
||||
rewriteStagedSkillBodies,
|
||||
rewriteStagedCommandBodies,
|
||||
// ADR-1235 §1: descriptor-driven agent cross-cutting
|
||||
applyAgentPathRewrites,
|
||||
normalizeAgentBodyForRuntime,
|
||||
_computePathPrefix: computePathPrefix,
|
||||
_applyRuntimeRewrites,
|
||||
_stampNonClaudeRuntimeDefaults,
|
||||
|
||||
@@ -21,11 +21,17 @@ interface ResolvedProfile {
|
||||
agents?: Set<string>;
|
||||
}
|
||||
|
||||
interface AgentCtx {
|
||||
runtime: string;
|
||||
pathPrefix: string;
|
||||
attribution: string | null | undefined;
|
||||
}
|
||||
|
||||
interface ArtifactKind {
|
||||
kind: ArtifactKindName;
|
||||
destSubpath: string;
|
||||
prefix?: string;
|
||||
stage: (resolvedProfile: ResolvedProfile) => string;
|
||||
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
|
||||
}
|
||||
|
||||
interface Layout {
|
||||
@@ -49,9 +55,18 @@ interface Dependencies {
|
||||
rewriteStagedCommandBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
|
||||
}
|
||||
|
||||
interface ComputePathPrefixOpts {
|
||||
isGlobal: boolean;
|
||||
isOpencode: boolean;
|
||||
isWindowsHost: boolean;
|
||||
resolvedTarget: string;
|
||||
homeDir: string;
|
||||
}
|
||||
|
||||
interface RuntimeArtifactConversionExports {
|
||||
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
||||
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
||||
_computePathPrefix: (opts: ComputePathPrefixOpts) => string;
|
||||
}
|
||||
|
||||
interface PlanItem {
|
||||
@@ -87,6 +102,35 @@ interface CreateRuntimeArtifactInstallPlanArgs {
|
||||
deps?: Dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that `destSubpath` resolves to a path inside `configDir`.
|
||||
*
|
||||
* Rejects any path that escapes the configDir root (e.g. "../../etc") and any
|
||||
* path containing a NUL byte. This is a security gate for Phase B of
|
||||
* ADR-1239: third-party descriptors must never be able to write outside the
|
||||
* designated config home directory.
|
||||
*
|
||||
* @param configDir - The root config directory (e.g. ~/.claude).
|
||||
* @param destSubpath - The relative path declared by the runtime descriptor.
|
||||
* @returns The resolved absolute path under configDir.
|
||||
* @throws {Error} if destSubpath escapes configDir or contains a NUL byte.
|
||||
*/
|
||||
function assertDestWithinConfigHome(configDir: string, destSubpath: string): string {
|
||||
if (destSubpath.includes('\0')) {
|
||||
throw new Error(
|
||||
`destSubpath "${destSubpath}" contains a NUL byte and is not valid`,
|
||||
);
|
||||
}
|
||||
const root = path.resolve(configDir);
|
||||
const resolved = path.resolve(configDir, destSubpath);
|
||||
if (resolved === root || !resolved.startsWith(root + path.sep)) {
|
||||
throw new Error(
|
||||
`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
@@ -122,10 +166,34 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
|
||||
resolveAttribution,
|
||||
};
|
||||
|
||||
// ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply
|
||||
// the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter
|
||||
// → normalize). This mirrors the exact per-file order in the inline agent loop
|
||||
// in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg
|
||||
// to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
|
||||
// NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
|
||||
const os = _require('node:os') as typeof import('node:os');
|
||||
const homedirFn: () => string = homedir ?? (() => os.homedir());
|
||||
const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
|
||||
const homeDir = homedirFn().replace(/\\/g, '/');
|
||||
const isGlobal = scope === 'global';
|
||||
const isOpencode = layout.runtime === 'opencode';
|
||||
const isWindowsHost = (platform ?? process.platform) === 'win32';
|
||||
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
|
||||
const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
|
||||
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix, attribution };
|
||||
|
||||
for (const kind of layout.kinds) {
|
||||
let stagedDir: string;
|
||||
try {
|
||||
stagedDir = kind.stage(resolvedProfile);
|
||||
if (kind.kind === 'agents') {
|
||||
// ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies
|
||||
// the full inline-loop order: pathRewrites → attribution → converter → normalize.
|
||||
// The cross-cutting is now PRE-converter (inside staging), not POST.
|
||||
stagedDir = kind.stage(resolvedProfile, agentCtx);
|
||||
} else {
|
||||
stagedDir = kind.stage(resolvedProfile);
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
|
||||
}
|
||||
@@ -139,6 +207,8 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
|
||||
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
|
||||
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
|
||||
}
|
||||
// agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx.
|
||||
// No POST-step needed. sourceDir stays as stagedDir.
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
|
||||
}
|
||||
@@ -146,7 +216,7 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
|
||||
items.push({
|
||||
kind: kind.kind,
|
||||
sourceDir,
|
||||
destDir: path.join(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,9 +227,9 @@ function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan {
|
||||
return {
|
||||
items: layout.kinds.map((kind) => ({
|
||||
kind: kind.kind,
|
||||
destDir: path.join(layout.configDir, kind.destSubpath),
|
||||
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
|
||||
export = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
|
||||
|
||||
@@ -54,11 +54,25 @@ interface ResolvedProfile {
|
||||
agents: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1).
|
||||
* Passed as the optional second arg to ArtifactKind.stage() for agents kind
|
||||
* entries so that stageAgentsForRuntimeWithConverter can apply the exact
|
||||
* inline-loop transform order: pathRewrites → attribution → converter → normalize.
|
||||
*/
|
||||
interface AgentCtx {
|
||||
runtime: string;
|
||||
pathPrefix: string;
|
||||
attribution: string | null | undefined;
|
||||
}
|
||||
|
||||
interface ArtifactKind {
|
||||
kind: KimiArtifactKindName;
|
||||
destSubpath: string;
|
||||
prefix: string;
|
||||
stage: (resolvedProfile: ResolvedProfile) => string;
|
||||
/** For agents kind with a converter, accepts an optional AgentCtx as the second
|
||||
* arg so cross-cutting can be applied pre-converter (ADR-1235 §1). */
|
||||
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
|
||||
}
|
||||
|
||||
interface Layout {
|
||||
@@ -207,17 +221,21 @@ function convertedAgentsKind(
|
||||
kind: 'agents',
|
||||
destSubpath,
|
||||
prefix,
|
||||
stage: (resolved) => {
|
||||
stage: (resolved, agentCtx) => {
|
||||
// isGlobal is threaded so scope-aware agent converters (copilot, antigravity)
|
||||
// choose global-home vs workspace-relative paths; converters that only take
|
||||
// (content) ignore the extra positional arg. Mirrors skillsKind's scope
|
||||
// threading (#1173).
|
||||
const converter = conversionExports[converterName] as (content: string, isGlobal?: boolean) => string;
|
||||
// ADR-1235 §1: when agentCtx is provided (by createRuntimeArtifactInstallPlan
|
||||
// for descriptor-driven runtimes), thread it through so stageAgentsForRuntimeWithConverter
|
||||
// can apply the full pre-converter + post-converter sequence in the correct order.
|
||||
return stageAgentsForRuntimeWithConverter(
|
||||
findAgentsSourceRoot(configDir),
|
||||
resolved,
|
||||
converter,
|
||||
scope === 'global',
|
||||
agentCtx,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -110,7 +110,7 @@ function atomicWriteFileSync(target: string, data: string, options: fs.WriteFile
|
||||
__atomicWrittenTmps.add(tmp);
|
||||
try {
|
||||
fs.writeFileSync(tmp, data, options);
|
||||
fs.renameSync(tmp, target);
|
||||
shellCmdProjection.retryRenameSync(tmp, target);
|
||||
// Successful rename: the tmp path no longer exists, but leave it in the
|
||||
// Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
|
||||
// lingers (e.g. a rename succeeded but left a stale entry on some FS).
|
||||
|
||||
@@ -142,20 +142,12 @@ export function getProjectInstructionFile(runtime: unknown): string {
|
||||
* `bin/install.js` re-exports this same function for back-compat.
|
||||
*/
|
||||
export function getDirName(runtime: string): string {
|
||||
if (runtime === 'copilot') return '.github';
|
||||
if (runtime === 'opencode') return '.opencode';
|
||||
if (runtime === 'gemini') return '.gemini';
|
||||
if (runtime === 'kilo') return '.kilo';
|
||||
if (runtime === 'codex') return '.codex';
|
||||
if (runtime === 'antigravity') return '.agents';
|
||||
if (runtime === 'cursor') return '.cursor';
|
||||
if (runtime === 'windsurf') return '.windsurf';
|
||||
if (runtime === 'augment') return '.augment';
|
||||
if (runtime === 'trae') return '.trae';
|
||||
if (runtime === 'qwen') return '.qwen';
|
||||
if (runtime === 'hermes') return '.hermes';
|
||||
if (runtime === 'kimi') return '.kimi-code';
|
||||
if (runtime === 'codebuddy') return '.codebuddy';
|
||||
if (runtime === 'cline') return '.cline';
|
||||
if (!runtime) return '.claude';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { runtimes } = require('./capability-registry.cjs') as {
|
||||
runtimes: Record<string, { runtime?: { localConfigDir?: string } } | undefined>;
|
||||
};
|
||||
const dir = runtimes[runtime]?.runtime?.localConfigDir;
|
||||
if (typeof dir === 'string' && dir.length > 0) return dir;
|
||||
return '.claude';
|
||||
}
|
||||
|
||||
@@ -253,6 +253,15 @@ export function isManagedHookCommand(commandText: unknown, opts: { surface?: str
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a `"$VAR"/rest` anchored hook-script token — a path whose leading
|
||||
* shell variable is already double-quoted with the remainder left bare (the
|
||||
* shape `projectLocalHookPrefix` emits for local installs, e.g.
|
||||
* `"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-x.js`). Such a token is ALREADY a
|
||||
* valid, correctly-quoted shell argument and must never be re-quoted.
|
||||
*/
|
||||
const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
|
||||
|
||||
/**
|
||||
* Projection helper for legacy settings.json hook rewrites.
|
||||
*
|
||||
@@ -275,8 +284,20 @@ export function projectLegacySettingsHookCommand({
|
||||
}): string | null {
|
||||
if (!absoluteRunner || !scriptPath) return null;
|
||||
const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
|
||||
// #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
|
||||
// quoted prefix (local installs) is already a valid shell token — only the
|
||||
// variable is quoted, the rest is bare. JSON.stringify-ing it on Windows
|
||||
// yields `"\"$CLAUDE_PROJECT_DIR\"/..."` (escaped quotes inside an outer
|
||||
// quote); node then receives an argument that *starts* with a `"`, treats it
|
||||
// as relative, and dies with MODULE_NOT_FOUND. Emit anchored tokens verbatim;
|
||||
// only bare absolute paths (which may contain spaces, e.g. "Program Files")
|
||||
// need the JSON.stringify quoting. Scoped to win32: the non-Windows branch
|
||||
// already preserves the caller's `scriptToken` (which is the bare anchored
|
||||
// token for these inputs), so it never had the double-quote bug.
|
||||
const commandScriptToken = platform === 'win32'
|
||||
? JSON.stringify(normalizedScriptPath)
|
||||
? (ANCHORED_HOOK_SCRIPT_TOKEN.test(normalizedScriptPath)
|
||||
? normalizedScriptPath
|
||||
: JSON.stringify(normalizedScriptPath))
|
||||
: (scriptToken || JSON.stringify(normalizedScriptPath));
|
||||
return projectShellCommandText({
|
||||
runnerToken: absoluteRunner,
|
||||
@@ -596,6 +617,21 @@ function atomicRenameWithRetry(tmpPath: string, filePath: string): NodeJS.ErrnoE
|
||||
return renameErr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `fs.renameSync(from, to)` that retries the transient
|
||||
* Windows lock errnos (EPERM/EBUSY/EACCES — see DEFECT.WINDOWS-FS-OPS) a bounded
|
||||
* number of times with a short backoff before rethrowing the final error.
|
||||
*
|
||||
* Idempotent on POSIX (the transient errnos do not occur), so callers retain
|
||||
* identical semantics on macOS/Linux while gaining resilience on Windows where
|
||||
* an antivirus scanner, indexer, or concurrent reader may briefly hold the
|
||||
* target open. Enforced by local/require-fs-op-fallback (ADR-1703 Phase 6).
|
||||
*/
|
||||
export function retryRenameSync(fromPath: string, toPath: string): void {
|
||||
const err = atomicRenameWithRetry(fromPath, toPath);
|
||||
if (err !== null) throw err;
|
||||
}
|
||||
|
||||
export function platformWriteSync(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): void {
|
||||
const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
|
||||
@@ -20,7 +20,7 @@ const { escapeRegex, normalizePhaseName, extractPhaseToken } = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserMod = require('./roadmap-parser.cjs');
|
||||
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
|
||||
import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir, planningPaths } = planningWorkspace;
|
||||
@@ -1903,7 +1903,7 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string {
|
||||
// we must NOT fall through to a delete — back off and retry the create.
|
||||
const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_stateStealSeq++);
|
||||
let renamed = false;
|
||||
try { fs.renameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ }
|
||||
try { retryRenameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ }
|
||||
if (renamed) {
|
||||
try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ }
|
||||
// Successful steal — retry immediately to grab the just-freed lock.
|
||||
|
||||
@@ -45,6 +45,9 @@ import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
|
||||
const { findInstallSourceRoot } = runtimeArtifactLayout;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
|
||||
const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan;
|
||||
|
||||
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
||||
|
||||
@@ -341,7 +344,7 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
|
||||
tempDirsToClean.push(rewritten);
|
||||
}
|
||||
}
|
||||
const dest = path.join(layout.configDir, kind.destSubpath);
|
||||
const dest = assertDestWithinConfigHome(layout.configDir, kind.destSubpath);
|
||||
_syncGsdDir(staged, dest, kind, skillManifest);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -23,7 +23,7 @@ const { toPosixPath, generateSlugInternal } = coreUtils;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParser = require('./roadmap-parser.cjs');
|
||||
const { getMilestoneInfo } = roadmapParser;
|
||||
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { platformWriteSync, platformEnsureDir, retryRenameSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningRoot, setActiveWorkstream, getActiveWorkstream } = planningWorkspace;
|
||||
@@ -92,13 +92,13 @@ function migrateToWorkstreams(cwd: string, workstreamName: string): MigrateResul
|
||||
const src = path.join(baseDir, item.name);
|
||||
if (fs.existsSync(src)) {
|
||||
const dest = path.join(wsDir, item.name);
|
||||
fs.renameSync(src, dest);
|
||||
retryRenameSync(src, dest);
|
||||
filesMoved.push(item.name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
for (const name of filesMoved) {
|
||||
try { fs.renameSync(path.join(wsDir, name), path.join(baseDir, name)); } catch { /* ignore */ }
|
||||
try { retryRenameSync(path.join(wsDir, name), path.join(baseDir, name)); } catch { /* ignore */ }
|
||||
}
|
||||
try { fs.rmSync(wsDir, { recursive: true }); } catch { /* ignore */ }
|
||||
try { fs.rmdirSync(path.join(baseDir, 'workstreams')); } catch { /* ignore */ }
|
||||
@@ -310,12 +310,12 @@ function cmdWorkstreamComplete(cwd: string, name: string | null | undefined, opt
|
||||
try {
|
||||
const entries = fs.readdirSync(wsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
fs.renameSync(path.join(wsDir, entry.name), path.join(archivePath, entry.name));
|
||||
retryRenameSync(path.join(wsDir, entry.name), path.join(archivePath, entry.name));
|
||||
filesMoved.push(entry.name);
|
||||
}
|
||||
} catch (err) {
|
||||
for (const fname of filesMoved) {
|
||||
try { fs.renameSync(path.join(archivePath, fname), path.join(wsDir, fname)); } catch { /* ignore */ }
|
||||
try { retryRenameSync(path.join(archivePath, fname), path.join(wsDir, fname)); } catch { /* ignore */ }
|
||||
}
|
||||
try { fs.rmSync(archivePath, { recursive: true }); } catch { /* ignore */ }
|
||||
if (active === name) setActiveWorkstream(cwd, name!);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('HDOC: anti-heredoc instruction', () => {
|
||||
for (const agent of ALL_AGENTS) {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, agent + '.md'), 'utf-8');
|
||||
// Match actual heredoc commands (not references in anti-heredoc instruction)
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Skip lines that are part of the anti-heredoc instruction or markdown code fences
|
||||
|
||||
@@ -32,7 +32,7 @@ const libDir = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
*/
|
||||
function findBareWrites(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const hits = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/\bfs\.writeFileSync\s*\(/.test(lines[i])) {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('commands/gsd/autonomous.md allowed-tools', () => {
|
||||
|
||||
// Parse the allowed-tools list items (lines starting with " - ")
|
||||
const toolLines = frontmatter
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /^\s+-\s+/.test(line))
|
||||
.map((line) => line.replace(/^\s+-\s+/, '').trim());
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user