chore(#2387): refactor CONTEXT.md legacy content + add glossary drift gate (#2391)

* chore(#2387): refactor CONTEXT.md legacy content + add glossary drift gate

Apply the audit-and-enforce concept from the ADR index (#2356) to CONTEXT.md:
correct stale facts, and add a CI gate so the machine-verifiable claims can't
silently re-rot.

CONTEXT.md was entirely hand-maintained with nothing checking its claims against
the shipped tree, so it had rotted. An audit against live code (Memtrace +
filesystem + gh), each finding adversarially re-verified, drove 38 factual
corrections + 1 surfaced by the new gate:

- Dead references: Package Identity named @opengsd/get-shit-done-redux (package
  is @opengsd/gsd-core); Shell Command Projection named run-git/run-npm/run-tool
  (real exports execGit/execNpm/execTool); a partial docs/adr/1606 ref; retired
  sdk/ framing.
- Superseded facts: allRuntimes 15 -> 17 (pi #2102, zcode); "seven nested-loader
  runtimes" -> five (claude reverted flat #924, antigravity flat); stacked-PR
  examples rebasing onto main -> next; QUOTA_SENTINELS precedence corrected to
  match src/agent-command-router.cts.
- Drifted CONTRIBUTING.md line citations refreshed.

Per CONTRIBUTING.md:179, only stale FACTS were corrected -- no maintainer intent,
lesson, or opinion was rewritten, and the append-only session log is untouched
except one dated in-place superseding note. The three tests that assert on
CONTEXT.md content (phase6-capstone-conformance, tracer-bullet,
external-job-waiting) keep all their anchors.

New scripts/check-glossary-refs.cjs (--check, wired into lint:generated-sync):
- Check A: every backticked file reference under a TRACKED_PREFIXES allowlist
  resolves on disk. Generated gsd-core/bin/lib/*.cjs (77 refs, gitignored),
  ~/-paths, .planning/, and bare filenames are deliberately skipped so a clean
  CI checkout never false-fails.
- Check B: the allRuntimes count + member set in the glossary prose match
  bin/install.js's allRuntimes literal (drifts on every runtime addition).
tests/check-glossary-refs.test.cjs covers both, including the false-positive
guard that a missing bin/lib/*.cjs ref does NOT trip the gate.

Closes #2387

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2387): confine glossary-gate file refs to ROOT (no `..` traversal)

Pre-PR security review finding (low): extractTrackedRefs fed tokens straight to
fs.existsSync(path.join(ROOT, token)), and PATH_TOKEN_RE admits `.` in a segment,
so a CONTEXT.md token like `src/../../../etc/passwd` passed the `src/` prefix
check and normalized to an out-of-tree absolute path — turning the doc lint into
a filesystem-existence oracle on the CI host (existsSync only; CONTEXT.md is a
trusted committed file, hence low severity, but a defense-in-depth gap).

Add isWithinRoot() confinement in extractTrackedRefs: a token is dropped unless
path.resolve(ROOT, token) stays within ROOT. A CONTEXT.md reference is always a
plain in-repo path, so a `..` escape is never legitimate. Regression test asserts
a `..`-bearing token is skipped and never named in output.

Refs #2387

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#2387): drop legacy `get-shit-done` name from a CONTEXT.md defect entry

CI lint-legacy-dir-name failed: the line-928 upstream-issue re-point I applied
wrote the historical provenance as "gsd-build/get-shit-done#3545", and
scripts/lint-legacy-dir-name.cjs forbids the legacy `get-shit-done` name. Reword
to "moved from #3545 in the predecessor repo" — same provenance, no legacy name.

Caught by `npm run lint:ci` (the CI lint chain), which I had not run locally —
lint:generated-sync + eslint do not include lint-legacy-dir-name.

Refs #2387

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-17 19:24:28 -04:00
committed by GitHub
parent 81f7ab4df1
commit f2c077df38
4 changed files with 453 additions and 41 deletions

View File

@@ -53,7 +53,7 @@ Module owning projection from dispatch results/errors to CLI `{ exitCode, stdout
Module owning STATE.md parse, field extraction, field replacement, status normalization, and frontmatter reconstruction. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and read-modify-write paths remain Adapters. Source of truth: `gsd-core/bin/lib/state-document.cjs`.
### STATE.md Transition Module
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` and the direct `writeStateMd` callers in `milestone.cts:352` and `phase.cts:1770`; verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call. Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. **ADR-1817 adds `rebuild` as the capstone 11th transition — the body-structure derivability contract.** Re-derives `## Current Position` prose from frontmatter and `## By-Phase Progress` table from phase dirs on disk; preserves `## Session` / `## Decisions` / unknown sections verbatim; de-duplicates `## Session Continuity Archive` (keep most-recent N, default 3); appends a structured audit entry to `## Rebuild Log` (`timestamp`, `kind`, `section`, `before`, `after`, `reason`) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with `sync` (3 lightweight frontmatter fields, auto-triggered) and orthogonal to `auto_prune_state` (age-based removal) — `rebuild` reconciles with current canonical sources, `prune` removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = `rebuildCore` body + `rebuild` dispatch case + drift-class unit tests (#1827), Phase 2 = `cmdStateRebuild` CLI + `--dry-run`/`--verbose` + integration tests + docs + changeset (#1826). Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`).
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` and the direct `writeStateMd` caller in `milestone.cts:552` (phase.cts's former direct caller has since been migrated away); verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call (`verify.cts:1925`). Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. **ADR-1817 adds `rebuild` as the capstone 11th transition — the body-structure derivability contract.** Re-derives `## Current Position` prose from frontmatter and `## By-Phase Progress` table from phase dirs on disk; preserves `## Session` / `## Decisions` / unknown sections verbatim; de-duplicates `## Session Continuity Archive` (keep most-recent N, default 3); appends a structured audit entry to `## Rebuild Log` (`timestamp`, `kind`, `section`, `before`, `after`, `reason`) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with `sync` (3 lightweight frontmatter fields, auto-triggered) and orthogonal to `auto_prune_state` (age-based removal) — `rebuild` reconciles with current canonical sources, `prune` removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = `rebuildCore` body + `rebuild` dispatch case + drift-class unit tests (#1827), Phase 2 = `cmdStateRebuild` CLI + `--dry-run`/`--verbose` + integration tests + docs + changeset (#1826). Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`).
### STATE.md Status Lifecycle (ADR-2207)
The `Status` field in STATE.md follows a strict lifecycle: `Ready to plan` → `All phases complete` (all phases done, milestone awaiting formal close) → `<version> milestone complete` (terminal, written only by the milestone-close verb `milestoneCompleteCore`) → `Awaiting next milestone` (archived). Phase-completion verbs write `All phases complete` on the last phase — never `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). `normalizeStateStatus` maps any status containing "complete" → `completed`, so consumers using the normalized projection (workstream inventory's `status` field, statusline) recognize `All phases complete` without code changes. Note: `isCompletedInventory` (workstream-inventory-builder.cts) intentionally checks only for the terminal `\bmilestone\s+complete\b` / `\barchived\b` — `All phases complete` returns `false` (intermediate, not terminal).
@@ -140,7 +140,7 @@ Module owning the layout-driven runtime-artifact install pipeline — `installRu
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.
### Installer Module
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, hermes, kilo, kimi, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). The same module exposes `detectAntigravityDirAmbiguity(opts)` — a side-effect-free probe reporting whether multiple `~/.gemini/antigravity{,-ide,-cli}` dirs coexist and which one GSD's `gsd-core/VERSION` marker (the `dot-home-nested` `probeExists`) resolves to, for installer / `/gsd-update` operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Seven runtimes with non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). The remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` layout unchanged. See Skill Surface Budget Module and Runtime Artifact Layout Module.
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (17 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, hermes, kimi, kilo, opencode, pi, qwen, trae, windsurf, zcode). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). The same module exposes `detectAntigravityDirAmbiguity(opts)` — a side-effect-free probe reporting whether multiple `~/.gemini/antigravity{,-ide,-cli}` dirs coexist and which one GSD's `gsd-core/VERSION` marker (the `dot-home-nested` `probeExists`) resolves to, for installer / `/gsd-update` operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Five runtimes with non-recursive skill loaders (cline, qwen, hermes, augment, trae) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). claude (reverted from nested per #924 — the Skill tool errors on unrouted names) and antigravity (one-level scan, but concrete skills must be top-level discoverable) plus the remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
### I/O Module
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
@@ -170,7 +170,7 @@ Module owning project configuration loading: reads `.planning/config.json`, merg
Module owning model and effort resolution policy: resolves the model, runtime tier, planning granularity, reasoning effort, and fast-mode for a given agent by reading project config and resolving against the model profiles and catalog (`resolveModelInternal`, `resolveModelPolicy`, `resolveTierEntry`, `resolveModelForTier`, `resolveGranularityInternal`, `resolveEffortInternal`, `resolveFastModeInternal`, `resolveEffortForTier`, `nextEffort`, `assertValidGranularityOverride`). Depends only on leaf modules (`config-loader` for `loadConfig`, `configuration` for defaults, `model-profiles` and `model-catalog` for the static tables) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2f (#888) — the final core.cts decomposition step; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/model-resolver.cjs` (generated from `src/model-resolver.cts`).
### Package Identity Module [Planned]
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/gsd-core`), `binName` (`Object.keys(.bin)[0]` → `gsd-core`), `repoSlug` (parsed from `.repository.url` → `open-gsd/gsd-core`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.
### Update Context Module [Planned]
Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home, cwd, env, fs, preferredConfigDir, preferredRuntime })` is a pure, injected-fs port of update.md's former ~280-line `get_installed_version` bash; it reproduces the full precedence cascade — preferred-config-dir fast path, local-over-global probe with same-path dedup, env-var overrides (`CLAUDE_CONFIG_DIR`, `OPENCODE_CONFIG`, `KILO_CONFIG`, `XDG_CONFIG_HOME`, `CODEX_HOME`, …), and semver validation — and returns the 4-field contract `{ installedVersion, scope, runtime, gsdDir }` (scope ∈ `LOCAL`/`GLOBAL`/`UNKNOWN`). Antigravity is modelled first-class (its `.gemini/antigravity{,-ide,-cli}` dirs probe before bare `.gemini`; #3608). Exposed to the workflow as `gsd-tools update-context [--config-dir <d>] [--runtime <r>] --json`; `loadUpdateContext` wires the real fs. The workflow keeps only the execution_context path → `PREFERRED_*` derivation (the one input it alone knows). Source: `gsd-core/bin/lib/update-context.cjs`; tests: `tests/issue-498-update-context.test.cjs`. See Installer Module and Package Identity Module.
@@ -401,7 +401,7 @@ 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.)
> 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-prohibition-enforcement-verify-seam.md` were co-delivered sibling PRs of epic #1605, now closed and landed; the predicate refs to them below are live.)
`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)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)`
@@ -429,8 +429,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
## 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`
`RULESET.TESTS.no-source-grep.stdout-extension=also flags assert.match/doesNotMatch on .stdout/.stderr — emit JSON from SUT, parse, assert on typed fields`
`RULESET.TESTS.no-source-grep=local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)`
`RULESET.TESTS.no-source-grep.exemption=// allow-test-rule: <runtime-contract-is-the-product> with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.`
`RULESET.TESTS.no-source-grep.tmp-file-traps=reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()`
@@ -444,12 +443,12 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`RULESET.TESTS.boundary-coverage.anti-pattern=test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)`
`LEARNING.prompt-budget.boundary-gap=PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures`
`RULESET.TESTS.no-timing-assertion=do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule (warn → error after #453); canonical replacement: clock-seam pattern with node:test mock.timers`
`RULESET.TESTS.no-timing-assertion=do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, currently warn (promotion to error tracked under open epic #1885, not #453 which already merged without completing it); canonical replacement: clock-seam pattern with node:test mock.timers`
`RULESET.TESTS.clock-seam=concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS=<epoch-ms> in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)`
`RULESET.TESTS.property-based-testing=modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge`
`RULESET.TESTS.mutation-score=Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification`
`RULESET.TESTS.delete-bad-tests=pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern`
`RULESET.TESTS.eslint-harness=ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at scripts/eslint-rules/; replaces scripts/lint-*.cjs regex scanners; three test-rigor rules (local/no-source-grep, local/no-magic-sleep-in-tests, local/no-elapsed-assertion) ship at warn, promoted to error after #453 cleanup sweep merges`
`RULESET.TESTS.eslint-harness=ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); of the three test-rigor rules, local/no-source-grep and local/no-magic-sleep-in-tests are already promoted to error in tests/**/*.test.cjs scope (post-cleanup), local/no-elapsed-assertion remains at warn pending open epic #1885 (its dedicated ratchet issue #453 already merged without completing this promotion; follow-up #1888 was closed not-planned and folded into #1885)`
`RULESET.AUDIT.search-source-not-generated=verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false "5e ConverterName unenforced"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep`
@@ -458,14 +457,14 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`RULESET.AGENT_SIZE_BUDGET=agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = per-file baseline (PRIMARY anti-creep: tests/agent-size-baseline.json pins each agents/gsd-*.md exact byte size) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). One 'npm run size:baseline' regenerates BOTH workflow and agent baselines via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter. A grown agent fails the baseline guard — regenerate + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes`
`RULESET.WORKFLOW_FILE_NAMES=workflow files use hyphens; <step name="..."> XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name`
`RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \`bug-3135-capture-backlog-workflow\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill`
`RULESET.WORKFLOW_EXECUTE_END_TO_END=ADR-0002 standard for single-workflow commands is "Execute end-to-end." (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets`
`RULESET.WORKFLOW_EXECUTE_END_TO_END=standard for single-workflow commands is "Execute end-to-end." (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)`
`RULESET.WORKFLOW.COVERAGE-METADATA=#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary <f>` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task <verify> results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch`
`RULESET.ALLOWED-TOOLS-FRONTMATTER=command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss`
`RULESET.ARGUMENTS-SANITIZE=any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\, max-length) — "(already sanitized)" must trace back to explicit guard; RESUME/fallback modes need own guards`
`RULESET.SHARED-HELPERS-LINT-VS-TEST=when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise`
`RULESET.ADR-HEADER=every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Deprecated + - **Date:** YYYY-MM-DD immediately after title`
`RULESET.ADR-HEADER=every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title`
`RULESET.MANIFEST-CANONICAL-KEY=docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL SIX families.* arrays (agents/commands/workflows/references/cli_modules/hooks) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all six, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write`
`RULESET.PR-SCOPE.one-concern-per-pr=split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit`
@@ -479,7 +478,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
## CodeRabbit + repo-process guards (machine-oriented predicates)
`RULESET.CONTRIB.GATE.ORDER=issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog`
`RULESET.CONTRIB.CLASSIFY.fix=requires confirmed/confirmed-bug before implementation`
`RULESET.CONTRIB.CLASSIFY.fix=requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)`
`RULESET.CONTRIB.CLASSIFY.enhancement=requires approved-enhancement before implementation`
`RULESET.CONTRIB.CLASSIFY.feature=requires approved-feature before implementation`
@@ -511,7 +510,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`WORKSTREAM.INVARIANT.slug-contract=all .planning/workstreams/<name> must be addressable by set/get/status/complete`
`WORKSTREAM.REGRESSION.test-anchor=tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug`
`ARCH.SKILL.improve-codebase.next-candidates=[Workstream Name Policy Module, Workstream Progress Projection Module, Active Workstream Pointer Store Module]`
`ARCH.SKILL.improve-codebase.next-candidates=[Workstream Progress Projection Module]`
`WORKTREE.SEAM.test-policy=cover all decision branches in policy module before changing prune behavior`
`WORKTREE.SEAM.test-anchors=[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]`
@@ -531,7 +530,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
## Release notes standard
`RELEASE-NOTES.SCOPE=GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rcN; not CHANGELOG.md (changeset workflow owns that)`
`RELEASE-NOTES.SCOPE=GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)`
`RELEASE-NOTES.DEFAULT-STATE=auto-generated body is "What's Changed" PR list + Full Changelog link; treat as draft, not final`
`RELEASE-NOTES.GATE.hotfix=manual edit required; auto-generated body for vX.Y.{Z>0} is "Full Changelog only" and must be replaced with structured body`
`RELEASE-NOTES.GATE.rc=manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard`
@@ -553,15 +552,15 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`RELEASE-NOTES.WORKFLOW.edit=gh release edit <tag> --notes-file <path>`
`RELEASE-NOTES.WORKFLOW.view=gh release view <tag> --json body --jq .body`
`RELEASE-NOTES.WORKFLOW.token=must use .envrc GITHUB_TOKEN per project CLAUDE.md; never ambient gh auth`
`RELEASE-NOTES.WORKFLOW.token=must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth`
`RELEASE-NOTES.WORKFLOW.idempotency=gh release edit overwrites body wholesale; safe to re-run after refining`
`RELEASE-NOTES.ANTI-PATTERN=raw "What's Changed" PR list as final body for hotfix or feature release; "Full Changelog only" body for tagged release with >0 user-facing fixes`
`RELEASE-NOTES.ANTI-PATTERN.implementation-first=do not lead bullet with file path or function name; lead with symptom/user-visible behavior`
`RELEASE-NOTES.ANTI-PATTERN.risk-commentary=do not include "may break", "be careful", "test thoroughly" - per global CLAUDE.md no-risk-commentary rule`
`RELEASE-NOTES.ANTI-PATTERN.risk-commentary=do not include "may break", "be careful", "test thoroughly" - release notes state what changed, not hedges about what might go wrong`
`RELEASE-NOTES.EXAMPLE.hotfix=v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups`
`RELEASE-NOTES.EXAMPLE.rc=v1.42.0-rc1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.42.0-rc1) - intro + Added/Changed/Fixed/Documentation taxonomy`
`RELEASE-NOTES.EXAMPLE.rc=v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy`
`RELEASE-NOTES.EXAMPLE.minor-auto-acceptable=v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles`
`RELEASE-NOTES.TEMPLATE.hotfix=## Fixed\n\n### <subgroup>\n- **<bold change>** — <explanation>. (#<PR>)\n\n---\n\nInstall/upgrade: \`npx @opengsd/gsd-core@latest\`\n\n**Full Changelog**: <compare-url>`
@@ -580,14 +579,14 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`META.RULE.brief-no-paraphrase=writing "k040 — never leave changelog box unchecked" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110`
`PRED.k320.signal=changelog-direct-edit-forbidden`
`PRED.k320.canonical-source=CONTRIBUTING.md L110-123`
`PRED.k320.canonical-source=CONTRIBUTING.md L193-211`
`PRED.k320.rule=do not edit CHANGELOG.md in feature/fix/enhancement PRs`
`PRED.k320.cure=drop .changeset/<adj>-<noun>-<noun>.md fragment ONLY`
`PRED.k320.tool=npm run changeset -- --type <T> --pr <NNN> --body "..."`
`PRED.k320.types=Added|Changed|Deprecated|Removed|Fixed|Security`
`PRED.k320.opt-out-label=no-changelog`
`PRED.k320.ci-enforcement=scripts/changeset/lint.cjs`
`PRED.k320.ci-paths-monitored=bin/ gsd-core/ agents/ commands/ docs/ hooks/ tests/ scripts/`
`PRED.k320.ci-paths-monitored=bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/`
`PRED.k320.recovery=open Removed-typed cleanup PR deleting only the redundant row`
`PRED.k320.evidence=PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09`
@@ -637,12 +636,12 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`PRED.k327.cooldown-throttled=k322`
`PRED.k328.signal=pr-template-typed-heading-required`
`PRED.k328.canonical-source=CONTRIBUTING.md L101`
`PRED.k328.canonical-source=CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)`
`PRED.k328.k100-restatement=heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR`
`PRED.k328.audit-list=[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]`
`PRED.k329.signal=changeset-fragment-canonical-shape`
`PRED.k329.canonical-source=CONTRIBUTING.md L112-117 + .changeset/README.md`
`PRED.k329.canonical-source=CONTRIBUTING.md L196-202 + .changeset/README.md`
`PRED.k329.filename=.changeset/<adj>-<noun>-<noun>.md`
`PRED.k329.frontmatter=---\\ntype: <Added|Changed|Deprecated|Removed|Fixed|Security>\\npr: <NNN>\\n---`
`PRED.k329.body=**<Bold user-visible change>** — <symptom-led explanation>. (#<NNN>)`
@@ -671,7 +670,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
## Triage and merge-wave lessons
`WAVE.LESSON.changelog-policy-violation-multiplier=brief contradicting CONTRIBUTING.md L110 produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture`
`WAVE.LESSON.changelog-policy-violation-multiplier=brief contradicting CONTRIBUTING.md's changelog-fragment policy ("CHANGELOG Entries — Drop a Fragment" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture`
`WAVE.LESSON.cr-throttle-burst-correlation=8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)`
`WAVE.LESSON.sibling-audit-overlap=k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap`
`WAVE.LESSON.agent-narrative-unreliable=k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification`
@@ -692,13 +691,13 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`DEFECT.STATE-TRAMPLE.symptom=state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter`
`DEFECT.STATE-TRAMPLE.examples=#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)`
`DEFECT.STATE-TRAMPLE.detect=any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress`
`DEFECT.STATE-TRAMPLE.fix-forward=route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316 SDK-first seams)`
`DEFECT.STATE-TRAMPLE.fix-forward=route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)`
`DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom=multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces`
`DEFECT.PHASE-DIR-PREFIX-DRIFT.examples=#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)`
`DEFECT.PHASE-DIR-PREFIX-DRIFT.detect=grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting`
`DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward=consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps`
`DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor=tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs (broad regression across workflow surfaces)`
`DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor=tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)`
`DEFECT.STACKED-PR-AUTO-RETARGET.symptom=PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts`
`DEFECT.STACKED-PR-AUTO-RETARGET.examples=#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged`
@@ -726,12 +725,12 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`DEFECT.INVENTORY-DRIFT.fix-forward=update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all six families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY)`
`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom=adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold`
`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state=gsd-planner.md is already 49,121 chars on main (over 45K); test fails on main; net-new content makes it strictly worse`
`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state=gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says "45K" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over`
`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect=tests/planner-decomposition.test.cjs ("planner is under 45K chars (proves mode sections were extracted)") and tests/reachability-check.test.cjs ("file stays under 50000 char limit")`
`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward=mirror MVP mode pattern — extract full rules to gsd-core/references/planner-<mode>.md, leave a slim Detection section in the agent file with @-reference to the new file`
`DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom=.changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number`
`DEFECT.CHANGESET-PR-FIELD-DRIFT.examples=#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); already covered in CONTEXT.md L94 + L186 but recurs every cycle`
`DEFECT.CHANGESET-PR-FIELD-DRIFT.examples=#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle`
`DEFECT.CHANGESET-PR-FIELD-DRIFT.detect=changeset pr: value mismatches the actual PR number returned by gh api POST /pulls`
`DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward=author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess`
@@ -770,8 +769,8 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect=any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts`
`DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward=template — "new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set <key> <old-value>"`
`DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom=new test file uses readFileSync + .includes() / .match() against source code (CONTEXT.md L82); contradicts the test rule lint script`
`DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect=scripts/lint-no-source-grep.cjs (npm run lint:tests) fails with line-number-precise violation`
`DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom=new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script`
`DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect=npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation`
`DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward=replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification`
`DEFECT.GENERATIVE-PRIORITY=these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer`
@@ -779,7 +778,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
`DEFECT.GENERATIVE-EXEMPLAR=tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)`
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom=a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep "^key:" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted`
`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.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; execute-phase.md has since been fixed to the frontmatter-scoped form (#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 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`
@@ -830,16 +829,16 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
## Shell Command Projection Module (expanded glossary entry, 2026-05-13)
Module owning all OS-facing I/O for the tool: runtime-aware command-text rendering (hook commands, PATH action lines, shim scripts), subprocess dispatch (run-git, run-npm, run-tool, probeTty), and platform file I/O (platformWriteSync, platformReadSync, platformEnsureDir). Single seam for platform-conditional logic — one place to fix any shell or file write regression across Windows, macOS, and Linux. Lives in `gsd-core/bin/lib/shell-command-projection.cjs`. See ADR-0009 (superseded "does not execute" constraint) and ADR-0010 (superseded File Operation Engine).
Module owning all OS-facing I/O for the tool: runtime-aware command-text rendering (hook commands, PATH action lines, shim scripts), subprocess dispatch (execGit, execNpm, execTool, probeTty), and platform file I/O (platformWriteSync, platformReadSync, platformEnsureDir). Single seam for platform-conditional logic — one place to fix any shell or file write regression across Windows, macOS, and Linux. Lives in `gsd-core/bin/lib/shell-command-projection.cjs`. See ADR-0009 (superseded "does not execute" constraint) and ADR-0010 (superseded File Operation Engine).
Invariants:
- Result shape: all run-* return `{ exitCode, stdout, stderr }`; never throw on non-zero exit code.
- Platform policy owned at the seam: `shell: process.platform === 'win32'` lives only in run-npm; probeTty returns `null` on Windows.
- Result shape: all exec* functions return `{ exitCode, stdout, stderr }`; never throw on non-zero exit code.
- Platform policy owned at the seam: `shell: process.platform === 'win32'` lives only in execNpm; probeTty returns `null` on Windows.
- Normalization policy: platformWriteSync owns full `normalizeMd` for `.md`; CRLF-to-LF + trailing newline for all others; callers must NOT pre-call `normalizeMd`.
- `_normalizeMd` is re-implemented inline (not imported from `core.cjs`) to avoid circular dep.
- `atomicWriteFileSync`, `safeReadFile`, `normalizeMd` were in `core.cjs` exports (retired in epic #1267); callers now import these from their respective leaf modules directly.
- `atomicWriteFileSync`, `safeReadFile`, `normalizeMd` were duplicated wrappers in `core.cjs`, removed from there in this module's own Migration Phase 4 (`#3468`, see ADR-0009/ADR-0010) — a separate effort from epic #1267 (the core.cjs re-export-spine retirement); callers now import `platformWriteSync`/`platformReadSync`/`normalizeContent` from this module directly.
Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets 6 subprocess files; Phase 3 (#3467) targets 15 fs files (215 call sites); Phase 4 (#3468) removes compat exports.
Migration: Phases 1-4 (#3465-#3468) shipped 2026-05-13 — seam additions, subprocess-dispatch migration (6 files), fs migration (15 files / 215 call sites), and core.cjs compat-export removal are all complete; see ADR-0009's 2026-05-13 Update section.
---
@@ -898,7 +897,7 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
`RULESET.HARNESS.test-memory-guard=~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM`
`RULESET.PR-FLOW.docker-before-push=before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — "we don't set a timer we actively watch and record results in real time as possible"`
`RULESET.PR-FLOW.docker-before-push=before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — "we don't set a timer we actively watch and record results in real time as possible". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:"passed" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.`
`RULESET.PR-FLOW.templates-mandatory=every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — "the whole reason i have that github action is because you fucking blow through and ignore using the templates"`
@@ -909,7 +908,7 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
`EXEC.CLASSIFY.handler=gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)`
`EXEC.CLASSIFY.workflow=gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)`
`EXEC.CLASSIFY.classes={class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}`
`EXEC.CLASSIFY.sentinel-order=most specific first: 429 beats too-many-requests; quota beats resource_exhausted; case-insensitive; canonical sentinel value is lower-cased form`
`EXEC.CLASSIFY.sentinel-order=most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form`
`EXEC.CLASSIFY.cross-runtime=Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests`
`EXEC.CLASSIFY.precedence=quota sentinel wins over classifyHandoffIfNeeded bug when both appear`
`EXEC.CLASSIFY.retry-after-parser=\bretry[-_ ]after[:\s]+(\d+)\b avoids embedded-word false matches like noretry-after`
@@ -926,11 +925,11 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause=gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()`
`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect=two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted`
`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward=set per-invocation LOCAL_OUT=/tmp/gsd-test-<tag>-local.jsonl DOCKER_OUT=/tmp/gsd-test-<tag>-docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)`
`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream=open-gsd/gsd-core#3545`
`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream=open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)`
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom=spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness "you will be notified" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator`
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect=sub-agent returns prematurely with text like "I should wait for the notification per CLAUDE.md" and incomplete work in its worktree (commits absent, push absent, PR absent)`
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward=keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task`
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor=project CLAUDE.md "Top-level orchestrator (cross-turn notifications available) vs Sub-agent worker (no cross-turn notifications)" guidance — load-bearing for multi-worktree parallel fix dispatch`
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor=lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom=sub-agent writes /gsd-<cmd> (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples=#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)`
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect=tests/slash-command-namespace.test.cjs prints "Found N retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead" with line-number-precise violations`
@@ -992,13 +991,13 @@ Full detail in `~/.claude/skills/gsd-pr-fix-discipline/SKILL.md`. AI agents MUST
- **Symptom:** After base PR squash-merges, stacked PR shows conflicts or wrong diff; GitHub auto-retarget fails
- **Affected this session:** #158 stacked on #156
- **Fix:** `git rebase --onto main <old-base> <stacked-branch>` then force-push and `gh pr edit --base main`
- **Fix:** `git rebase --onto next <old-base> <stacked-branch>` then force-push and `gh pr edit --base next` (repo's integration branch as of the `next`-branch model introduced 2026-05-24; was `main` before that)
### `tee` pipe swallowing exit codes
- **Symptom:** `gsd-test-summary --both 2>&1 | tee /tmp/log` returns `0` even when Docker reports failures
- **Symptom:** piping any test-runner output through `tee` (e.g. `... 2>&1 | tee /tmp/log`) returns `0` even when the run reports failures — the pipeline exits on `tee`'s status, not the runner's
- **Affected this session:** Session-wide risk
- **Fix:** Run un-piped, or `set -o pipefail` before the pipe
- **Fix:** Run `gsd-test` (classic executor) UNPIPED per CLAUDE.md, or `set -o pipefail` before any pipe. Do not use the deprecated `gsd-test-summary` / `gsd-test-both` wrappers at all (see DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION / DEFECT.GSD-TEST-MIRROR-POISONED).
### Auto-merge disabled

View File

@@ -109,7 +109,7 @@
"lint:test-file-count": "node scripts/lint-test-file-count.cjs",
"lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
"lint:changeset": "node scripts/changeset/lint.cjs",
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check",
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check",
"lint:docs": "node scripts/lint-docs-required.cjs",
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
"ci:test-scope": "node scripts/ci-test-scope.cjs",

View File

@@ -0,0 +1,220 @@
#!/usr/bin/env node
'use strict';
/**
* Verifies the machine-checkable claims in CONTEXT.md against the shipped
* tree, so the glossary cannot silently re-rot the way the ADR index did
* before scripts/gen-adr-index.cjs (#2340).
*
* Unlike gen-adr-index.cjs this gate has NO `--write` — CONTEXT.md's prose is
* hand-authored, not a derived artifact this tool can regenerate. It only
* verifies.
*
* Two checks:
*
* A. File references resolve. Every backticked token in CONTEXT.md that
* looks like a file path — and whose path is inside a TRACKED_PREFIXES
* directory (or is one of the two named exact files) — must exist on
* disk. Everything else (generated bin/lib/*.cjs, `.planning/` runtime
* artifacts, `~/`- or `/`-rooted paths, bare filenames, example data
* shapes like `capability.json`) is deliberately ignored: asserting
* those would false-fail a clean checkout, which is the exact trap this
* gate exists to avoid falling into itself.
*
* B. `allRuntimes` enum parity. CONTEXT.md documents the runtime enum's
* count and member list in prose (e.g. "Runtime enum: `allRuntimes` (17
* values: claude, ...)"). This is compared against the real
* `allRuntimes` array literal in bin/install.js — both the count and the
* member set — so adding/removing a runtime without updating the prose
* is caught.
*
* Usage:
* node scripts/check-glossary-refs.cjs # print findings to stdout
* node scripts/check-glossary-refs.cjs --check # exit 1 on any finding
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..');
const CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md');
const INSTALL_JS_PATH = path.join(ROOT, 'bin', 'install.js');
/**
* Directory prefixes this gate can verify against the shipped tree. A token
* outside these — most importantly `gsd-core/bin/lib/**`, which is generated
* and gitignored — is not a claim this gate can check, so it is skipped
* rather than asserted.
*/
const TRACKED_PREFIXES = [
'src/',
'tests/',
'scripts/',
'docs/',
'gsd-core/references/',
'gsd-core/workflows/',
'gsd-core/templates/',
'gsd-core/contexts/',
'.github/',
'eslint-rules/',
];
/** The only bare (no-prefix-match) tokens this gate checks by exact name. */
const TRACKED_EXACT = new Set(['bin/install.js', 'package.json']);
/**
* Shape a backticked token must have to even be considered a path candidate:
* one or more `/`-separated segments of word/dot/dash characters, with an
* optional trailing `:<line>` suffix. Anything else inside backticks (CLI
* invocations with spaces, function signatures with parens/commas, bare
* identifiers, env vars) is prose, not a path reference.
*/
const PATH_TOKEN_RE = /^[\w.-]+(?:\/[\w.-]+)*(?::\d+)?$/;
/** Whether `token` (line-suffix already stripped) is one this gate checks. */
function isTracked(token) {
return TRACKED_EXACT.has(token) || TRACKED_PREFIXES.some((prefix) => token.startsWith(prefix));
}
/**
* True if joining `token` to ROOT stays inside ROOT. `PATH_TOKEN_RE` admits `.`
* inside a segment, so a token like `src/../../../etc/passwd` matches and (via
* the `src/` prefix) reads as "tracked" — `path.join(ROOT, token)` would then
* normalize to an out-of-tree absolute path and `fs.existsSync` would probe it,
* turning a doc lint into a filesystem-existence oracle on the CI host. A
* CONTEXT.md reference is always a plain in-repo path, so a `..` escape is never
* legitimate: confine to ROOT and drop anything that climbs out.
*/
function isWithinRoot(token) {
const resolved = path.resolve(ROOT, token);
return resolved === ROOT || resolved.startsWith(ROOT + path.sep);
}
/**
* Every distinct, trackable file-path token referenced in `text`, with any
* trailing `:<line>` suffix stripped.
*/
function extractTrackedRefs(text) {
const tokens = new Set();
const re = /`([^`]+)`/g;
let m;
while ((m = re.exec(text)) !== null) {
const raw = m[1];
if (!PATH_TOKEN_RE.test(raw)) continue;
const token = raw.replace(/:\d+$/, '');
if (!isTracked(token)) continue;
if (!isWithinRoot(token)) continue;
tokens.add(token);
}
return tokens;
}
/** Check A: every tracked reference must resolve on disk. */
function checkFileRefs(contextText) {
const tokens = [...extractTrackedRefs(contextText)].sort();
const findings = [];
for (const token of tokens) {
if (!fs.existsSync(path.join(ROOT, token))) {
findings.push(`CONTEXT.md references \`${token}\` which does not exist in the repo.`);
}
}
return { findings, checked: tokens.length };
}
/** The glossary's own claim: `Runtime enum: `allRuntimes` (N values: a, b, c)`. */
const ALLRUNTIMES_CLAIM_RE = /Runtime enum:\s*`allRuntimes`\s*\((\d+)\s*values:\s*([^)]*)\)/;
function parseClaimedRuntimes(contextText) {
const m = contextText.match(ALLRUNTIMES_CLAIM_RE);
if (!m) return null;
return {
count: Number(m[1]),
members: m[2]
.split(',')
.map((s) => s.trim())
.filter(Boolean),
};
}
/** The real `allRuntimes = [...]` array literal in bin/install.js. */
const ALLRUNTIMES_ARRAY_RE = /allRuntimes\s*=\s*\[([^\]]*)\]/;
function parseRealRuntimes(installJsText) {
const m = installJsText.match(ALLRUNTIMES_ARRAY_RE);
if (!m) return null;
return [...m[1].matchAll(/'([^']+)'/g)].map((mm) => mm[1]);
}
/** Check B: CONTEXT.md's prose count + member set must match bin/install.js. */
function checkAllRuntimesParity(contextText, installJsText) {
const claimed = parseClaimedRuntimes(contextText);
if (!claimed) {
return [
'CONTEXT.md is missing the `allRuntimes` enum-count sentence ' +
'("Runtime enum: `allRuntimes` (N values: ...)") that this gate checks against bin/install.js.',
];
}
const real = parseRealRuntimes(installJsText);
if (!real) {
return ['bin/install.js does not contain a parseable `allRuntimes = [...]` array literal.'];
}
const findings = [];
if (claimed.count !== real.length) {
findings.push(
`CONTEXT.md's allRuntimes enum-count sentence claims ${claimed.count} values but bin/install.js's ` +
`allRuntimes array has ${real.length}.`,
);
}
const claimedSet = new Set(claimed.members);
const realSet = new Set(real);
const missingFromProse = real.filter((r) => !claimedSet.has(r)).sort();
const noLongerReal = claimed.members.filter((c) => !realSet.has(c)).sort();
if (missingFromProse.length > 0 || noLongerReal.length > 0) {
const parts = [];
if (missingFromProse.length > 0) parts.push(`missing from CONTEXT.md's list: ${missingFromProse.join(', ')}`);
if (noLongerReal.length > 0) parts.push(`no longer in bin/install.js's allRuntimes: ${noLongerReal.join(', ')}`);
findings.push(`CONTEXT.md's allRuntimes member list has drifted from bin/install.js (${parts.join('; ')}).`);
}
return findings;
}
function main() {
const [, , flag] = process.argv;
const contextText = fs.readFileSync(CONTEXT_PATH, 'utf8');
const installJsText = fs.readFileSync(INSTALL_JS_PATH, 'utf8');
const fileRefs = checkFileRefs(contextText);
const runtimeFindings = checkAllRuntimesParity(contextText, installJsText);
const findings = [...fileRefs.findings, ...runtimeFindings];
if (flag === '--check') {
if (findings.length > 0) {
process.stderr.write(`CONTEXT.md glossary has ${findings.length} drift finding(s).\n\n`);
for (const f of findings) process.stderr.write(` ✗ ${f}\n`);
process.stderr.write('\n');
throw new ExitError(1);
}
process.stdout.write(
`CONTEXT.md glossary references are current (${fileRefs.checked} refs checked, allRuntimes parity ok).\n`,
);
return;
}
if (findings.length === 0) {
process.stdout.write(
`CONTEXT.md glossary references are current (${fileRefs.checked} refs checked, allRuntimes parity ok).\n`,
);
} else {
process.stdout.write(`CONTEXT.md glossary has ${findings.length} drift finding(s):\n\n`);
for (const f of findings) process.stdout.write(` ✗ ${f}\n`);
}
}
runMain(main);

View File

@@ -0,0 +1,193 @@
'use strict';
/**
* Behavioral tests for scripts/check-glossary-refs.cjs — the CONTEXT.md
* glossary drift gate (#2387).
*
* These drive the real CLI as a subprocess against synthetic CONTEXT.md /
* bin/install.js fixtures in a temp dir, asserting on exit code and emitted
* text. No source-grepping: the runtime behavior is the contract.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const SCRIPT_REL = path.join('scripts', 'check-glossary-refs.cjs');
// The real bin/install.js allRuntimes array, mirrored here so fixtures can
// build both a matching and a deliberately-drifted CONTEXT.md against it.
const REAL_RUNTIMES = [
'claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot',
'cursor', 'hermes', 'kimi', 'kilo', 'opencode', 'pi', 'qwen', 'trae', 'windsurf', 'zcode',
];
function allRuntimesSentence(count, members) {
return `Runtime enum: \`allRuntimes\` (${count} values: ${members.join(', ')})`;
}
/**
* Build a throwaway repo containing exactly what the gate reads: CONTEXT.md,
* bin/install.js, a real src/ file a clean fixture can legitimately reference,
* and a copy of the gate + its cli-exit dependency. A unique mkdtemp per call
* keeps parallel tests from colliding, and the dir is removed via `t.after()`
* so a failing assertion cannot leak it.
*/
function makeRepo(t, { contextBody, runtimes = REAL_RUNTIMES }) {
const root = createTempDir('gsd-glossary-refs-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'scripts', 'lib'), { recursive: true });
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
fs.copyFileSync(path.join(REPO_ROOT, SCRIPT_REL), path.join(root, SCRIPT_REL));
fs.copyFileSync(
path.join(REPO_ROOT, 'scripts', 'lib', 'cli-exit.cjs'),
path.join(root, 'scripts', 'lib', 'cli-exit.cjs'),
);
fs.writeFileSync(path.join(root, 'CONTEXT.md'), contextBody);
fs.writeFileSync(
path.join(root, 'bin', 'install.js'),
`'use strict';\nconst allRuntimes = [${runtimes.map((r) => `'${r}'`).join(', ')}];\nmodule.exports = { allRuntimes };\n`,
);
// A real file a clean CONTEXT.md fixture can legitimately reference.
fs.writeFileSync(path.join(root, 'src', 'real-module.cts'), '// fixture\n');
return root;
}
/** Run the gate in `root`; never throws — returns {status, stdout, stderr}. */
function run(root, args = []) {
const res = spawnSync(process.execPath, [path.join(root, SCRIPT_REL), ...args], {
cwd: root,
encoding: 'utf8',
timeout: 30_000,
});
if (res.error) throw res.error;
return { status: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
}
test('a clean CONTEXT.md whose refs resolve and whose allRuntimes matches passes --check', (t) => {
const context = [
'# Context',
'',
'See `src/real-module.cts` for details.',
'',
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
'',
].join('\n');
const root = makeRepo(t, { contextBody: context });
const res = run(root, ['--check']);
assert.equal(res.status, 0, `expected a clean pass: ${res.stderr}`);
assert.match(res.stdout, /glossary references are current/);
});
test('a reference to a nonexistent tracked file fails --check and names the token', (t) => {
const context = [
'# Context',
'',
'See `src/does-not-exist.cts` for details.',
'',
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
'',
].join('\n');
const root = makeRepo(t, { contextBody: context });
const res = run(root, ['--check']);
assert.equal(res.status, 1);
assert.match(res.stderr, /src\/does-not-exist\.cts/);
});
test('allRuntimes count and membership drift is caught', (t) => {
// Mirrors the real-world case: CONTEXT.md says 15 while bin/install.js has 17.
const claimed15 = REAL_RUNTIMES.filter((r) => r !== 'pi' && r !== 'zcode');
assert.equal(claimed15.length, 15);
const context = [
'# Context',
'',
'See `src/real-module.cts` for details.',
'',
`${allRuntimesSentence(15, claimed15)}.`,
'',
].join('\n');
const root = makeRepo(t, { contextBody: context }); // bin/install.js defaults to the real 17
const res = run(root, ['--check']);
assert.equal(res.status, 1);
assert.match(res.stderr, /claims 15 values/);
assert.match(res.stderr, /has 17/);
assert.match(res.stderr, /pi/);
assert.match(res.stderr, /zcode/);
});
test('a reference to a nonexistent gsd-core/bin/lib/*.cjs path is skipped (generated, gitignored)', (t) => {
const context = [
'# Context',
'',
'Generated router lives at `gsd-core/bin/lib/does-not-exist.cjs`.',
'',
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
'',
].join('\n');
const root = makeRepo(t, { contextBody: context });
const res = run(root, ['--check']);
assert.equal(res.status, 0, `generated bin/lib path must be skipped, not asserted missing: ${res.stderr}`);
});
test('a ~/-rooted path and a bare filename are both skipped', (t) => {
const context = [
'# Context',
'',
'See `~/.claude/x.md` and `core.cjs` for details.',
'',
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
'',
].join('\n');
const root = makeRepo(t, { contextBody: context });
const res = run(root, ['--check']);
assert.equal(res.status, 0, `home path and bare filename must be skipped, not asserted missing: ${res.stderr}`);
});
test('a `..`-traversal token cannot escape ROOT into a filesystem-existence probe', (t) => {
// Security review finding: `src/../../../etc/passwd` passes PATH_TOKEN_RE (`.`
// is a legal segment char) and the `src/` prefix, so without a confinement
// check `path.join(ROOT, token)` normalizes out of the tree and existsSync
// probes it — a doc lint turned filesystem oracle. It must be skipped, so the
// gate neither errors nor reports a finding about an out-of-tree path.
const context = [
'# Context',
'',
'Escape attempt: `src/../../../../../../etc/passwd` and `src/../../etc/hosts`.',
'',
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
'',
].join('\n');
const root = makeRepo(t, { contextBody: context });
const res = run(root, ['--check']);
assert.equal(res.status, 0, `..-traversal tokens must be confined to ROOT and skipped: ${res.stderr}`);
assert.doesNotMatch(res.stderr, /etc\/passwd|etc\/hosts/, 'a confined gate must not name out-of-tree paths');
});
test('the real script runs cleanly against the real repo without crashing', () => {
// Does NOT assert the exit code — CONTEXT.md may still be mid-edit — only
// that the gate itself runs to a real verdict (0 or 1), not an unhandled
// crash (no status / non-2-shaped exit).
const res = spawnSync(process.execPath, [path.join(REPO_ROOT, SCRIPT_REL), '--check'], {
cwd: REPO_ROOT,
encoding: 'utf8',
timeout: 30_000,
});
assert.equal(res.error, undefined, `spawn must not error: ${res.error}`);
assert.ok(res.status === 0 || res.status === 1, `expected exit 0 or 1, got ${res.status} (stderr: ${res.stderr})`);
});