diff --git a/.changeset/zesty-koalas-sing.md b/.changeset/zesty-koalas-sing.md new file mode 100644 index 000000000..1f93bbf65 --- /dev/null +++ b/.changeset/zesty-koalas-sing.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2938 +--- +**`gsd_run query context-predicates` — targeted lookups against the `CONTEXT.md` fact-store** — search predicates live by class, id prefix, or substring instead of reading the whole file, with a CI-guarded `docs/CONTEXT-INDEX.json` index kept in sync automatically. (#2928) diff --git a/.gitignore b/.gitignore index c3ab688a9..68a9453c5 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ build/ /gsd-core/bin/lib/mcp-server.cjs /gsd-core/bin/lib/external-descriptor-trust.cjs /gsd-core/bin/lib/cli-skew-check.cjs +/gsd-core/bin/lib/context-predicates.cjs /gsd-core/bin/lib/capability-loader.cjs /gsd-core/bin/lib/capability-source.cjs /gsd-core/bin/lib/capability-ledger.cjs diff --git a/CONTEXT.md b/CONTEXT.md index dc1c0f2cd..48af820be 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -524,7 +524,6 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `RULESET.CODERABBIT.GUARD.RESOLVE=fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query` `RULESET.CODERABBIT.GUARD.SCOPE=if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete` `RULESET.TESTS.CODERABBIT_FIX=prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule` -`RULESET.WORKFLOW_MARKDOWN.FENCES=when editing shell snippets inside workflow markdown, preserve the opening language fence; malformed fence can create fresh CodeRabbit threads` `CI.GATE.issue-link-required=hard-fail if PR body lacks closes/fixes/resolves #` `CI.GATE.changeset-lint=hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label` `CI.GATE.repair-sequence(PR)=create issue -> apply approval label -> edit PR body w/ closing keyword -> apply no-changelog if appropriate -> re-run checks` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 083fd7028..beb2e5bf5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -347,6 +347,14 @@ gsd-tools query research-plan ← Research Provider: check cache, build Agents always return a `RESEARCH.md` path, never raw fetched content. Context discipline is enforced through subagent isolation, compact provider output, and fetch-to-disk. See [ADR-0656](adr/0656-research-module-seam.md). +### Context Predicate Fact-Store (`src/context-predicates.cts`, ADR-1671) + +The `CONTEXT.md` predicate fact-store — every backtick-wrapped `CLASS.subkey=value` declaration in the repo-root `CONTEXT.md` — has a compiled parser/selector seam (generated to `gsd-core/bin/lib/context-predicates.cjs` per ADR-457) reachable live via `gsd-tools query context-predicates --class|--prefix|--contains`. Fence-aware line skipping mirrors `markdown-sectionizer.cts`'s exported `scanFencedBlocks` delimiter-matching rule exactly (proven by a fence-skip parity test suite), but is scanned by a LOCAL, interleaved single pass rather than a call into that seam directly: fences and HTML comments must mutually suppress each other's open/close detection while either is active (a fence delimiter inside a real comment, or a comment token inside a real fence, must not falsely toggle the other construct), and that precedence cannot be resolved by two independent passes over `scanFencedBlocks`'s comment-blind output — see `src/context-predicates.cts`'s module doc comment. + +`scripts/gen-context-index.cjs --check` is the CI drift-guard for the committed `docs/CONTEXT-INDEX.json` artifact: it fails on staleness between a fresh parse of `CONTEXT.md` and the committed file, and on any duplicate predicate ID. It is wired into `lint:generated-sync` (so `lint:ci`, so CI). `docs/CONTEXT-INDEX.json` is **generated — never hand-edit it**; regenerate with `gen-context-index.cjs --write` (also wired into `build`, after `build:lib`, and into `regen:derived`). The generator `require()`s the compiled `context-predicates.cjs`, so it must run after `build:lib` in any pipeline; `.github/workflows/test.yml` does this. + +The committed index intentionally carries **no `line` field** for any predicate (ADR-1671 open question 4, resolved by #2928) — committed-but-uncompared metadata goes silently stale, the same defect class the drift-guard exists to catch, with the alarm removed. The live `gsd-tools query context-predicates` parse still returns `line`/`section` for callers that want to cite a source location. See [ADR-1671](adr/1671-dynamic-context-management-platform.md) and [CLI Tools Reference](CLI-TOOLS.md#query-context-predicates). + ### CLI Tools (`gsd-core/bin/`) Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core/bin/lib/` (see [`docs/INVENTORY.md`](INVENTORY.md#cli-modules) for the authoritative roster): @@ -382,6 +390,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | +| `context-predicates.cjs` | `CONTEXT.md` predicate fact-store parser/selector (ADR-1671, #2928); backs `query context-predicates` and `scripts/gen-context-index.cjs`'s `docs/CONTEXT-INDEX.json` drift guard; compiled from `src/context-predicates.cts` | | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts; emitted by `scripts/gen-loop-host-contract.cjs` from workflow markers (ADR-894 §3); consumed by `gen-capability-registry.cjs` | | `capability-loader.cjs` | Runtime registry overlay loader (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay of third-party capability manifests read from global `$GSD_HOME/.gsd/capabilities/` and project `/.gsd/capabilities/`; first-party always wins; load-time `engines.gsd` re-gate skips incompatible overlays with a warning; gate-kind hooks on skipped capabilities fail OPEN — no gate is injected; a loud warning (stderr + envelope `warnings`) names the load failure and the `gsd capability remove ` remediation (#2009) | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) | diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 38167ab10..f1be112a5 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -322,6 +322,47 @@ This command is strictly read-only — no config writes, no disk mutation. --- +### `query context-predicates` + +```bash +node gsd-tools.cjs query context-predicates --class | --prefix | --contains +``` + +Selector surface for the `CONTEXT.md` predicate fact-store (ADR-1671, #2928). Parses the repo-root `CONTEXT.md` **live** on every call via the compiled `context-predicates.cjs` — it never reads the committed `docs/CONTEXT-INDEX.json` (that artifact is a CI drift-guard byproduct, not a query source, so it can never go stale relative to the live predicates it answers about). + +**Selectors** (at least one required; when more than one is given they are ANDed together): + +| Flag | Type | Description | +|---|---|---| +| `--class ` | string | Exact match on the predicate's class (the segment before the first `.`) | +| `--prefix ` | string | Match predicate ids starting with this dotted prefix | +| `--contains ` | string | Case-insensitive substring match against `id + ' ' + value` | + +Each flag also accepts the inline-assignment form (`--contains=`), which is the escape +hatch for a flag-shaped value the space-separated form cannot express — e.g. +`--contains=--dry-run` to search for the literal substring `--dry-run`. The space-separated form +(`--contains --dry-run`) always reads a following `--...` token as a missing value, by design. + +**Output JSON:** + +```json +{ + "matched": 2, + "predicates": [ + { "id": "RULESET.EXAMPLE", "klass": "RULESET", "value": "…", "line": 42, "section": "Glossary" } + ] +} +``` + +| Field | Type | Description | +|---|---|---| +| `matched` | number | Count of predicates satisfying all given selectors | +| `predicates` | array | Each entry is a live `Predicate` — `id`, `klass`, `value`, `line` (1-based source line), `section` (nearest enclosing heading) | + +This command is strictly read-only — no config writes, no disk mutation. See [ADR-1671](adr/1671-dynamic-context-management-platform.md) and [Architecture — CLI Tools](ARCHITECTURE.md#cli-tools-gsd-corebin). + +--- + ## Model Resolution ```bash @@ -739,6 +780,7 @@ User-facing entry point: `/gsd-graphify` (see [Command Reference](COMMANDS.md#gs | Audit | `lib/audit.cjs` | Phase/milestone audit queue handlers; `audit-open` helper | | GSD2 Import | `lib/gsd2-import.cjs` | Reverse-migration importer from GSD-2 projects (backs `/gsd-import --from-gsd2`) | | Intel | `lib/intel.cjs` | Queryable codebase intelligence index (backs `/gsd-map-codebase --query`) | +| Context Predicates | `lib/context-predicates.cjs` | `CONTEXT.md` predicate fact-store parser/selector (ADR-1671, #2928) — backs `query context-predicates` and `scripts/gen-context-index.cjs`'s `docs/CONTEXT-INDEX.json` drift guard | | Capability State | `lib/capability-state.cjs` | Capability-state resolver — composes install profile, surface, and config into per-capability `enabled`/`active` view | | Capability Writer | `lib/capability-writer.cjs` | Capability-state writer (ADR-1213) — write-side inverse; projects `--on`/`--off`/`--gate` onto surface + config substrates then re-resolves | | Worktree Base Ref | `lib/worktree-base-ref.cjs` | Worktree fork-base detection and `worktree base-check` / `set-baseref` commands (#683) | diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json new file mode 100644 index 000000000..48303893c --- /dev/null +++ b/docs/CONTEXT-INDEX.json @@ -0,0 +1,2104 @@ +{ + "schemaVersion": 1, + "count": 415, + "classes": { + "ARCH": 1, + "CI": 2, + "CONFIG": 1, + "DEFECT": 167, + "EXEC": 8, + "GSD-RESEARCH": 6, + "LEARNING": 1, + "META": 4, + "PLANNING": 3, + "PR": 2, + "PRED": 68, + "PROBE": 11, + "PROC": 14, + "PROHIB": 10, + "RELEASE-NOTES": 31, + "RULESET": 55, + "SESSION": 9, + "WAVE": 5, + "WORKSTREAM": 5, + "WORKTREE": 12 + }, + "predicates": [ + { + "id": "ARCH.SKILL.improve-codebase.next-candidates", + "klass": "ARCH", + "value": "[Workstream Progress Projection Module]" + }, + { + "id": "CI.GATE.changeset-lint", + "klass": "CI", + "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label" + }, + { + "id": "CI.GATE.issue-link-required", + "klass": "CI", + "value": "hard-fail if PR body lacks closes/fixes/resolves #" + }, + { + "id": "CONFIG.SEAM.loadConfig-context", + "klass": "CONFIG", + "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites" + }, + { + "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect", + "klass": "DEFECT", + "value": "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\")" + }, + { + "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward", + "klass": "DEFECT", + "value": "mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file" + }, + { + "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom", + "klass": "DEFECT", + "value": "adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold" + }, + { + "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect", + "klass": "DEFECT", + "value": "tests/slash-command-namespace.test.cjs prints \"Found N retired /gsd- reference(s) — use /gsd: instead\" with line-number-precise violations" + }, + { + "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples", + "klass": "DEFECT", + "value": "#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)" + }, + { + "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward", + "klass": "DEFECT", + "value": "replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct" + }, + { + "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson", + "klass": "DEFECT", + "value": "agent-trust-but-verify is load-bearing — sub-agent reporting \"done\" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes" + }, + { + "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom", + "klass": "DEFECT", + "value": "sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff" + }, + { + "id": "DEFECT.BOT-BRANCH-STALE-BASE.detect", + "klass": "DEFECT", + "value": "git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale" + }, + { + "id": "DEFECT.BOT-BRANCH-STALE-BASE.examples", + "klass": "DEFECT", + "value": "#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)" + }, + { + "id": "DEFECT.BOT-BRANCH-STALE-BASE.fix-forward", + "klass": "DEFECT", + "value": "git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease" + }, + { + "id": "DEFECT.BOT-BRANCH-STALE-BASE.symptom", + "klass": "DEFECT", + "value": "auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved" + }, + { + "id": "DEFECT.CANARY-VERSION-LEAK.detect", + "klass": "DEFECT", + "value": "jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version" + }, + { + "id": "DEFECT.CANARY-VERSION-LEAK.examples", + "klass": "DEFECT", + "value": "2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at \"version\": \"1.50.0-canary.0\" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned [\"0.1.0\"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '\"version\": \"1.50.0-canary.0\"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base" + }, + { + "id": "DEFECT.CANARY-VERSION-LEAK.fix-forward", + "klass": "DEFECT", + "value": "open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main" + }, + { + "id": "DEFECT.CANARY-VERSION-LEAK.symptom", + "klass": "DEFECT", + "value": "package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label" + }, + { + "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect", + "klass": "DEFECT", + "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls" + }, + { + "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.examples", + "klass": "DEFECT", + "value": "#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle" + }, + { + "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom", + "klass": "DEFECT", + "value": ".changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number" + }, + { + "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples", + "klass": "DEFECT", + "value": "#3309 v2 default flip from mid-flight to end-of-phase" + }, + { + "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward", + "klass": "DEFECT", + "value": "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 \"" + }, + { + "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom", + "klass": "DEFECT", + "value": "PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)" + }, + { + "id": "DEFECT.FORMAT", + "klass": "DEFECT", + "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable" + }, + { + "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect", + "klass": "DEFECT", + "value": "grep \"^:\" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it" + }, + { + "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples", + "klass": "DEFECT", + "value": "#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)" + }, + { + "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward", + "klass": "DEFECT", + "value": "scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' \"$f\" | grep -m1 \"^:\" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)" + }, + { + "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.GENERATIVE-EXEMPLAR", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.GENERATIVE-FIX", + "klass": "DEFECT", + "value": "for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge" + }, + { + "id": "DEFECT.GENERATIVE-PRIORITY", + "klass": "DEFECT", + "value": "these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer" + }, + { + "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward", + "klass": "DEFECT", + "value": "set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)" + }, + { + "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause", + "klass": "DEFECT", + "value": "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()" + }, + { + "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom", + "klass": "DEFECT", + "value": "two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; \"local exit=1 docker exit=1\" reported even though remote containers ran fine" + }, + { + "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream", + "klass": "DEFECT", + "value": "open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)" + }, + { + "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect", + "klass": "DEFECT", + "value": "gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out" + }, + { + "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples", + "klass": "DEFECT", + "value": "2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes" + }, + { + "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward", + "klass": "DEFECT", + "value": "TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f \"ssh \"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds" + }, + { + "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related", + "klass": "DEFECT", + "value": "DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month" + }, + { + "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom", + "klass": "DEFECT", + "value": "pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes \"$h\" true); subsequent ssh \"$h\" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all" + }, + { + "id": "DEFECT.GSD-TEST-MIRROR-POISONED.detect", + "klass": "DEFECT", + "value": "docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp \".gsd-*.\" failed" + }, + { + "id": "DEFECT.GSD-TEST-MIRROR-POISONED.recovery", + "klass": "DEFECT", + "value": "ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)" + }, + { + "id": "DEFECT.GSD-TEST-MIRROR-POISONED.root-cause", + "klass": "DEFECT", + "value": "container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts" + }, + { + "id": "DEFECT.GSD-TEST-MIRROR-POISONED.symptom", + "klass": "DEFECT", + "value": "gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs" + }, + { + "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream", + "klass": "DEFECT", + "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe" + }, + { + "id": "DEFECT.HALT-COST-PATTERN.detect", + "klass": "DEFECT", + "value": "any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context" + }, + { + "id": "DEFECT.HALT-COST-PATTERN.examples", + "klass": "DEFECT", + "value": "#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured \"tens of thousands of tokens\" per halt)" + }, + { + "id": "DEFECT.HALT-COST-PATTERN.fix-forward", + "klass": "DEFECT", + "value": "offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer" + }, + { + "id": "DEFECT.HALT-COST-PATTERN.symptom", + "klass": "DEFECT", + "value": "architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn" + }, + { + "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect", + "klass": "DEFECT", + "value": "hook re-fires on each invocation regardless of session-state read receipts" + }, + { + "id": "DEFECT.HOOK-OVER-ENFORCEMENT.examples", + "klass": "DEFECT", + "value": "this session repeatedly hit \"Refusing to run gh issue create|edit / gh pr create|edit\" despite reading every listed file" + }, + { + "id": "DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward", + "klass": "DEFECT", + "value": "use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match" + }, + { + "id": "DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking", + "klass": "DEFECT", + "value": "gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files" + }, + { + "id": "DEFECT.HOOK-OVER-ENFORCEMENT.symptom", + "klass": "DEFECT", + "value": "PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session" + }, + { + "id": "DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass", + "klass": "DEFECT", + "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks" + }, + { + "id": "DEFECT.INVENTORY-DRIFT.detect", + "klass": "DEFECT", + "value": "tests/inventory-manifest-sync.test.cjs fails with \"New surfaces not in manifest\"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading" + }, + { + "id": "DEFECT.INVENTORY-DRIFT.examples", + "klass": "DEFECT", + "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)" + }, + { + "id": "DEFECT.INVENTORY-DRIFT.fix-forward", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.INVENTORY-DRIFT.symptom", + "klass": "DEFECT", + "value": "new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json" + }, + { + "id": "DEFECT.NAME-COLLISION.detect", + "klass": "DEFECT", + "value": "trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract" + }, + { + "id": "DEFECT.NAME-COLLISION.examples", + "klass": "DEFECT", + "value": "#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws \"Usage: config-ensure-section
\")" + }, + { + "id": "DEFECT.NAME-COLLISION.fix-forward", + "klass": "DEFECT", + "value": "either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract" + }, + { + "id": "DEFECT.NAME-COLLISION.symptom", + "klass": "DEFECT", + "value": "a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw" + }, + { + "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect", + "klass": "DEFECT", + "value": "any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning" + }, + { + "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples", + "klass": "DEFECT", + "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants" + }, + { + "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward", + "klass": "DEFECT", + "value": "accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source" + }, + { + "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom", + "klass": "DEFECT", + "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed" + }, + { + "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.detect", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.examples", + "klass": "DEFECT", + "value": "#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)" + }, + { + "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward", + "klass": "DEFECT", + "value": "consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps" + }, + { + "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom", + "klass": "DEFECT", + "value": "multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect", + "klass": "DEFECT", + "value": "CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward", + "klass": "DEFECT", + "value": "ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention", + "klass": "DEFECT", + "value": "when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect", + "klass": "DEFECT", + "value": "any new bare tag in agents/*.md" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples", + "klass": "DEFECT", + "value": "#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward", + "klass": "DEFECT", + "value": "hyphenate the tag (, ) — scanner regex matches bare names only" + }, + { + "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom", + "klass": "DEFECT", + "value": "custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate" + }, + { + "id": "DEFECT.REMOVED-BUT-NEEDED.detect", + "klass": "DEFECT", + "value": "before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete" + }, + { + "id": "DEFECT.REMOVED-BUT-NEEDED.examples", + "klass": "DEFECT", + "value": "#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace" + }, + { + "id": "DEFECT.REMOVED-BUT-NEEDED.fix-forward", + "klass": "DEFECT", + "value": "restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility" + }, + { + "id": "DEFECT.REMOVED-BUT-NEEDED.symptom", + "klass": "DEFECT", + "value": "file/key removed because \"no longer used\" without verifying every consumer (workflows, docs, manifests, npm scripts)" + }, + { + "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT", + "klass": "DEFECT", + "value": "provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)" + }, + { + "id": "DEFECT.SCOPE.window", + "klass": "DEFECT", + "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287" + }, + { + "id": "DEFECT.SDK-PORT-NAME-COLLISION.generative-tie", + "klass": "DEFECT", + "value": "instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open" + }, + { + "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect", + "klass": "DEFECT", + "value": "grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)" + }, + { + "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples", + "klass": "DEFECT", + "value": "#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" + }, + { + "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward", + "klass": "DEFECT", + "value": "tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class" + }, + { + "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor", + "klass": "DEFECT", + "value": "tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)" + }, + { + "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect", + "klass": "DEFECT", + "value": "npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation" + }, + { + "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom", + "klass": "DEFECT", + "value": "new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script" + }, + { + "id": "DEFECT.STACKED-PR-AUTO-RETARGET.detect", + "klass": "DEFECT", + "value": "ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts" + }, + { + "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples", + "klass": "DEFECT", + "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged" + }, + { + "id": "DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward", + "klass": "DEFECT", + "value": "PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)" + }, + { + "id": "DEFECT.STACKED-PR-AUTO-RETARGET.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern", + "klass": "DEFECT", + "value": "blindly running git rebase --onto origin/main on the patch branch — produces \"conflicts\" that are really \"the scaffolding doesn't exist yet\"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing" + }, + { + "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect", + "klass": "DEFECT", + "value": "gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with \"does not exist in origin/main\"" + }, + { + "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples", + "klass": "DEFECT", + "value": "#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all" + }, + { + "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward", + "klass": "DEFECT", + "value": "user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with \"subsumed by #\". Alternatives explicitly rejected: leaving stacked open (\"no, fold them in\") and closing-without-folding (\"we want the fix\")" + }, + { + "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom", + "klass": "DEFECT", + "value": "patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's \"base\" on GitHub is the feature branch, not main; merging requires the upstream PR to land first" + }, + { + "id": "DEFECT.STATE-TRAMPLE.detect", + "klass": "DEFECT", + "value": "any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress" + }, + { + "id": "DEFECT.STATE-TRAMPLE.examples", + "klass": "DEFECT", + "value": "#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)" + }, + { + "id": "DEFECT.STATE-TRAMPLE.fix-forward", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.STATE-TRAMPLE.symptom", + "klass": "DEFECT", + "value": "state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter" + }, + { + "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.detect", + "klass": "DEFECT", + "value": "after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded" + }, + { + "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples", + "klass": "DEFECT", + "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)" + }, + { + "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward", + "klass": "DEFECT", + "value": "close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history" + }, + { + "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom", + "klass": "DEFECT", + "value": "multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts" + }, + { + "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples", + "klass": "DEFECT", + "value": "#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" + }, + { + "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.UNBOUNDED-SUBPROCESS.detect", + "klass": "DEFECT", + "value": "execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view" + }, + { + "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples", + "klass": "DEFECT", + "value": "a33cbe72 worktree fix bound git subprocesses with timeout" + }, + { + "id": "DEFECT.UNBOUNDED-SUBPROCESS.fix-forward", + "klass": "DEFECT", + "value": "add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw" + }, + { + "id": "DEFECT.UNBOUNDED-SUBPROCESS.symptom", + "klass": "DEFECT", + "value": "git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network" + }, + { + "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.detect", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.examples", + "klass": "DEFECT", + "value": "#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed" + }, + { + "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.prevention", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.symptom", + "klass": "DEFECT", + "value": "execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)" + }, + { + "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-FS-OPS.detect", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-FS-OPS.examples", + "klass": "DEFECT", + "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim" + }, + { + "id": "DEFECT.WINDOWS-FS-OPS.fix-forward", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-FS-OPS.symptom", + "klass": "DEFECT", + "value": "fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target" + }, + { + "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention", + "klass": "DEFECT", + "value": "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,'/'))" + }, + { + "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward", + "klass": "DEFECT", + "value": "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." + }, + { + "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect", + "klass": "DEFECT", + "value": "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)" + }, + { + "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples", + "klass": "DEFECT", + "value": "#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" + }, + { + "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward", + "klass": "DEFECT", + "value": "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" + }, + { + "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; 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" + }, + { + "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom", + "klass": "DEFECT", + "value": "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 === ; 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" + }, + { + "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-TEST-PORTABILITY.examples", + "klass": "DEFECT", + "value": "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" + }, + { + "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 ) 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)" + }, + { + "id": "DEFECT.WINDOWS-TEST-PORTABILITY.prevention", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WINDOWS-TEST-PORTABILITY.symptom", + "klass": "DEFECT", + "value": "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" + }, + { + "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect", + "klass": "DEFECT", + "value": "after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present" + }, + { + "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples", + "klass": "DEFECT", + "value": "PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge" + }, + { + "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward", + "klass": "DEFECT", + "value": "copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves" + }, + { + "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention", + "klass": "DEFECT", + "value": "any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime" + }, + { + "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom", + "klass": "DEFECT", + "value": "workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail" + }, + { + "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect", + "klass": "DEFECT", + "value": "git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook" + }, + { + "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples", + "klass": "DEFECT", + "value": "this session, branch fix/3309-... and pr-3316" + }, + { + "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward", + "klass": "DEFECT", + "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:" + }, + { + "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom", + "klass": "DEFECT", + "value": "in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch" + }, + { + "id": "EXEC.CLASSIFY.classes", + "klass": "EXEC", + "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}" + }, + { + "id": "EXEC.CLASSIFY.cross-runtime", + "klass": "EXEC", + "value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests" + }, + { + "id": "EXEC.CLASSIFY.handler", + "klass": "EXEC", + "value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)" + }, + { + "id": "EXEC.CLASSIFY.precedence", + "klass": "EXEC", + "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear" + }, + { + "id": "EXEC.CLASSIFY.proactive-signal-not-usable", + "klass": "EXEC", + "value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)" + }, + { + "id": "EXEC.CLASSIFY.retry-after-parser", + "klass": "EXEC", + "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after" + }, + { + "id": "EXEC.CLASSIFY.sentinel-order", + "klass": "EXEC", + "value": "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" + }, + { + "id": "EXEC.CLASSIFY.workflow", + "klass": "EXEC", + "value": "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)" + }, + { + "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", + "klass": "GSD-RESEARCH", + "value": "less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob" + }, + { + "id": "GSD-RESEARCH.INTEGRATION.L2-hybrid", + "klass": "GSD-RESEARCH", + "value": "code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches" + }, + { + "id": "GSD-RESEARCH.MODULE.package-legitimacy", + "klass": "GSD-RESEARCH", + "value": "registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate" + }, + { + "id": "GSD-RESEARCH.MODULE.research-provider", + "klass": "GSD-RESEARCH", + "value": "single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in docs/web discovery)" + }, + { + "id": "GSD-RESEARCH.MODULE.research-store", + "klass": "GSD-RESEARCH", + "value": "content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache" + }, + { + "id": "GSD-RESEARCH.PROVIDER.availability", + "klass": "GSD-RESEARCH", + "value": "config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal" + }, + { + "id": "LEARNING.prompt-budget.boundary-gap", + "klass": "LEARNING", + "value": "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" + }, + { + "id": "META.RULE.brief-must-cite-doc", + "klass": "META", + "value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations" + }, + { + "id": "META.RULE.brief-no-paraphrase", + "klass": "META", + "value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110" + }, + { + "id": "META.RULE.canonical-source-precedence", + "klass": "META", + "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory" + }, + { + "id": "META.RULE.read-contributing-first", + "klass": "META", + "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch" + }, + { + "id": "PLANNING.PATH.PARITY.project-scope", + "klass": "PLANNING", + "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()" + }, + { + "id": "PLANNING.PATH.SEAM.helpers", + "klass": "PLANNING", + "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root" + }, + { + "id": "PLANNING.PATH.SEAM.init-handlers", + "klass": "PLANNING", + "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)" + }, + { + "id": "PR.3267.POSTMORTEM.recovery", + "klass": "PR", + "value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]" + }, + { + "id": "PR.3267.POSTMORTEM.root-cause", + "klass": "PR", + "value": "[missing issue link, missing changeset/no-changelog]" + }, + { + "id": "PRED.k320.canonical-source", + "klass": "PRED", + "value": "CONTRIBUTING.md L193-211" + }, + { + "id": "PRED.k320.ci-enforcement", + "klass": "PRED", + "value": "scripts/changeset/lint.cjs" + }, + { + "id": "PRED.k320.ci-paths-monitored", + "klass": "PRED", + "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/" + }, + { + "id": "PRED.k320.cure", + "klass": "PRED", + "value": "drop .changeset/--.md fragment ONLY" + }, + { + "id": "PRED.k320.evidence", + "klass": "PRED", + "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09" + }, + { + "id": "PRED.k320.opt-out-label", + "klass": "PRED", + "value": "no-changelog" + }, + { + "id": "PRED.k320.recovery", + "klass": "PRED", + "value": "open Removed-typed cleanup PR deleting only the redundant row" + }, + { + "id": "PRED.k320.rule", + "klass": "PRED", + "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs" + }, + { + "id": "PRED.k320.signal", + "klass": "PRED", + "value": "changelog-direct-edit-forbidden" + }, + { + "id": "PRED.k320.tool", + "klass": "PRED", + "value": "npm run changeset -- --type --pr --body \"...\"" + }, + { + "id": "PRED.k320.types", + "klass": "PRED", + "value": "Added|Changed|Deprecated|Removed|Fixed|Security" + }, + { + "id": "PRED.k321.evidence", + "klass": "PRED", + "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads" + }, + { + "id": "PRED.k321.poll-shape", + "klass": "PRED", + "value": "parse pulls//reviews body AND graphql reviewThreads" + }, + { + "id": "PRED.k321.resolution", + "klass": "PRED", + "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings" + }, + { + "id": "PRED.k321.shape", + "klass": "PRED", + "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads" + }, + { + "id": "PRED.k321.signal", + "klass": "PRED", + "value": "cr-outside-diff-range-finding" + }, + { + "id": "PRED.k322.cure-1", + "klass": "PRED", + "value": "2nd retrigger ~10min after first ack" + }, + { + "id": "PRED.k322.cure-2", + "klass": "PRED", + "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body" + }, + { + "id": "PRED.k322.distinct-from", + "klass": "PRED", + "value": "k080" + }, + { + "id": "PRED.k322.evidence", + "klass": "PRED", + "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers" + }, + { + "id": "PRED.k322.merge-gate-impact", + "klass": "PRED", + "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment" + }, + { + "id": "PRED.k322.shape", + "klass": "PRED", + "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min" + }, + { + "id": "PRED.k322.signal", + "klass": "PRED", + "value": "cr-sustained-throttle" + }, + { + "id": "PRED.k323.cure-alt", + "klass": "PRED", + "value": "consolidate into single PR when 2+ issues share root cause" + }, + { + "id": "PRED.k323.cure-pre-dispatch", + "klass": "PRED", + "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site" + }, + { + "id": "PRED.k323.evidence", + "klass": "PRED", + "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09" + }, + { + "id": "PRED.k323.recovery", + "klass": "PRED", + "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk" + }, + { + "id": "PRED.k323.shape", + "klass": "PRED", + "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff" + }, + { + "id": "PRED.k323.signal", + "klass": "PRED", + "value": "sibling-audit-cross-pr-overlap" + }, + { + "id": "PRED.k324.cure", + "klass": "PRED", + "value": "verify via gh api on every agent-completion notification; never trust narrative" + }, + { + "id": "PRED.k324.evidence", + "klass": "PRED", + "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262" + }, + { + "id": "PRED.k324.k095-restatement", + "klass": "PRED", + "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates" + }, + { + "id": "PRED.k324.poll-shape", + "klass": "PRED", + "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail" + }, + { + "id": "PRED.k324.signal", + "klass": "PRED", + "value": "agent-terminates-mid-monitor" + }, + { + "id": "PRED.k325.cleanup", + "klass": "PRED", + "value": "git worktree remove --force for aged agent worktrees" + }, + { + "id": "PRED.k325.cure", + "klass": "PRED", + "value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/" + }, + { + "id": "PRED.k325.evidence", + "klass": "PRED", + "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD" + }, + { + "id": "PRED.k325.shape", + "klass": "PRED", + "value": "git checkout errors \"already used by worktree at \"" + }, + { + "id": "PRED.k325.signal", + "klass": "PRED", + "value": "worktree-branch-lock-on-force-push" + }, + { + "id": "PRED.k326.cure", + "klass": "PRED", + "value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"" + }, + { + "id": "PRED.k326.evidence", + "klass": "PRED", + "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110" + }, + { + "id": "PRED.k326.shape", + "klass": "PRED", + "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations" + }, + { + "id": "PRED.k326.signal", + "klass": "PRED", + "value": "brief-contradicts-canonical-doc" + }, + { + "id": "PRED.k327.ack-shape", + "klass": "PRED", + "value": "body \"✅ Actions performed - Full review triggered\"" + }, + { + "id": "PRED.k327.cooldown-normal", + "klass": "PRED", + "value": "[5s, 410s]" + }, + { + "id": "PRED.k327.cooldown-throttled", + "klass": "PRED", + "value": "k322" + }, + { + "id": "PRED.k327.distinguish-key", + "klass": "PRED", + "value": "len(pulls//reviews) — ack=0, real=≥1" + }, + { + "id": "PRED.k327.real-review-shape", + "klass": "PRED", + "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"" + }, + { + "id": "PRED.k327.signal", + "klass": "PRED", + "value": "cr-ack-vs-real-review" + }, + { + "id": "PRED.k328.audit-list", + "klass": "PRED", + "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]" + }, + { + "id": "PRED.k328.canonical-source", + "klass": "PRED", + "value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)" + }, + { + "id": "PRED.k328.k100-restatement", + "klass": "PRED", + "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR" + }, + { + "id": "PRED.k328.signal", + "klass": "PRED", + "value": "pr-template-typed-heading-required" + }, + { + "id": "PRED.k329.body", + "klass": "PRED", + "value": "**** — . (#)" + }, + { + "id": "PRED.k329.canonical-source", + "klass": "PRED", + "value": "CONTRIBUTING.md L196-202 + .changeset/README.md" + }, + { + "id": "PRED.k329.filename", + "klass": "PRED", + "value": ".changeset/--.md" + }, + { + "id": "PRED.k329.frontmatter", + "klass": "PRED", + "value": "---\\\\ntype: \\\\npr: \\\\n---" + }, + { + "id": "PRED.k329.observed-clean", + "klass": "PRED", + "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows" + }, + { + "id": "PRED.k329.signal", + "klass": "PRED", + "value": "changeset-fragment-canonical-shape" + }, + { + "id": "PRED.k330.fallback", + "klass": "PRED", + "value": "append predicate-format findings directly to CONTEXT.md" + }, + { + "id": "PRED.k330.shape", + "klass": "PRED", + "value": "mempalace MCP tools require explicit user call; AI cannot trigger" + }, + { + "id": "PRED.k330.signal", + "klass": "PRED", + "value": "mempalace-diary-not-callable-by-ai" + }, + { + "id": "PRED.k331.cure", + "klass": "PRED", + "value": "gh pr close with NO --comment flag" + }, + { + "id": "PRED.k331.evidence", + "klass": "PRED", + "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s" + }, + { + "id": "PRED.k331.k101-restatement", + "klass": "PRED", + "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body" + }, + { + "id": "PRED.k331.recovery", + "klass": "PRED", + "value": "if violation lands, gh api -X DELETE repos///issues/comments/" + }, + { + "id": "PRED.k331.shape", + "klass": "PRED", + "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body" + }, + { + "id": "PRED.k331.signal", + "klass": "PRED", + "value": "close-with-no-comment-is-literal" + }, + { + "id": "PROBE.ci.surface", + "klass": "PROBE", + "value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)" + }, + { + "id": "PROBE.core.seam", + "klass": "PROBE", + "value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)" + }, + { + "id": "PROBE.edge.verification", + "klass": "PROBE", + "value": "explicit|backstop" + }, + { + "id": "PROBE.family", + "klass": "PROBE", + "value": "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)" + }, + { + "id": "PROBE.item.axes", + "klass": "PROBE", + "value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)" + }, + { + "id": "PROBE.principle", + "klass": "PROBE", + "value": "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" + }, + { + "id": "PROBE.prohib.verification", + "klass": "PROBE", + "value": "test|judgment" + }, + { + "id": "PROBE.protocol", + "klass": "PROBE", + "value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason" + }, + { + "id": "PROBE.ui.axis", + "klass": "PROBE", + "value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)" + }, + { + "id": "PROBE.ui.seam", + "klass": "PROBE", + "value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)" + }, + { + "id": "PROBE.ui.verification", + "klass": "PROBE", + "value": "explicit|backstop" + }, + { + "id": "PROC.AGENT-DISPATCH.completion-verify", + "klass": "PROC", + "value": "run k324.poll-shape on every agent-completion notification" + }, + { + "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", + "klass": "PROC", + "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners" + }, + { + "id": "PROC.AGENT-DISPATCH.preflight", + "klass": "PROC", + "value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]" + }, + { + "id": "PROC.MERGE-WAVE.changelog-strip-pattern", + "klass": "PROC", + "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease" + }, + { + "id": "PROC.MERGE-WAVE.merge-tool", + "klass": "PROC", + "value": "gh pr merge --squash --delete-branch" + }, + { + "id": "PROC.MERGE-WAVE.merge-tool-warning", + "klass": "PROC", + "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted" + }, + { + "id": "PROC.MERGE-WAVE.ordering", + "klass": "PROC", + "value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]" + }, + { + "id": "PROC.MERGE-WAVE.preflight", + "klass": "PROC", + "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer" + }, + { + "id": "PROC.PARALLEL-FIX-DISPATCH.observed", + "klass": "PROC", + "value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened" + }, + { + "id": "PROC.PARALLEL-FIX-DISPATCH.pattern", + "klass": "PROC", + "value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill" + }, + { + "id": "PROC.PARALLEL-FIX-DISPATCH.rationale", + "klass": "PROC", + "value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION" + }, + { + "id": "PROC.TRIAGE.comment-shape", + "klass": "PROC", + "value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close" + }, + { + "id": "PROC.TRIAGE.no-duplicate-label", + "klass": "PROC", + "value": "this repo has no duplicate label; framing lives in comment text + closing the issue" + }, + { + "id": "PROC.TRIAGE.routing-incoming", + "klass": "PROC", + "value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest" + }, + { + "id": "PROHIB.canon-referral", + "klass": "PROHIB", + "value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)" + }, + { + "id": "PROHIB.descriptor.shape", + "klass": "PROHIB", + "value": "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)" + }, + { + "id": "PROHIB.enforce.adr", + "klass": "PROHIB", + "value": "docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)" + }, + { + "id": "PROHIB.enforce.causation", + "klass": "PROHIB", + "value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)" + }, + { + "id": "PROHIB.enforce.failfirst", + "klass": "PROHIB", + "value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)" + }, + { + "id": "PROHIB.enforce.green-rule", + "klass": "PROHIB", + "value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default" + }, + { + "id": "PROHIB.enforce.kinds", + "klass": "PROHIB", + "value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)" + }, + { + "id": "PROHIB.judgment-tier", + "klass": "PROHIB", + "value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)" + }, + { + "id": "PROHIB.rail", + "klass": "PROHIB", + "value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability" + }, + { + "id": "PROHIB.recall", + "klass": "PROHIB", + "value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)" + }, + { + "id": "RELEASE-NOTES.ANTI-PATTERN", + "klass": "RELEASE-NOTES", + "value": "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" + }, + { + "id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first", + "klass": "RELEASE-NOTES", + "value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior" + }, + { + "id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary", + "klass": "RELEASE-NOTES", + "value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong" + }, + { + "id": "RELEASE-NOTES.DEFAULT-STATE", + "klass": "RELEASE-NOTES", + "value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final" + }, + { + "id": "RELEASE-NOTES.EXAMPLE.hotfix", + "klass": "RELEASE-NOTES", + "value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups" + }, + { + "id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable", + "klass": "RELEASE-NOTES", + "value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles" + }, + { + "id": "RELEASE-NOTES.EXAMPLE.rc", + "klass": "RELEASE-NOTES", + "value": "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" + }, + { + "id": "RELEASE-NOTES.GATE.hotfix", + "klass": "RELEASE-NOTES", + "value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body" + }, + { + "id": "RELEASE-NOTES.GATE.minor", + "klass": "RELEASE-NOTES", + "value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix" + }, + { + "id": "RELEASE-NOTES.GATE.rc", + "klass": "RELEASE-NOTES", + "value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard" + }, + { + "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", + "klass": "RELEASE-NOTES", + "value": "next (RCs) + latest (stable); install via @next or @latest" + }, + { + "id": "RELEASE-NOTES.RELEASE-STREAM.rule", + "klass": "RELEASE-NOTES", + "value": "streams do not mix; do not document @next in hotfix/stable notes" + }, + { + "id": "RELEASE-NOTES.SCOPE", + "klass": "RELEASE-NOTES", + "value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)" + }, + { + "id": "RELEASE-NOTES.SOURCE.changesets", + "klass": "RELEASE-NOTES", + "value": ".changeset/*.md (frontmatter pr: + body bullets)" + }, + { + "id": "RELEASE-NOTES.SOURCE.commits", + "klass": "RELEASE-NOTES", + "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges" + }, + { + "id": "RELEASE-NOTES.SOURCE.pr-bodies", + "klass": "RELEASE-NOTES", + "value": "gh pr view --json title,body for fixes lacking a changeset" + }, + { + "id": "RELEASE-NOTES.SOURCE.precedence", + "klass": "RELEASE-NOTES", + "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)" + }, + { + "id": "RELEASE-NOTES.STANDARD.bullet-shape", + "klass": "RELEASE-NOTES", + "value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref." + }, + { + "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", + "klass": "RELEASE-NOTES", + "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/..." + }, + { + "id": "RELEASE-NOTES.STANDARD.footer.hotfix", + "klass": "RELEASE-NOTES", + "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`" + }, + { + "id": "RELEASE-NOTES.STANDARD.footer.rc", + "klass": "RELEASE-NOTES", + "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)" + }, + { + "id": "RELEASE-NOTES.STANDARD.heading-level", + "klass": "RELEASE-NOTES", + "value": "## for category, ### for subgroup (area), - for bullet" + }, + { + "id": "RELEASE-NOTES.STANDARD.intro", + "klass": "RELEASE-NOTES", + "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes" + }, + { + "id": "RELEASE-NOTES.STANDARD.subgroups", + "klass": "RELEASE-NOTES", + "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security" + }, + { + "id": "RELEASE-NOTES.STANDARD.taxonomy", + "klass": "RELEASE-NOTES", + "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation" + }, + { + "id": "RELEASE-NOTES.TEMPLATE.hotfix", + "klass": "RELEASE-NOTES", + "value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: " + }, + { + "id": "RELEASE-NOTES.TEMPLATE.rc", + "klass": "RELEASE-NOTES", + "value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: " + }, + { + "id": "RELEASE-NOTES.WORKFLOW.edit", + "klass": "RELEASE-NOTES", + "value": "gh release edit --notes-file " + }, + { + "id": "RELEASE-NOTES.WORKFLOW.idempotency", + "klass": "RELEASE-NOTES", + "value": "gh release edit overwrites body wholesale; safe to re-run after refining" + }, + { + "id": "RELEASE-NOTES.WORKFLOW.token", + "klass": "RELEASE-NOTES", + "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth" + }, + { + "id": "RELEASE-NOTES.WORKFLOW.view", + "klass": "RELEASE-NOTES", + "value": "gh release view --json body --jq .body" + }, + { + "id": "RULESET.ADR-HEADER", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.AGENT_SIZE_BUDGET", + "klass": "RULESET", + "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same tests/emitted-drift-ack.json as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + 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). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + 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. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724" + }, + { + "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", + "klass": "RULESET", + "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss" + }, + { + "id": "RULESET.ARGUMENTS-SANITIZE", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.AUDIT.search-source-not-generated", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.CAPABILITY.cutover-self-gating", + "klass": "RULESET", + "value": "a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — \"invoke skill X at point Y when config Z\" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding." + }, + { + "id": "RULESET.CAPABILITY.off-means-off", + "klass": "RULESET", + "value": "the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018." + }, + { + "id": "RULESET.CAPABILITY.precedence-engine-single-owner", + "klass": "RULESET", + "value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly." + }, + { + "id": "RULESET.CAPABILITY.step-additive-gate-blocks", + "klass": "RULESET", + "value": "a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022." + }, + { + "id": "RULESET.CODERABBIT.GUARD.COMPLETE", + "klass": "RULESET", + "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0" + }, + { + "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", + "klass": "RULESET", + "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone" + }, + { + "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", + "klass": "RULESET", + "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run" + }, + { + "id": "RULESET.CODERABBIT.GUARD.RERUN", + "klass": "RULESET", + "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved" + }, + { + "id": "RULESET.CODERABBIT.GUARD.RESOLVE", + "klass": "RULESET", + "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query" + }, + { + "id": "RULESET.CODERABBIT.GUARD.SCOPE", + "klass": "RULESET", + "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete" + }, + { + "id": "RULESET.CONTENT-PATH-NORMALIZATION", + "klass": "RULESET", + "value": "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)" + }, + { + "id": "RULESET.CONTRIB.CLASSIFY.enhancement", + "klass": "RULESET", + "value": "requires approved-enhancement before implementation" + }, + { + "id": "RULESET.CONTRIB.CLASSIFY.feature", + "klass": "RULESET", + "value": "requires approved-feature before implementation" + }, + { + "id": "RULESET.CONTRIB.CLASSIFY.fix", + "klass": "RULESET", + "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)" + }, + { + "id": "RULESET.CONTRIB.GATE.ORDER", + "klass": "RULESET", + "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog" + }, + { + "id": "RULESET.CR-THREAD-RESOLVE", + "klass": "RULESET", + "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'" + }, + { + "id": "RULESET.EMITTED_ATTRIBUTION", + "klass": "RULESET", + "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name `tests/emitted-drift-ack.json`, say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`" + }, + { + "id": "RULESET.GH.AUTH.DEFAULT", + "klass": "RULESET", + "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback" + }, + { + "id": "RULESET.HARNESS.test-memory-guard", + "klass": "RULESET", + "value": "~/.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" + }, + { + "id": "RULESET.MANIFEST-CANONICAL-KEY", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.PR-FLOW.docker-before-push", + "klass": "RULESET", + "value": "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." + }, + { + "id": "RULESET.PR-FLOW.templates-mandatory", + "klass": "RULESET", + "value": "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\"" + }, + { + "id": "RULESET.PR-SCOPE.one-concern-per-pr", + "klass": "RULESET", + "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit" + }, + { + "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.TESTS.CODERABBIT_FIX", + "klass": "RULESET", + "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule" + }, + { + "id": "RULESET.TESTS.boundary-coverage", + "klass": "RULESET", + "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs" + }, + { + "id": "RULESET.TESTS.boundary-coverage.anti-pattern", + "klass": "RULESET", + "value": "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)" + }, + { + "id": "RULESET.TESTS.boundary-coverage.fixtures", + "klass": "RULESET", + "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)" + }, + { + "id": "RULESET.TESTS.clock-seam", + "klass": "RULESET", + "value": "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= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)" + }, + { + "id": "RULESET.TESTS.coderabbit-fix-prefer", + "klass": "RULESET", + "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep" + }, + { + "id": "RULESET.TESTS.delete-bad-tests", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.TESTS.diagnostics", + "klass": "RULESET", + "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes" + }, + { + "id": "RULESET.TESTS.escape-regex", + "klass": "RULESET", + "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter" + }, + { + "id": "RULESET.TESTS.eslint-harness", + "klass": "RULESET", + "value": "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)" + }, + { + "id": "RULESET.TESTS.feedback-loop-convergence", + "klass": "RULESET", + "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs" + }, + { + "id": "RULESET.TESTS.guard-toplevel-readFileSync", + "klass": "RULESET", + "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load" + }, + { + "id": "RULESET.TESTS.mutation-score", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.TESTS.no-dead-regex-in-includes", + "klass": "RULESET", + "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete" + }, + { + "id": "RULESET.TESTS.no-source-grep", + "klass": "RULESET", + "value": "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)" + }, + { + "id": "RULESET.TESTS.no-source-grep.exemption", + "klass": "RULESET", + "value": "// allow-test-rule: 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." + }, + { + "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", + "klass": "RULESET", + "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()" + }, + { + "id": "RULESET.TESTS.no-timing-assertion", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.TESTS.property-based-testing", + "klass": "RULESET", + "value": "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" + }, + { + "id": "RULESET.TRIAGE-EXISTING-WORK", + "klass": "RULESET", + "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement" + }, + { + "id": "RULESET.WORKFLOW.COVERAGE-METADATA", + "klass": "RULESET", + "value": "#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 ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task 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" + }, + { + "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", + "klass": "RULESET", + "value": "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)" + }, + { + "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", + "klass": "RULESET", + "value": "@-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" + }, + { + "id": "RULESET.WORKFLOW_FILE_NAMES", + "klass": "RULESET", + "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name" + }, + { + "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", + "klass": "RULESET", + "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)" + }, + { + "id": "RULESET.WORKFLOW_SIZE_BUDGET", + "klass": "RULESET", + "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires a tests/emitted-drift-ack.json entry) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification" + }, + { + "id": "SESSION.2026-05-05", + "klass": "SESSION", + "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]" + }, + { + "id": "SESSION.2026-05-05.sdk-bridge", + "klass": "SESSION", + "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary" + }, + { + "id": "SESSION.2026-05-09", + "klass": "SESSION", + "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]" + }, + { + "id": "SESSION.2026-05-10", + "klass": "SESSION", + "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]" + }, + { + "id": "SESSION.2026-05-13", + "klass": "SESSION", + "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]" + }, + { + "id": "SESSION.2026-05-14", + "klass": "SESSION", + "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]" + }, + { + "id": "SESSION.2026-05-15", + "klass": "SESSION", + "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]" + }, + { + "id": "SESSION.2026-05-15.parallel-fix-dispatch", + "klass": "SESSION", + "value": "[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]" + }, + { + "id": "SESSION.2026-05-16", + "klass": "SESSION", + "value": "[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with \"Failed to read config.json:\"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]" + }, + { + "id": "WAVE.LESSON.agent-narrative-unreliable", + "klass": "WAVE", + "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification" + }, + { + "id": "WAVE.LESSON.changelog-policy-violation-multiplier", + "klass": "WAVE", + "value": "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" + }, + { + "id": "WAVE.LESSON.cr-throttle-burst-correlation", + "klass": "WAVE", + "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)" + }, + { + "id": "WAVE.LESSON.k101-still-trips", + "klass": "WAVE", + "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard" + }, + { + "id": "WAVE.LESSON.sibling-audit-overlap", + "klass": "WAVE", + "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap" + }, + { + "id": "WORKSTREAM.INVARIANT.migrate-name", + "klass": "WORKSTREAM", + "value": "must normalize through canonical slug policy" + }, + { + "id": "WORKSTREAM.INVARIANT.slug-contract", + "klass": "WORKSTREAM", + "value": "all .planning/workstreams/ must be addressable by set/get/status/complete" + }, + { + "id": "WORKSTREAM.NAME.POLICY.cjs-module", + "klass": "WORKSTREAM", + "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation" + }, + { + "id": "WORKSTREAM.POINTER.SEAM.cjs-module", + "klass": "WORKSTREAM", + "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream" + }, + { + "id": "WORKSTREAM.REGRESSION.test-anchor", + "klass": "WORKSTREAM", + "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug" + }, + { + "id": "WORKTREE.SEAM.caller-rule", + "klass": "WORKTREE", + "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers" + }, + { + "id": "WORKTREE.SEAM.current", + "klass": "WORKTREE", + "value": "Worktree Safety Policy Module" + }, + { + "id": "WORKTREE.SEAM.decision-1", + "klass": "WORKTREE", + "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold" + }, + { + "id": "WORKTREE.SEAM.default-prune-policy", + "klass": "WORKTREE", + "value": "metadata_prune_only (non-destructive)" + }, + { + "id": "WORKTREE.SEAM.files", + "klass": "WORKTREE", + "value": "[gsd-core/bin/lib/worktree-safety.cjs]" + }, + { + "id": "WORKTREE.SEAM.interface", + "klass": "WORKTREE", + "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]" + }, + { + "id": "WORKTREE.SEAM.invariant", + "klass": "WORKTREE", + "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal" + }, + { + "id": "WORKTREE.SEAM.inventory-interface", + "klass": "WORKTREE", + "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]" + }, + { + "id": "WORKTREE.SEAM.inventory-snapshot", + "klass": "WORKTREE", + "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers" + }, + { + "id": "WORKTREE.SEAM.test-anchor-w017", + "klass": "WORKTREE", + "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs" + }, + { + "id": "WORKTREE.SEAM.test-anchors", + "klass": "WORKTREE", + "value": "[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]" + }, + { + "id": "WORKTREE.SEAM.test-policy", + "klass": "WORKTREE", + "value": "cover all decision branches in policy module before changing prune behavior" + } + ], + "duplicates": [] +} diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 9b2786d6e..906224eda 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -346,6 +346,7 @@ "config-types.cjs", "config.cjs", "configuration.cjs", + "context-predicates.cjs", "context-utilization.cjs", "core-utils.cjs", "coverage.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 9cc6d5f92..1e9c562e8 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -446,6 +446,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `config-types.cjs` | TypeScript type definitions for the `model_policy` config block — `ModelPolicyConfig`, `TierEntry`, `RuntimeTiers`; compiled from `src/config-types.cts` at publish time (ADR-457) | | `config.cjs` | `config.json` read/write, section initialization; imports validator from `config-schema.cjs` | | `configuration.cjs` | Configuration Module — legacy-key normalization, defaults merge, and explicit on-disk migration; pure normalization primitives consumed by `config-loader.cjs` and `config-schema.cjs` (loadConfig extracted to config-loader per ADR-857 #885) | +| `context-predicates.cjs` | CONTEXT.md predicate fact-store parser (ADR-1671, #2928) — pure `parsePredicates` (extracts every backtick-wrapped `CLASS.subkey=value` declaration, fence/HTML-comment-aware), `selectPredicates` (class/prefix/contains selectors, ANDed), and `buildIndex` (deterministic, line-free artifact shape); backs both `gsd_run query context-predicates` and `scripts/gen-context-index.cjs`'s docs/CONTEXT-INDEX.json drift guard. Compiled from `src/context-predicates.cts` | | `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) | | `core-utils.cjs` | Shared low-level utilities — POSIX path normalization, sub-repo/subdirectory scanning, phase file stats, slug/one-liner/plan-id helpers, time-ago (extracted from `core.cjs`, ADR-857) | | `core.cjs` | Shared utilities and runtime fallbacks; compatibility re-exports for planning-workspace and I/O (`io.cjs`) helpers | diff --git a/docs/adr/1671-dynamic-context-management-platform.md b/docs/adr/1671-dynamic-context-management-platform.md index 4e84a338a..34e919d90 100644 --- a/docs/adr/1671-dynamic-context-management-platform.md +++ b/docs/adr/1671-dynamic-context-management-platform.md @@ -118,12 +118,15 @@ That is a point-in-time true-up, not a fix. Per Open question 4, the index is ke Prototype scope notes: the parser is intentionally self-contained for the example; production should consume the compiled `markdown-sectionizer` seam, live under `src/` → `bin/lib/`, and be drift-guarded by a generator wired into the build **after** `build:lib`. +**Done (#2928).** Production landed under `src/context-predicates.cts` → `gsd-core/bin/lib/context-predicates.cjs` (ADR-457 build-at-publish). Fence-aware line skipping mirrors `markdown-sectionizer.cts`'s exported `scanFencedBlocks` delimiter-matching rule exactly (byte-for-behavior parity proven by a dedicated test suite) via a LOCAL, interleaved single pass, rather than a call into that seam directly: a two-pass design (mask comments, then call `scanFencedBlocks`, or the reverse) cannot correctly resolve mutual precedence between HTML comments and fences in both directions — a fence delimiter inside a real comment (with no later real closer) was found to falsely skip the rest of the file to EOF, and the converse ordering falsely let a comment token inside a real fence leak past the fence's own close — so the two constructs are scanned together, each suppressing the other's open/close detection while active (post-#2928-review fix; see `src/context-predicates.cts`'s module doc comment). `scripts/gen-context-index.cjs --check`/`--write` is wired into `lint:generated-sync` (so `lint:ci`, CI-gated) and into `build` (after `build:lib`) and `regen:derived`; the selector is exposed live via `gsd-tools query context-predicates --class|--prefix|--contains`. + ## Open questions 1. Fragment unit: separate files vs in-file section markers? 2. Build-time emission vs run-time assembly as the primary surface during migration (double-write vs per-workflow cutover)? 3. Whether/when to invest in per-runtime native channels (skills, MCP) above the universal file floor. -4. **Index keying: stable IDs vs baked `line` numbers.** `CONTEXT-INDEX.json` stores each predicate's `line`, so `--check` re-drifts on *any* `CONTEXT.md` line shift — a typo fix three sections up fails the gate. Phase 1 promotes `--check` to a CI gate, where that makes it routinely red for reasons unrelated to predicate integrity. Keying the comparison on stable IDs, with `line` retained as non-compared metadata, is the candidate fix. Raised by @davesienkowski (#1671, 2026-06-25) and confirmed on `next` 2026-07-31. + +**Resolved by #2928 — index keying: stable IDs, with no `line` field at all.** Question 4 asked stable IDs vs baked `line` numbers: `CONTEXT-INDEX.json` stored each predicate's `line`, so `--check` re-drifted on *any* `CONTEXT.md` line shift — a typo fix three sections up failed the gate. Raised by @davesienkowski (#1671, 2026-06-25). The shipped resolution is **stronger than the option originally proposed** (keying the comparison on stable IDs with `line` retained as non-compared metadata): the committed `ContextIndex.predicates` entries carry **no `line` field at all**. Committed-but-uncompared metadata goes silently stale — the same defect class the drift-guard exists to catch, with the alarm removed — so it was dropped from the committed artifact rather than merely excluded from the comparison. `line` is still returned by the live `parsePredicates`/`gsd-tools query context-predicates` result for callers that want to cite a source location; only the committed `docs/CONTEXT-INDEX.json` shape omits it. **Resolved by other work — not carried as open.** A fourth question was proposed in review (#1671, 2026-06-25): *what populates the eval-gate assertion set, and is it graded exogenously?* Since that review, the answer has landed as first-class predicate classes rather than remaining a design gap: `PROBE.principle` (`verifier-reach-equals-spec-reach`), `PROBE.family` (edge-probe + prohibition-probe + ui-consideration-probe), `PROBE.protocol` (recall → precision), and `PROHIB.judgment-tier` (exogenous grading) — see ADR-550 D4/D7 and ADR-1606. The `PROHIB.*` predicates live in the same `CONTEXT.md` store this ADR formalizes, which is the single-store property that review asked for. diff --git a/docs/contributor-standards.md b/docs/contributor-standards.md index ed41c48d2..69d3c3b41 100644 --- a/docs/contributor-standards.md +++ b/docs/contributor-standards.md @@ -163,6 +163,18 @@ Before any AI agent writes a single line of code or docs, it must read: If you are dispatching an AI agent, include these reads in the agent's prompt explicitly. An agent that invents synonyms for `CONTEXT.md` vocabulary or contradicts an accepted ADR without flagging it has failed the pre-work requirement. +**Citing a machine-oriented predicate in a brief.** `CONTEXT.md`'s `KEY.SUBKEY=value` predicates +(see below) must be cited by ID verbatim, never paraphrased (`META.RULE.brief-must-cite-doc`, +`META.RULE.brief-no-paraphrase`). Rather than grepping the file by eye for the predicate set a +brief needs, pull it with the selector, which parses the live file on every call: + +```bash +node gsd-tools.cjs query context-predicates --class | --prefix | --contains +``` + +See [`query context-predicates`](CLI-TOOLS.md#query-context-predicates) for the full flag and +output reference. + **In the PR body**, state which ADR or standards section was followed. If using an AI assistant, this statement is your responsibility as the author — not the agent's. ### Worktree isolation diff --git a/eslint.config.mjs b/eslint.config.mjs index d859db89b..c07dd8f3a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -234,6 +234,8 @@ export default tseslint.config( 'gsd-core/bin/lib/state-io.cjs', 'gsd-core/bin/lib/external-descriptor-trust.cjs', 'gsd-core/bin/lib/mcp-server.cjs', + // ADR-1671 (#2928): tsc-generated runtime artifact — lint the src/context-predicates.cts source. + 'gsd-core/bin/lib/context-predicates.cjs', ], }, diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index e17a54f75..adbcfb197 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -2605,6 +2605,133 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load } } + // `gsd_run query context-predicates` — selector surface for the CONTEXT.md + // predicate fact-store (ADR-1671, #2928 Phase 1 row S9). Parses the + // repo-root CONTEXT.md LIVE via the compiled context-predicates.cjs on + // every call — it never reads the committed docs/CONTEXT-INDEX.json (that + // artifact is a CI drift-guard byproduct, not a query source, so it can + // never go stale relative to the live predicates it answers about). + // + // Selectors: --class , --prefix , --contains . + // At least one is required. When more than one is given they are ANDed + // together — the same documented precedence selectPredicates() itself + // implements (see context-predicates.cjs doc comment: "Select predicates + // by one or more optional criteria (ANDed together)"); no selector is + // silently dropped or overridden by another. + // + // Flag parsing mirrors routePromptBudget's Map-based flagMap: the three + // known flags are recognized in both the space-separated `--flag value` + // form and the inline-assignment `--flag=value` form (the latter is the + // escape hatch for a flag-shaped selector value, e.g. `--contains=--dry-run` + // — #2928 review finding C; the space-separated form has no such escape by + // design, since a following `--...` token always reads as a missing value). + // `--class=` (empty value) and `--class==A` (double-equals typo shape) + // are rejected the same way under either form. On a duplicate flag the + // FIRST occurrence wins (`Map.set` only fires when the key is absent), + // which is deterministic across repeated invocations with identical argv. + // + // Prototype-pollution safety: selector values are only ever compared via + // `===`/`.startsWith()`/`.includes()` against ordinary string fields — this + // route never uses a user-supplied string as an object property key + // (`obj[userValue] = ...`), so `--class __proto__` / `constructor` / + // `prototype` are just non-matching ordinary strings, not property-access + // vectors. `flagMap` itself is a `Map`, immune to prototype pollution by + // construction. + function routeContextPredicates({ args, cwd, raw, error }) { + const { parsePredicates, selectPredicates } = require('./lib/context-predicates.cjs'); + + const KNOWN_FLAGS = new Set(['--class', '--prefix', '--contains']); + const flagMap = new Map(); + for (let i = 1; i < args.length; i++) { + const current = args[i]; + if (typeof current !== 'string' || !current.startsWith('--')) continue; + + // Inline-assignment escape hatch (`--flag=value`, mirrors the `--config-dir=`/ + // `--runtime=` convention in routeUpdateContext elsewhere in this file). This is + // the ONLY way to pass a flag-shaped selector value (e.g. searching CONTEXT.md + // for the literal substring "--dry-run"): the space-separated form below always + // treats a following `--...` token as a missing value, by design, so it has no + // escape hatch on its own (#2928 review finding C). + const eqFlag = [...KNOWN_FLAGS].find((f) => current.startsWith(`${f}=`)); + if (eqFlag) { + const value = current.slice(eqFlag.length + 1); + // Reject an empty value (`--class=`) and the `--class==A` double-equals typo + // shape (a value starting with `=`) the same way the pre-existing malformed- + // assignment behavior did — never silently accept "=A" as a literal value. + if (value === '' || value.startsWith('=')) { + error(`context-predicates: ${eqFlag} requires a non-empty value`, ERROR_REASON.USAGE); + return; + } + if (!flagMap.has(eqFlag)) flagMap.set(eqFlag, value); + continue; + } + + if (!KNOWN_FLAGS.has(current)) { + error(`Unknown flag for context-predicates: ${current}`, ERROR_REASON.USAGE); + return; + } + const next = args[i + 1]; + if (next === undefined || next.startsWith('--')) { + if (!flagMap.has(current)) flagMap.set(current, null); + continue; + } + if (!flagMap.has(current)) flagMap.set(current, next); + i++; + } + + const hasClass = flagMap.has('--class'); + const hasPrefix = flagMap.has('--prefix'); + const hasContains = flagMap.has('--contains'); + + if (!hasClass && !hasPrefix && !hasContains) { + error( + 'Usage: gsd-tools query context-predicates --class | --prefix | --contains ' + + '(selectors are ANDed when combined)', + ERROR_REASON.USAGE, + ); + return; + } + + const requireNonEmpty = (flagName, rawValue) => { + if (rawValue === null || rawValue === undefined || rawValue.trim() === '') { + error(`context-predicates: ${flagName} requires a non-empty value`, ERROR_REASON.USAGE); + return null; + } + return rawValue; + }; + + const opts = {}; + if (hasClass) { + const v = requireNonEmpty('--class', flagMap.get('--class')); + if (v === null) return; + opts.klass = v; + } + if (hasPrefix) { + const v = requireNonEmpty('--prefix', flagMap.get('--prefix')); + if (v === null) return; + opts.prefix = v; + } + if (hasContains) { + const v = requireNonEmpty('--contains', flagMap.get('--contains')); + if (v === null) return; + opts.contains = v; + } + + const contextMdPath = path.join(__dirname, '..', '..', 'CONTEXT.md'); + let markdown; + try { + markdown = fs.readFileSync(contextMdPath, 'utf8'); + } catch (err) { + error(`context-predicates: cannot read ${contextMdPath}: ${err && err.message}`, ERROR_REASON.USAGE); + return; + } + + const { predicates } = parsePredicates(markdown); + const matches = selectPredicates(predicates, opts); + + output({ matched: matches.length, predicates: matches }, raw); + } + function routeUpdateContext({ args, cwd, raw, error }) { // #498: resolve the installed GSD version, scope, runtime, and config dir // for /gsd:update. Replaces ~280 lines of inline bash in update.md with a @@ -2940,6 +3067,7 @@ const HOST_COMMAND_ROUTERS = { 'restore-custom-files': routeRestoreCustomFiles, 'from-gsd2': routeFromGsd2, 'prompt-budget': routePromptBudget, + 'context-predicates': routeContextPredicates, 'review-lane': routeReviewLane, 'update-context': routeUpdateContext, 'classify-confidence': routeClassifyConfidence, @@ -3143,6 +3271,90 @@ function runWithTimeout(argv) { // ─── CLI Router ─────────────────────────────────────────────────────────────── +// Top-level usage string — emitted by `gsd-tools` (no args) and by +// `gsd-tools --help` / any `--help` request below. +// CR feedback: the command list must enumerate every top-level command +// supported by the dispatcher so `--help` is actually useful for +// discovery; previously it was a partial subset that didn't include +// phase / roadmap / milestone / progress / etc. +// +// Module-scoped (not function-local) so it can be exported and compared +// against HOST_COMMAND_ROUTERS in a parity test (DEFECT.GENERATIVE-FIX) — +// this string and HOST_COMMAND_ROUTERS/SKIP_ROOT_RESOLUTION are three +// independently hand-maintained sites and nothing previously caught them +// drifting apart when a query command was added to only one or two. +const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ] [--ws ] [--json-errors]\n' + + 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' + + 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' + + 'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' + + 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + + 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + + 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + + 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' + + 'config-set-model-profile, dispatch-isolation, dispatch-should-flatten, estimate-calibrate, estimate-calibration, estimate-check, resolve-dispatch-type, ' + + 'resolve-execution, review-lane, skill-manifest, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' + + 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' + + 'Global flags:\n' + + ' --raw Emit raw output without post-processing\n' + + ' --pick Extract a single field from JSON output (dot/bracket notation)\n' + + ' --cwd Override working directory for project-root resolution\n' + + ' --ws Override active workstream (or set GSD_WORKSTREAM)\n' + + ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' + + 'For command-specific argument requirements, invoke the command without args ' + + '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.'; + +// Multi-repo guard: resolve project root for commands that read/write .planning/. +// Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary +// filesystem traversal on every invocation. +// 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION. +// Both are registry/config queries that resolve activation via +// .planning/config.json; they need the project root (cwd) for correct +// `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION, +// move the other at the same time (keep them consistent). +// +// Module-scoped for the same reason as TOP_LEVEL_USAGE above — kept +// module-private and exposed to the dispatch-table/help-string/skip-list +// parity test only through the read-only skipsRootResolution() predicate +// below (never as the live Set itself; see that function's doc comment). +const SKIP_ROOT_RESOLUTION = new Set([ + 'generate-slug', 'current-timestamp', 'verify-path-exists', + // #2844: verify-summary was previously skipped, leaving relative file-claim + // paths resolved against the raw process.cwd() — invoking from a subdirectory + // manufactured "missing files" on an otherwise-correct SUMMARY. It now goes + // through findProjectRoot so claims resolve against the project root. + 'template', 'frontmatter', 'detect-custom-files', + // #1854: restore-custom-files operates on a runtime config dir passed + // explicitly via --config-dir; it never reads .planning/. + 'restore-custom-files', + 'worktree', 'prompt-budget', + // context-predicates is a pure repo-root CONTEXT.md read (like + // prompt-budget); it never touches .planning/, so it needs no project + // root resolution and must work from any cwd (including one with no + // .planning/ directory at all). + 'context-predicates', + 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence', + 'user-story', // pure string validation — no .planning/ access needed + // #1529: pure runtime→filename projection via getProjectInstructionFile; no + // .planning/ access needed, and resolving project root would break workflow + // invocations that run before .planning/ exists (new-project Step 1). + 'project-instruction-file', + // #1579: eval.score is pure arithmetic (covered/total + infra weights); it + // needs no .planning/ access, so skip the findProjectRoot traversal. + 'eval', +]); + +// Read-only accessor for SKIP_ROOT_RESOLUTION (DEFECT.MUTABLE-EXPORTED-SET, +// #2928 review). The Set above stays module-private and mutable internally +// (main() only ever calls .has() on it), but exporting the live Set directly +// would let any importer call .add()/.delete() on it — Object.freeze() does +// not lock Set.prototype.add/delete, so freezing the instance would not have +// closed this — and silently change dispatch behavior for every caller in the +// process. Export this predicate instead; it exposes membership without +// exposing a mutation surface. +function skipsRootResolution(command) { + return SKIP_ROOT_RESOLUTION.has(command); +} + async function main() { let args = process.argv.slice(2); @@ -3276,30 +3488,6 @@ async function main() { } } - // Top-level usage string — emitted by `gsd-tools` (no args) and by - // `gsd-tools --help` / any `--help` request below. - // CR feedback: the command list must enumerate every top-level command - // supported by the dispatcher so `--help` is actually useful for - // discovery; previously it was a partial subset that didn't include - // phase / roadmap / milestone / progress / etc. - const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ] [--ws ] [--json-errors]\n' + - 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' + - 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' + - 'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' + - 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + - 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + - 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + - 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' + - 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' + - 'Global flags:\n' + - ' --raw Emit raw output without post-processing\n' + - ' --pick Extract a single field from JSON output (dot/bracket notation)\n' + - ' --cwd Override working directory for project-root resolution\n' + - ' --ws Override active workstream (or set GSD_WORKSTREAM)\n' + - ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' + - 'For command-specific argument requirements, invoke the command without args ' + - '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.'; - if (!command) { error(TOP_LEVEL_USAGE); } @@ -3326,35 +3514,6 @@ async function main() { } } - // Multi-repo guard: resolve project root for commands that read/write .planning/. - // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary - // filesystem traversal on every invocation. - // 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION. - // Both are registry/config queries that resolve activation via - // .planning/config.json; they need the project root (cwd) for correct - // `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION, - // move the other at the same time (keep them consistent). - const SKIP_ROOT_RESOLUTION = new Set([ - 'generate-slug', 'current-timestamp', 'verify-path-exists', - // #2844: verify-summary was previously skipped, leaving relative file-claim - // paths resolved against the raw process.cwd() — invoking from a subdirectory - // manufactured "missing files" on an otherwise-correct SUMMARY. It now goes - // through findProjectRoot so claims resolve against the project root. - 'template', 'frontmatter', 'detect-custom-files', - // #1854: restore-custom-files operates on a runtime config dir passed - // explicitly via --config-dir; it never reads .planning/. - 'restore-custom-files', - 'worktree', 'prompt-budget', - 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence', - 'user-story', // pure string validation — no .planning/ access needed - // #1529: pure runtime→filename projection via getProjectInstructionFile; no - // .planning/ access needed, and resolving project root would break workflow - // invocations that run before .planning/ exists (new-project Step 1). - 'project-instruction-file', - // #1579: eval.score is pure arithmetic (covered/total + infra weights); it - // needs no .planning/ access, so skip the findProjectRoot traversal. - 'eval', - ]); if (!SKIP_ROOT_RESOLUTION.has(command)) { cwd = findProjectRoot(cwd); } @@ -3511,5 +3670,13 @@ if (require.main === module) { // synthetic registry + requireModule injections. // ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for // the third-party overlay dispatch + install-root confinement tests. -module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot, dispatchHostCommand, HOST_COMMAND_ROUTERS }; +module.exports = { + dispatchCapabilityCommand, + dispatchOverlayCapabilityCommand, + defaultRequireFromInstallRoot, + dispatchHostCommand, + HOST_COMMAND_ROUTERS, + TOP_LEVEL_USAGE, + skipsRootResolution, +}; diff --git a/package.json b/package.json index 27bee7a4a..8d6384a8d 100644 --- a/package.json +++ b/package.json @@ -84,16 +84,17 @@ "check:identity-drift": "node scripts/lint-package-identity-drift.cjs", "check:phase-id-drift": "node scripts/lint-phase-id-drift.cjs", "check:integrity": "node scripts/check-npm-integrity.cjs", - "build": "npm run generate:identity && npm run build:lib && npm run gen:plugin-skills && npm run gen:loop-host-contract && npm run gen:capability-registry && npm run build:hooks", + "build": "npm run generate:identity && npm run build:lib && npm run gen:context-index && npm run gen:plugin-skills && npm run gen:loop-host-contract && npm run gen:capability-registry && npm run build:hooks", "build:hooks": "node scripts/build-hooks.js", "build:lib": "tsc -p tsconfig.build.json", "generate:identity": "node scripts/generate-package-identity.cjs", + "gen:context-index": "node scripts/gen-context-index.cjs --write", "gen:loop-host-contract": "node scripts/gen-loop-host-contract.cjs --write", "gen:plugin-skills": "node scripts/gen-plugin-skills.cjs --write", "gen:capability-registry": "node scripts/gen-capability-registry.cjs --write", "gen:registry": "node scripts/gen-registry.cjs --write", "gen:install-tree": "node scripts/gen-install-tree-fixtures.cjs", - "regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree", + "regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/gen-context-index.cjs --write && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree", "validate:registry": "node scripts/validate-registry.cjs", "prepack": "npm run build:lib", "prepare": "npm run build:lib", @@ -112,7 +113,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 && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.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 && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.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", diff --git a/scripts/gen-context-index.cjs b/scripts/gen-context-index.cjs new file mode 100644 index 000000000..aa6e512cf --- /dev/null +++ b/scripts/gen-context-index.cjs @@ -0,0 +1,448 @@ +#!/usr/bin/env node +'use strict'; + +/** + * gen-context-index.cjs — generates docs/CONTEXT-INDEX.json from the + * predicate declarations (`` `CLASS.subkey=value` `` lines) in the + * repo-root CONTEXT.md. + * + * Usage: + * node scripts/gen-context-index.cjs # print to stdout + * node scripts/gen-context-index.cjs --write # write docs/CONTEXT-INDEX.json + * node scripts/gen-context-index.cjs --check # exit 1 if committed file is stale + * node scripts/gen-context-index.cjs --check --json # same, + typed report on stdout + * node scripts/gen-context-index.cjs --write --context-path

--index-path

+ * # override the two hardcoded + * # repo-root paths (tests use + * # this to point the real CLI at + * # a temp fixture tree with no fs + * # monkeypatching required) + * + * ADR-1671 ("Dynamic context management platform", #2928) Phase 1 commits 1+3. + * The generated artifact is plain JSON (docs/CONTEXT-INDEX.json), mirroring + * docs/INVENTORY-MANIFEST.json's precedent: a committed, generated, + * `--check`-guarded JSON manifest that is NOT runtime code. It previously + * lived at gsd-core/bin/lib/context-index.cjs — a shipped runtime module is + * the wrong place for ~120 KB of arbitrary CONTEXT.md prose: it tripped both + * tests/cline-install.test.cjs's leaked-`.claude`-path guard and + * tests/package-name-single-source.test.cjs's hardcoded-package-name guard, + * both true positives against runtime-code content scanning. Moving the + * artifact to docs/ (never scanned as runtime code) fixes both without + * weakening either guard. + * + * Depends on the COMPILED gsd-core/bin/lib/context-predicates.cjs + * (src/context-predicates.cts, built by `npm run build:lib`). This is safe + * for CI: `.github/workflows/test.yml` runs `build:lib` before `lint:ci`. + * + * `--check --json` (CONTRIBUTING.md "Prohibited: Raw Text Matching on Test + * Outputs"): emits `{ ok, reason, duplicates, count, classes }` — `reason` is + * always one of the frozen `REASON` values (exported below) so tests assert + * `report.reason === REASON.FAIL_X` instead of regex-matching the prose the + * non-JSON mode still prints for human operators. The human-readable output + * is unchanged. + */ + +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_PREDICATES_LIB_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'context-predicates.cjs'); +const CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md'); +const INDEX_PATH = path.join(ROOT, 'docs', 'CONTEXT-INDEX.json'); + +// ─── Typed reason enum (CONTRIBUTING.md "Prohibited: Raw Text Matching") ─────── + +/** + * Stable reason codes for `checkReport`'s `reason` field. Tests assert via + * `assert.equal(report.reason, REASON.X)` rather than regex-matching the + * human-readable prose the non-JSON `--check` mode still writes to + * stdout/stderr, so the diagnostic surface is a typed enum, not free text. + * + * Adding a new reason requires updating this map AND the tests' shape + * assertion that locks the documented set of codes + * (`Object.keys(REASON).sort()`). + */ +const REASON = Object.freeze({ + OK_UP_TO_DATE: 'ok_up_to_date', + FAIL_STALE: 'fail_stale', + FAIL_INDEX_MISSING: 'fail_index_missing', + FAIL_INDEX_UNPARSEABLE: 'fail_index_unparseable', + FAIL_DUPLICATE_IDS: 'fail_duplicate_ids', + FAIL_CONTEXT_MISSING: 'fail_context_missing', + FAIL_CONTEXT_UNREADABLE: 'fail_context_unreadable', + FAIL_LIB_NOT_BUILT: 'fail_lib_not_built', +}); + +// ─── Loaders ────────────────────────────────────────────────────────────────── + +/** + * Load the compiled context-predicates library. The artifact is a gitignored + * tsc build output of src/context-predicates.cts and only exists after + * `npm run build:lib`. Throws a clean ExitError (never a bare + * MODULE_NOT_FOUND stack) naming the remedy when it is missing. + * + * @returns {{ parsePredicates: Function, selectPredicates: Function, buildIndex: Function }} + */ +function loadContextPredicatesLib() { + try { + delete require.cache[require.resolve(CONTEXT_PREDICATES_LIB_PATH)]; + return require(CONTEXT_PREDICATES_LIB_PATH); + } catch (err) { + throw new ExitError( + 1, + `Cannot load ${path.relative(ROOT, CONTEXT_PREDICATES_LIB_PATH)}: ${err && err.message}\n` + + 'Run:\n npm run build:lib\n', + ); + } +} + +/** + * Read a CONTEXT.md-shaped markdown file. Throws a clean ExitError naming the + * path (never a bare stack trace) when it is missing or unreadable. + * + * @param {string} [contextPath] - defaults to the real repo-root CONTEXT.md. + * @returns {string} + */ +function readContextMarkdown(contextPath = CONTEXT_PATH) { + try { + return fs.readFileSync(contextPath, 'utf8'); + } catch (err) { + throw new ExitError(1, `Cannot read ${path.relative(ROOT, contextPath)}: ${err && err.message}`); + } +} + +/** + * Build a fresh ContextIndex from the given CONTEXT.md-shaped content. + * + * @param {string} [contextPath] - defaults to the real repo-root CONTEXT.md. + * @returns {object} + */ +function buildFreshIndex(contextPath = CONTEXT_PATH) { + const { parsePredicates, buildIndex } = loadContextPredicatesLib(); + const markdown = readContextMarkdown(contextPath); + const { predicates } = parsePredicates(markdown); + return buildIndex(predicates); +} + +// ─── Serialization ──────────────────────────────────────────────────────────── + +/** + * Serialize a ContextIndex to the committed plain-JSON artifact text + * (docs/CONTEXT-INDEX.json). Plain JSON — not a CommonJS module — because + * this is a generated data manifest (mirroring docs/INVENTORY-MANIFEST.json), + * not runtime code: it must never be `require()`-able from a shipped + * gsd-core/bin/lib/*.cjs module, which is exactly the mistake that leaked + * ~120 KB of CONTEXT.md prose (including `.claude/hooks/...` path literals + * and hardcoded package-name strings) into runtime-code content scanning. + * + * @param {object} index + * @returns {string} + */ +function serializeIndex(index) { + return JSON.stringify(index, null, 2) + '\n'; +} + +/** + * Normalize line endings to LF for CRLF-agnostic full-content comparison. + * + * @param {string} content + * @returns {string} + */ +function normalizeLineEndings(content) { + return content.replace(/\r/g, ''); +} + +/** + * Extract the sorted list of duplicate predicate ids from a built index. + * + * @param {{ duplicates: Array<{ id: string, count: number }> }} index + * @returns {string[]} + */ +function duplicateIds(index) { + return index.duplicates.map((d) => d.id); +} + +// ─── Typed check report ─────────────────────────────────────────────────────── + +/** + * Empty-report shape shared by every early-exit branch below, so callers + * (JSON mode, tests) always see the same four data fields regardless of + * which reason fired. + * + * @returns {{ duplicates: Array, count: number, classes: object }} + */ +function emptyReportFields() { + return { duplicates: [], count: 0, classes: {} }; +} + +/** + * Compute the full `--check` result as a typed, non-throwing report — the + * structured intermediate representation CONTRIBUTING.md's "Prohibited: Raw + * Text Matching on Test Outputs" section requires alongside the human prose + * `main()` still prints. Never throws; every failure mode is a `reason` code + * from the frozen `REASON` enum. + * + * @param {string} [contextPath] - defaults to the real repo-root CONTEXT.md. + * @param {string} [indexPath] - defaults to the real committed index. + * @returns {{ ok: boolean, reason: string, duplicates: Array<{id:string,count:number}>, count: number, classes: object, message: string }} + */ +function checkReport(contextPath = CONTEXT_PATH, indexPath = INDEX_PATH) { + let markdown; + try { + markdown = fs.readFileSync(contextPath, 'utf8'); + } catch (err) { + const reason = err && err.code === 'ENOENT' ? REASON.FAIL_CONTEXT_MISSING : REASON.FAIL_CONTEXT_UNREADABLE; + return { + ok: false, + reason, + ...emptyReportFields(), + message: `Cannot read ${path.relative(ROOT, contextPath)}: ${err && err.message}`, + }; + } + + let parsePredicates; + let buildIndex; + try { + delete require.cache[require.resolve(CONTEXT_PREDICATES_LIB_PATH)]; + ({ parsePredicates, buildIndex } = require(CONTEXT_PREDICATES_LIB_PATH)); + } catch (err) { + return { + ok: false, + reason: REASON.FAIL_LIB_NOT_BUILT, + ...emptyReportFields(), + message: `Cannot load ${path.relative(ROOT, CONTEXT_PREDICATES_LIB_PATH)}: ${err && err.message}\n` + + 'Run:\n npm run build:lib\n', + }; + } + + const { predicates } = parsePredicates(markdown); + const live = buildIndex(predicates); + const dups = duplicateIds(live); + + if (dups.length > 0) { + return { + ok: false, + reason: REASON.FAIL_DUPLICATE_IDS, + duplicates: live.duplicates, + count: live.count, + classes: live.classes, + message: 'CONTEXT.md has duplicate predicate id(s): ' + dups.join(', ') + '\n' + + 'Each predicate id must be declared exactly once.\n', + }; + } + + if (!fs.existsSync(indexPath)) { + return { + ok: false, + reason: REASON.FAIL_INDEX_MISSING, + duplicates: live.duplicates, + count: live.count, + classes: live.classes, + message: `${path.relative(ROOT, indexPath)} does not exist. Run:\n node scripts/gen-context-index.cjs --write\n`, + }; + } + + let committedIndex; + try { + const committedText = fs.readFileSync(indexPath, 'utf8'); + committedIndex = JSON.parse(committedText); + if (!committedIndex || typeof committedIndex !== 'object' || !Array.isArray(committedIndex.predicates)) { + throw new Error('parsed JSON does not have the expected ContextIndex shape'); + } + } catch (err) { + return { + ok: false, + reason: REASON.FAIL_INDEX_UNPARSEABLE, + duplicates: live.duplicates, + count: live.count, + classes: live.classes, + message: `${path.relative(ROOT, indexPath)} is unparseable: ${err && err.message}\n` + + 'Run:\n node scripts/gen-context-index.cjs --write\n', + }; + } + + // Comparing parsed JSON (rather than raw file text) is inherently + // CRLF-agnostic: JSON.parse treats \r\n and \n as equivalent insignificant + // whitespace between tokens, and predicate values never contain embedded + // newlines (the parser only extracts single-physical-line declarations). + if (JSON.stringify(committedIndex) !== JSON.stringify(live)) { + return { + ok: false, + reason: REASON.FAIL_STALE, + duplicates: live.duplicates, + count: live.count, + classes: live.classes, + message: `${path.relative(ROOT, indexPath)} is stale. Run:\n node scripts/gen-context-index.cjs --write\n`, + }; + } + + return { + ok: true, + reason: REASON.OK_UP_TO_DATE, + duplicates: live.duplicates, + count: live.count, + classes: live.classes, + message: `${path.relative(ROOT, indexPath)} is up to date.\n`, + }; +} + +// ─── Argument parsing ───────────────────────────────────────────────────────── + +/** + * True when `value` cannot be accepted as a `--context-path`/`--index-path` + * argument: absent (no more argv), empty string, or flag-shaped (starts with + * `-`, so a dangling `--context-path` immediately followed by the NEXT flag + * is rejected rather than silently swallowing that flag as a literal path). + * + * @param {string|undefined} value + * @returns {boolean} + */ +function isMissingPathValue(value) { + return value === undefined || value === '' || value.startsWith('-'); +} + +/** + * Parse CLI arguments into a structured options object. + * + * `--context-path` / `--index-path` override the two hardcoded repo-root + * paths — added so tests can point the real CLI at a temp fixture tree + * directly, with no fs monkeypatching required. + * + * Two usage-error conditions (DEFECT.GEN-CONTEXT-INDEX-PARSEARGS-GATE-BYPASS, + * MAJOR review finding) collapse into `mode: 'unknown'`, the same clean, + * no-stack-trace usage-error path `main()` already uses for an unrecognized + * flag: + * (a) `--check` and `--write` given together — previously the LAST one + * seen silently won, so `--check --write` exited 0 and REWROTE the + * committed index instead of gating. Conflicting mode flags are now a + * hard usage error regardless of order. + * (b) a missing/empty/flag-shaped value for `--context-path` / + * `--index-path` — previously `path.resolve(argv[++i] ?? '')` + * resolved to the current working directory, and in `--write` mode + * that later threw an uncaught, uncleaned `EISDIR` stack trace from + * `fs.writeFileSync` (CONTRIBUTING.md: no stack trace in non-debug + * failure output). Rejected up front instead, before any I/O. + * + * @param {string[]} argv - process.argv.slice(2) + * @returns {{ mode: 'check'|'write'|'default'|'unknown', json: boolean, contextPath: string, indexPath: string, unknownArg?: string, usageMessage?: string }} + */ +function parseArgs(argv) { + const opts = { mode: 'default', json: false, contextPath: CONTEXT_PATH, indexPath: INDEX_PATH }; + let sawCheck = false; + let sawWrite = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--check') { + sawCheck = true; + opts.mode = 'check'; + } else if (arg === '--write') { + sawWrite = true; + opts.mode = 'write'; + } else if (arg === '--json') { + opts.json = true; + } else if (arg === '--context-path' || arg === '--index-path') { + const value = argv[i + 1]; + if (isMissingPathValue(value)) { + return { + ...opts, + mode: 'unknown', + unknownArg: arg, + usageMessage: `${arg} requires a non-empty path argument (got ${value === undefined ? 'nothing' : JSON.stringify(value)})`, + }; + } + i++; + if (arg === '--context-path') opts.contextPath = path.resolve(value); + else opts.indexPath = path.resolve(value); + } else { + opts.mode = 'unknown'; + opts.unknownArg = arg; + } + } + + if (sawCheck && sawWrite) { + return { + ...opts, + mode: 'unknown', + usageMessage: '--check and --write are mutually exclusive', + }; + } + + return opts; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.mode === 'unknown') { + process.stderr.write('Usage: gen-context-index.cjs [--write|--check] [--json] [--context-path ] [--index-path ]\n'); + if (opts.usageMessage) process.stderr.write(`${opts.usageMessage}\n`); + throw new ExitError(1); + } + + if (opts.mode === 'default') { + process.stdout.write(serializeIndex(buildFreshIndex(opts.contextPath)) + '\n'); + return; + } + + if (opts.mode === 'check') { + const report = checkReport(opts.contextPath, opts.indexPath); + + if (opts.json) { + process.stdout.write(JSON.stringify({ + ok: report.ok, + reason: report.reason, + duplicates: report.duplicates, + count: report.count, + classes: report.classes, + }) + '\n'); + } else if (report.ok) { + process.stdout.write(report.message); + } + + if (!report.ok) { + // JSON mode already carries the structured report on stdout — do not + // duplicate the prose onto stderr, but the exit code must still be 1. + throw new ExitError(1, opts.json ? undefined : report.message); + } + return; + } + + // opts.mode === 'write' + const index = buildFreshIndex(opts.contextPath); + fs.mkdirSync(path.dirname(opts.indexPath), { recursive: true }); + fs.writeFileSync(opts.indexPath, serializeIndex(index), 'utf8'); + const dupCount = index.duplicates.length; + process.stdout.write( + `Wrote ${path.relative(ROOT, opts.indexPath)}\n` + + ` ${index.count} predicates, ${Object.keys(index.classes).length} classes, ` + + `${dupCount} duplicate id${dupCount !== 1 ? 's' : ''}\n`, + ); +} + +// ─── Exports (for tests) ────────────────────────────────────────────────────── + +module.exports = { + loadContextPredicatesLib, + readContextMarkdown, + buildFreshIndex, + serializeIndex, + normalizeLineEndings, + duplicateIds, + checkReport, + parseArgs, + REASON, + CONTEXT_PREDICATES_LIB_PATH, + CONTEXT_PATH, + INDEX_PATH, +}; + +// ─── CLI entry point ────────────────────────────────────────────────────────── + +if (require.main === module) { + runMain(main); +} diff --git a/src/context-predicates.cts b/src/context-predicates.cts new file mode 100644 index 000000000..29ca4970f --- /dev/null +++ b/src/context-predicates.cts @@ -0,0 +1,542 @@ +/** + * Context Predicates — CONTEXT.md predicate fact-store parser. + * + * Ported from the reference prototype + * `examples/dynamic-context-management/context-predicates.cjs` (ADR-1671, + * "Dynamic context management platform", Option-E predicate fact-store). + * Behavior is preserved BYTE-FOR-BEHAVIOR from the prototype for the parts it + * shares: `ID_RE`, the first-`=` split, the naive triple-backtick fence + * toggle, and the `- ` list-item-only form. Known prototype defects are + * DELIBERATELY carried forward here — a later commit fixes them behind a + * failing-first test. + * + * Two intentional deviations from the prototype (ADR-1671 open question 4): + * - `Duplicate` carries `count`, not `lines: number[]`. + * - `ContextIndex.predicates` entries carry `{id, klass, value}` with NO + * `line` field — a committed artifact with no line numbers cannot drift + * on a line shift. The live parse result (`Predicate`) still carries + * `line` and `section`. + * + * One addition beyond the prototype: `ParseResult.malformed` collects + * backtick lines that look like a predicate declaration but are rejected for + * having an empty value (e.g. `` `ID=` ``), so the empty-value case is + * surfaced as a diagnostic instead of being silently dropped. This does not + * change any accept/reject outcome — only adds a diagnostic. + * + * Grammar (from discovery facts): + * Two line forms, each on exactly one source line: + * 1. Bare backtick-wrapped, optionally indented: `ID=value`, ` `ID=value`` + * 2. List-item backtick: `-`/`*`/`+`/`N.` marker followed by `ID=value` + * + * ID grammar: CLASS(.subkey)* where CLASS = first dot-separated segment. + * ID chars: [A-Za-z0-9._-] (CLASS always uppercase; subkeys may be mixed). + * Split on FIRST '=' only; everything before is the ID, everything after is + * the value (up to the closing backtick). + * + * Skip: + * - Fenced code blocks: ``` or ~~~, fence-length- and fence-char-aware + * (a longer fence containing a shorter same-char fence line stays a + * single skipped region; mismatched-char lines are fence content, not + * a toggle) + * - HTML comments (``), including multi-line + * - Prose lines (headings, blank lines, list items without a predicate) + * - Blockquote lines (session-log preamble, etc.) + * + * Fence-length-awareness note: `src/markdown-sectionizer.cts`'s + * `stripFencedCode` is the repo's canonical CommonMark fence-stripper, but its + * `StripFencedResult.text` DROPS fence delimiter and content lines from the + * output — it does not preserve original line numbers. This parser reports + * `Predicate.line`/`Malformed.line` as 1-based SOURCE line numbers, which + * callers assert on — so line-accurate skip detection is required, and + * `stripFencedCode` cannot serve it directly. + * + * Comment/fence precedence (DEFECT.CONTEXT-PREDICATES-COMMENT-FENCE-BLIND, + * #2928 review): `computeSkippedLineFlags` previously ran the HTML-comment + * scan and a delegated call to `markdown-sectionizer.cts`'s exported + * `scanFencedBlocks` seam as two INDEPENDENT passes over the raw lines. That + * is unsound: `scanFencedBlocks` is comment-blind, so a fence delimiter + * appearing INSIDE an HTML comment (with no later matching close in the + * file) was treated as a real *unterminated* fence — silently skipping every + * remaining line to EOF and permanently dropping later predicates. The + * converse is also unsound the other way: a bare two-pass ordering that + * resolves comments first and only then masks-and-rescans for fences + * mis-handles a `` token that appears *inside a genuine fenced + * block* (proven while fixing this: guarding the comment pass by a + * comment-blind fence pass, or vice versa, always breaks one of the two + * directions — the two constructs must suppress each other's + * open/close detection while active, which only a single interleaved + * left-to-right pass can guarantee). + * + * Chosen precedence (documented per the review's requirement): the two + * constructs are scanned in ONE forward pass with two mutually-exclusive + * states, `fence: {char,len} | null` and `inHtmlComment: boolean`. + * - While a fence is open, only a fence-close delimiter (same char, run + * length >= the opener's) can close it; any `` token on a + * fenced-content line is fence content, never a comment boundary. + * - While NEITHER is open and an HTML comment opens, only a `-->` token + * can close it; any fence delimiter seen while inside a comment is + * comment content, never a fence boundary. + * - When neither is open, a comment opener (`` never spuriously opens/closes + * anything once the line has already been claimed as a fence opener (the + * inverse: a genuine one-line comment `` is masked whole and + * never interpreted as a fence delimiter). + * This single-pass design reuses `markdown-sectionizer.cts`'s exact + * delimiter-matching rule (same regex, same `len >= open.len` / + * mismatched-char-is-content / backtick-info-string-cannot-contain-backtick + * semantics as `scanFencedBlocks`), so non-comment-interacting documents are + * byte-for-byte identical to delegating to `scanFencedBlocks` (see + * `tests/context-predicates.test.cjs`'s fence-skip parity suite) — the + * interleaving is required ONLY to resolve the comment/fence interaction, + * not to change fence semantics themselves. This is therefore a second, + * necessarily local copy of the (tiny) delimiter-match condition — the + * scanFencedBlocks seam cannot serve both scans at once, because the correct + * boundary decision for either construct depends on the OTHER construct's + * live state at that exact line, not just on a static, comment-blind + * pre-scan of the raw lines. + * + * ADR-457 build-at-publish: compiled by tsc to + * gsd-core/bin/lib/context-predicates.cjs (gitignored). + */ + +/** A single parsed predicate fact from CONTEXT.md. */ +export interface Predicate { + id: string; + klass: string; + value: string; + line: number; + section: string; +} + +/** A predicate id that occurs more than once. */ +export interface Duplicate { + id: string; + count: number; +} + +/** A backtick-wrapped line that looked like a predicate declaration but was rejected. */ +export interface Malformed { + line: number; + text: string; + reason: string; +} + +/** Result of parsing a CONTEXT.md markdown string for predicates. */ +export interface ParseResult { + predicates: Predicate[]; + duplicates: Duplicate[]; + malformed: Malformed[]; + skippedSections: string[]; +} + +/** Criteria for {@link selectPredicates}, ANDed together. */ +export interface SelectOptions { + klass?: string; + prefix?: string; + contains?: string; +} + +/** A deterministic, committed index entry — no `line` (see module doc). */ +export interface ContextIndexPredicate { + id: string; + klass: string; + value: string; +} + +/** Deterministic index built from a parsed predicates array. */ +export interface ContextIndex { + schemaVersion: 1; + count: number; + classes: Record; + predicates: ContextIndexPredicate[]; + duplicates: Duplicate[]; +} + +// ID grammar, validated STRUCTURALLY rather than by a single regex +// (DEFECT.CONTEXT-PREDICATES-ID-REDOS, #2928 review). The formerly-used regex +// `^([A-Z][A-Z0-9_-]*(?:\.[A-Za-z0-9_.-]+)*)=(.+)$` is exponential: the +// group `(?:\.[A-Za-z0-9_.-]+)*` is ambiguous because its own character +// class contains `.`, so N consecutive dots have exponentially many +// backtick-partitionings for the regex engine to try on a failed match +// (measured: ~565ms for 40 consecutive dots, doubling roughly every 5). +// `.github/workflows/test.yml` runs `lint:ci` -> `lint:generated-sync` -> +// `gen-context-index.cjs --check` on `pull_request`, which parses the PR's +// own CONTEXT.md — so any external contributor could hang the shared CI +// runner with one crafted line, no write access required. +// +// Fix: split the candidate id on '.' and validate each segment with a +// simple, non-backtracking, per-segment pattern — linear in id length, no +// ambiguous quantifier. First segment (CLASS) must start with an uppercase +// letter; subsequent segments may start with letter/digit and include +// hyphens/underscores. We intentionally allow lowercase-starting +// sub-segments (e.g. PRED.k320.rule). +// +// Behavior change vs. the old regex: an EMPTY segment (a doubled dot, e.g. +// `A..b`) now REJECTS — the old regex accepted it because `.` was inside the +// subsequent-segment character class, so `.` itself could satisfy +// `[A-Za-z0-9_.-]+` with a single character. The real repo CONTEXT.md was +// checked (`grep -nE '\`[A-Z][A-Za-z0-9_.-]*\.\.[A-Za-z0-9_.-]*=' +// CONTEXT.md`) and contains ZERO ids with a doubled dot, so rejecting the +// empty-segment case is the correct, stricter grammar with no behavior loss +// against real data (pinned by a dedicated test below). +const ID_FIRST_SEGMENT_RE = /^[A-Z][A-Z0-9_-]*$/; +const ID_SUBSEQUENT_SEGMENT_RE = /^[A-Za-z0-9_-]+$/; + +/** + * Structurally validate a candidate predicate id (linear time — no ambiguous + * backtracking quantifier; see the ID grammar comment above). + * + * @param id - candidate id (everything before the first '=') + */ +function isValidId(id: string): boolean { + const segments = id.split('.'); + if (!ID_FIRST_SEGMENT_RE.test(segments[0])) return false; + for (let i = 1; i < segments.length; i++) { + if (!ID_SUBSEQUENT_SEGMENT_RE.test(segments[i])) return false; + } + return true; +} + +// List markers recognized ahead of a backtick-wrapped declaration: +// `-`, `*`, `+`, or a numbered marker (`1.`, `42.`), each followed by +// whitespace. Mirrors the marker family `iterateBullets`/`updateBullet` +// (markdown-sectionizer.cts) recognize, widened here beyond the +// prototype-carried-forward dash-only form (ADR-1671 Phase 1 commit 3). +const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)[ \t]+/; + +/** + * Strip a source line down to its backtick-wrapped "inner" content, if any. + * Handles both line forms: + * 1. Bare backtick line, optionally indented: `ID=value`, ` `ID=value`` + * 2. List-item backtick, any of `-`/`*`/`+`/`N.`, optionally indented: + * `- `ID=value``, `* `ID=value``, `+ `ID=value``, `1. `ID=value`` + * + * @param raw - the original source line (with newline stripped) + * @returns the inner content between the backticks, or null if the line is + * not backtick-wrapped in either recognized form + */ +function extractInner(raw: string): string | null { + const line = raw.trimEnd(); + + // Bare backtick-wrapped, tolerating leading indentation — shape decides + // the bare form, not column 0 (Postel: CONTEXT.md authors indent freely). + const bareTrimmed = line.replace(/^[ \t]+/, ''); + if (bareTrimmed.startsWith('`') && bareTrimmed.endsWith('`') && bareTrimmed.length > 2) { + return bareTrimmed.slice(1, -1); + } + + // List-item form: strip optional leading whitespace + list marker, then + // check for backtick wrapping. `stripped !== line` guards against a line + // with no marker at all (LIST_MARKER_RE.replace would otherwise no-op and + // re-check the same failed bare-form test). + const stripped = line.replace(LIST_MARKER_RE, ''); + if (stripped !== line && stripped.startsWith('`') && stripped.endsWith('`') && stripped.length > 2) { + return stripped.slice(1, -1); + } + + return null; +} + +/** + * Parse a single source line and return a raw {id, value} if it is a + * predicate, or null otherwise. + * + * @param raw - the original source line (with newline stripped) + */ +function extractPredicate(raw: string): { id: string; value: string } | null { + const inner = extractInner(raw); + if (inner === null) return null; + + // Split on FIRST '=' only. + const eqIdx = inner.indexOf('='); + if (eqIdx < 1) return null; + + const id = inner.slice(0, eqIdx); + const value = inner.slice(eqIdx + 1); + + // Value must be non-empty (the old ID_RE's trailing `(.+)$` requirement) + // and must contain no embedded ECMAScript LineTerminator character (LF, + // CR, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR) -- the old + // regex's `.` metachar excludes exactly those four characters and carried + // no `s`/`m` flag, so a value spanning an embedded \r (possible only via + // the documented lone-CR limit: a "line" with no real \n at all still + // carries a mid-string \r joining what the author intended as two + // separate lines) could never satisfy `(.+)$`. Preserved byte-for-behavior + // here so `yieldsNoPredicatesForLoneCrDocumentAsDocumentedLimit` stays + // pinned. And the id must match the structural grammar (no spaces, + // correct char set, no empty segment -- see isValidId's doc comment). + if (value === '' || /[\n\r\u2028\u2029]/.test(value) || !isValidId(id)) return null; + + return { id, value }; +} + +/** + * Detect the "looks like a declaration but has an empty value" malformed + * case for a line that {@link extractPredicate} already rejected. Only + * fires when the ID portion is grammatically valid on its own and the value + * after the first '=' is empty (e.g. `` `ID=` ``). Does not change any + * accept/reject decision — diagnostic only. + * + * @param raw - the original source line (with newline stripped) + */ +function detectMalformed(raw: string): { text: string; reason: string } | null { + const inner = extractInner(raw); + if (inner === null) return null; + + const eqIdx = inner.indexOf('='); + if (eqIdx < 1) return null; + + const id = inner.slice(0, eqIdx); + const value = inner.slice(eqIdx + 1); + + if (value === '' && isValidId(id)) { + return { text: raw.trimEnd(), reason: 'empty-value' }; + } + + return null; +} + +// Fence delimiter line matcher — mirrors `markdown-sectionizer.cts`'s +// `scanFencedBlocks` regex exactly (≥3 backticks/tildes, ≤3-space indent +// tolerance). Kept local so the single interleaved pass below can decide, +// line by line, whether a delimiter is a REAL fence boundary given the +// comment state AT THAT LINE — see the module doc comment's "Comment/fence +// precedence" section for why this can't be a call-then-mask over +// `scanFencedBlocks`'s output. +const FENCE_DELIM_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/; + +/** + * Compute, per source line, whether that line falls inside a fenced code + * block or an HTML comment (``, single- or multi-line). + * LINE-PRESERVING: returns one boolean per input line (no lines dropped or + * collapsed) — see the module doc comment for why that distinction is + * load-bearing here. + * + * Single interleaved forward pass over two mutually-exclusive states — + * `fence` (open fence delimiter char + run length, or null) and + * `inHtmlComment` — so each construct suppresses the OTHER's open/close + * detection while it is active (module doc comment's "Comment/fence + * precedence"). This is the fix for DEFECT.CONTEXT-PREDICATES-COMMENT-FENCE- + * BLIND: a fence delimiter inside a real HTML comment is comment content + * (never opens a fence), and a `` token inside a real fenced block + * is fence content (never opens/closes a comment). + * + * @param lines - source lines (as produced by `markdown.split('\n')`) + */ +function computeSkippedLineFlags(lines: string[]): boolean[] { + const skip = new Array(lines.length).fill(false); + + let fence: { char: '`' | '~'; len: number } | null = null; + let inHtmlComment = false; + + for (let i = 0; i < lines.length; i++) { + // Strip trailing \r (CRLF safety), mirroring stripFencedCode's/ + // scanFencedBlocks's own `rawLine.replace(/\r$/, '')`. + const line = lines[i].replace(/\r$/, ''); + + if (fence !== null) { + // Inside a real fence: only a matching closer can end it. Any + // `` on this line is fence content, not a comment boundary + // (converse precedence). + skip[i] = true; + const m = FENCE_DELIM_RE.exec(line); + if (m) { + const char = m[2][0] as '`' | '~'; + const len = m[2].length; + const trailing = m[3]; + if (char === fence.char && len >= fence.len && /^\s*$/.test(trailing)) { + fence = null; + } + } + continue; + } + + if (inHtmlComment) { + // Inside a real comment: only '-->' can end it. Any fence delimiter on + // this line is comment content, not a fence boundary (primary + // precedence — the DEFECT.CONTEXT-PREDICATES-COMMENT-FENCE-BLIND + // repro: a fence delimiter with no later real closer must not skip to + // EOF just because it happened to appear inside a comment). + skip[i] = true; + if (line.includes('-->')) inHtmlComment = false; + continue; + } + + // Neither construct open: HTML comments are lexically outermost in this + // document's grammar, so a comment opener is checked BEFORE a fence + // opener on the same line. + const trimmed = line.trim(); + if (trimmed.startsWith('')) { + inHtmlComment = true; // multi-line: stays open until a later '-->' + } + continue; + } + + const m = FENCE_DELIM_RE.exec(line); + if (m) { + const char = m[2][0] as '`' | '~'; + const trailing = m[3]; + // CommonMark §4.5: a backtick fence opener's info string must not + // itself contain a backtick — such a line is ordinary content, not a + // valid opener (mirrors scanFencedBlocks). + if (!(char === '`' && trailing.includes('`'))) { + skip[i] = true; + fence = { char, len: m[2].length }; + continue; + } + } + + skip[i] = false; + } + + return skip; +} + +/** + * Parse all predicates from a CONTEXT.md markdown string. + * + * @param markdown + */ +export function parsePredicates(markdown: string): ParseResult { + const lines = markdown.split('\n'); + const predicates: Predicate[] = []; + const malformed: Malformed[] = []; + // Track id -> occurrence count for duplicate detection + const idCounts = new Map(); + + const skippedLines = computeSkippedLineFlags(lines); + let currentSection = ''; + const allSections: string[] = []; + const seenSections = new Set(); + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const lineNo = i + 1; // 1-based + + // Fenced code blocks and HTML comments (line-preserving; see + // computeSkippedLineFlags's doc comment). + if (skippedLines[i]) continue; + + // Track section headings for the section field. + if (raw.startsWith('#')) { + currentSection = raw.replace(/^#+\s*/, '').trim(); + if (currentSection && !seenSections.has(currentSection)) { + seenSections.add(currentSection); + allSections.push(currentSection); + } + continue; + } + + // Blockquote lines (start with ">") are prose — skip. + if (raw.trimStart().startsWith('>')) continue; + + // Attempt extraction. + const pred = extractPredicate(raw); + if (!pred) { + const bad = detectMalformed(raw); + if (bad) { + malformed.push({ line: lineNo, text: bad.text, reason: bad.reason }); + } + continue; + } + + const klass = pred.id.split('.')[0]; + predicates.push({ + id: pred.id, + klass, + value: pred.value, + line: lineNo, + section: currentSection, + }); + + idCounts.set(pred.id, (idCounts.get(pred.id) || 0) + 1); + } + + // Build duplicates list: ids with >1 occurrence. + const duplicates: Duplicate[] = []; + for (const [id, count] of idCounts) { + if (count > 1) duplicates.push({ id, count }); + } + // Sort duplicates by id for determinism. + duplicates.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + + // Skipped sections: headings that yielded zero predicates (pure prose). + const activeSections = new Set(predicates.map((p) => p.section)); + const skippedSections = allSections.filter((s) => !activeSections.has(s)); + + return { predicates, duplicates, malformed, skippedSections }; +} + +/** + * Select predicates by one or more optional criteria (ANDed together). + * + * @param predicates + * @param opts + */ +export function selectPredicates(predicates: Predicate[], opts: SelectOptions = {}): Predicate[] { + const { klass, prefix, contains } = opts; + const containsLower = contains ? contains.toLowerCase() : null; + + return predicates.filter((p) => { + if (klass !== undefined && p.klass !== klass) return false; + if (prefix !== undefined && !p.id.startsWith(prefix)) return false; + if (containsLower !== null) { + const haystack = (p.id + ' ' + p.value).toLowerCase(); + if (!haystack.includes(containsLower)) return false; + } + return true; + }); +} + +/** + * Build a deterministic index object from a parsed predicates array. + * + * @param predicates + */ +export function buildIndex(predicates: Predicate[]): ContextIndex { + // Count per class. + const classCounts: Record = {}; + for (const p of predicates) { + classCounts[p.klass] = (classCounts[p.klass] || 0) + 1; + } + + // Sort classes object by key for determinism. + const classes: Record = {}; + for (const k of Object.keys(classCounts).sort()) { + classes[k] = classCounts[k]; + } + + // Sort predicates by id then by line number (line used for ordering only — + // the committed index entry itself omits `line`; see module doc). + const sortedPredicates = predicates + .slice() + .sort((a, b) => { + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return a.line - b.line; + }) + .map(({ id, klass, value }) => ({ id, klass, value })); + + // Rebuild duplicates from the (sorted-by-id) predicates for determinism. + const idCounts = new Map(); + for (const p of predicates) { + idCounts.set(p.id, (idCounts.get(p.id) || 0) + 1); + } + const duplicates: Duplicate[] = []; + for (const [id, count] of idCounts) { + if (count > 1) duplicates.push({ id, count }); + } + duplicates.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + + return { + schemaVersion: 1, + count: predicates.length, + classes, + predicates: sortedPredicates, + duplicates, + }; +} diff --git a/src/markdown-sectionizer.cts b/src/markdown-sectionizer.cts index b72021900..3bf4e8b04 100644 --- a/src/markdown-sectionizer.cts +++ b/src/markdown-sectionizer.cts @@ -269,7 +269,7 @@ function stripInlineCodeLine(line: string): string { // ─── extractFencedBlock ─────────────────────────────────────────────────────── /** A fenced code block located by `scanFencedBlocks`: line-index span + info string. */ -interface FencedBlockRecord { +export interface FencedBlockRecord { /** Fence delimiter character (`` ` `` or `~`). */ char: '`' | '~'; /** Fence delimiter run length (≥3). */ @@ -301,8 +301,13 @@ interface FencedBlockRecord { * Tracked duplication (same status as `tokenizeHeadings`'s copy, see its * comment above): this is a second independent copy of the fence state * machine, pending a T-tier consolidation. + * + * Exported so `context-predicates.cts` can consume this seam directly for its + * line-preserving fenced-line skip detection, instead of carrying a third + * independent copy of the fence state machine (see that module's doc + * comment). */ -function scanFencedBlocks(lines: string[]): FencedBlockRecord[] { +export function scanFencedBlocks(lines: string[]): FencedBlockRecord[] { const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/; const blocks: FencedBlockRecord[] = []; let open: { char: '`' | '~'; len: number; infoString: string; openLineIdx: number } | null = null; diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index d367f6a96..ef9a0c44d 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -3763,3 +3763,78 @@ describe('#2279: map-codebase date stamp instructions overwrite existing dates', 'workflow must instruct agents to overwrite existing dates, not just replace [YYYY-MM-DD] placeholders'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// DEFECT.GENERATIVE-FIX parity guard: HOST_COMMAND_ROUTERS vs TOP_LEVEL_USAGE +// vs SKIP_ROOT_RESOLUTION (#2928 S9) +// +// gsd-tools.cjs's query-command surface is declared across THREE +// independently hand-maintained sites in the same file with no prior parity +// gate between them: the dispatch table (HOST_COMMAND_ROUTERS), the +// `--help` command list (TOP_LEVEL_USAGE), and the project-root-skip list +// (SKIP_ROOT_RESOLUTION). Nothing previously caught a command being wired +// into the dispatch table but omitted from the help string (or vice versa) +// — exactly the generative-fix-divergence shape CLAUDE.md's +// "Generative Fix Divergence" anti-pattern names ("add a parity assertion +// test that fails if the shared constants/arrays/parsers diverge"). +// +// This is a STRUCTURAL comparison against the exported constants, not a +// source-text/string-match test, so it stays correct across reformatting +// and is immune to the no-source-grep concern. +// ───────────────────────────────────────────────────────────────────────────── +describe('gsd-tools.cjs dispatch/help/skip-list parity (DEFECT.GENERATIVE-FIX, #2928 S9)', () => { + const { HOST_COMMAND_ROUTERS, TOP_LEVEL_USAGE, skipsRootResolution } = require('../gsd-core/bin/gsd-tools.cjs'); + + // Parse the "Commands: a, b, c\n\nGlobal flags:" line out of the usage + // string rather than hardcoding a copy of it here — this test must fail + // when the two sites diverge, not silently pass because it re-embeds its + // own stale expectation. + function parseHelpCommandNames(usage) { + const match = usage.match(/Commands: ([\s\S]*?)\n\nGlobal flags:/); + assert.ok(match, 'TOP_LEVEL_USAGE must contain a "Commands: ...\\n\\nGlobal flags:" block'); + return match[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } + + test('every HOST_COMMAND_ROUTERS entry is listed in the --help command string', () => { + const helpNames = new Set(parseHelpCommandNames(TOP_LEVEL_USAGE)); + const missing = Object.keys(HOST_COMMAND_ROUTERS).filter((name) => !helpNames.has(name)); + assert.deepEqual( + missing, + [], + `command(s) registered in HOST_COMMAND_ROUTERS but missing from TOP_LEVEL_USAGE's ` + + `"Commands:" list: ${missing.join(', ')}`, + ); + }); + + test('context-predicates is registered in all three hand-maintained sites', () => { + // Concrete regression pin for the command this parity test was added + // alongside (#2928 S9) — a generic diff-based assertion alone would not + // fail if ALL THREE sites were missing an entry simultaneously. + assert.ok( + Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, 'context-predicates'), + 'context-predicates must be registered in HOST_COMMAND_ROUTERS', + ); + assert.ok( + parseHelpCommandNames(TOP_LEVEL_USAGE).includes('context-predicates'), + 'context-predicates must be listed in TOP_LEVEL_USAGE', + ); + assert.ok( + skipsRootResolution('context-predicates'), + 'context-predicates must be in SKIP_ROOT_RESOLUTION (it is a pure repo-root CONTEXT.md ' + + 'read, like prompt-budget, and must work with no .planning/ directory present)', + ); + }); + + test('SKIP_ROOT_RESOLUTION is not exported as a mutable live Set (DEFECT.MUTABLE-EXPORTED-SET, #2928)', () => { + const gsdTools = require('../gsd-core/bin/gsd-tools.cjs'); + assert.equal( + gsdTools.SKIP_ROOT_RESOLUTION, + undefined, + 'the live Set must not be exported directly — only the read-only skipsRootResolution() predicate', + ); + assert.equal(typeof gsdTools.skipsRootResolution, 'function'); + }); +}); diff --git a/tests/context-predicates-query.test.cjs b/tests/context-predicates-query.test.cjs new file mode 100644 index 000000000..0c4a992af --- /dev/null +++ b/tests/context-predicates-query.test.cjs @@ -0,0 +1,278 @@ +'use strict'; + +/** + * Integration tests for `gsd_run query context-predicates` — the selector + * surface for the CONTEXT.md predicate fact-store (ADR-1671 S9, #2928 Phase + * 1, rows G1-G17). + * + * NET-NEW COMMAND, EXPECTED RED: `context-predicates` is not yet registered + * in gsd-core/bin/gsd-tools.cjs's HOST_COMMAND_ROUTERS / TOP_LEVEL_USAGE / + * SKIP_ROOT_RESOLUTION (S9's three hand-maintained sites). Every test in this + * file targets the REQUIRED behavior from 40-design.md rows 38-43 and + * currently fails because the command does not exist — that is expected and + * correct for this commit (a failing-first regression matrix), not a + * defect in the test. + * + * Invocation shape: `gsd_run query [args]` maps to + * `node gsd-tools.cjs query [args]` — `query` is a meta-prefix the + * dispatcher strips (gsd-core/bin/gsd-tools.cjs, "Accept `query` as a + * meta-prefix"). This is the same shape every existing query consumer in + * gsd-core/workflows/*.md uses (e.g. `gsd_run query stats.json`, + * `gsd_run query prompt-budget ...`) — mirrored here since no real caller of + * `context-predicates` is wired yet (net-new capability). + * + * Structured assertions: success/failure is asserted via exit code, and via + * `--json-errors` (a real, already-shipped global gsd-tools flag) parsed as + * `{ok, reason, message}` — `reason` is compared against the frozen + * ERROR_REASON enum (gsd-core/bin/lib/io.cjs), never a substring match on + * `message` prose. On success, output is asserted via JSON.parse of stdout + * (gsd-tools' shared `output()` helper always serializes JSON to stdout + * unless --raw is passed) — never a stdout regex. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs'); +const { ERROR_REASON } = require('../gsd-core/bin/lib/io.cjs'); +const { parsePredicates, selectPredicates } = require('../gsd-core/bin/lib/context-predicates.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const STACK_FRAME_RE = /\n\s+at\s+\S+\s+\(.*:\d+:\d+\)/; + +function queryContextPredicates(args, cwd = ROOT, env = {}) { + return runGsdTools(['query', 'context-predicates', ...args], cwd, env); +} + +function queryContextPredicatesJsonErrors(args, cwd = ROOT) { + const r = queryContextPredicates([...args, '--json-errors'], cwd); + let parsedError = null; + try { + parsedError = JSON.parse(r.error); + } catch { + // r.error was not JSON (e.g. a resource-starvation message) — leave null. + } + return { ...r, parsedError }; +} + +// Independently computed expectation set, from the real CONTEXT.md, via the +// exported pure parser/selector — NOT by parsing the CLI's own rendered text. +const REAL_PREDICATES = parsePredicates(fs.readFileSync(path.join(ROOT, 'CONTEXT.md'), 'utf8')).predicates; + +describe('gsd_run query context-predicates (G)', () => { + test('queryReturnsPredicatesForClass', () => { + const expected = selectPredicates(REAL_PREDICATES, { klass: 'META' }); + assert.ok(expected.length > 0, 'fixture sanity: META class must exist in the real CONTEXT.md'); + + const r = queryContextPredicates(['--class', 'META']); + assert.equal(r.success, true, 'query --class META must succeed once the command is wired'); + const parsed = JSON.parse(r.output); + const ids = (Array.isArray(parsed) ? parsed : parsed.predicates).map((p) => p.id).sort(); + assert.deepEqual(ids, expected.map((p) => p.id).sort()); + }); + + test('queryReturnsPredicatesForDottedPrefix', () => { + const expected = selectPredicates(REAL_PREDICATES, { prefix: 'RULESET.TESTS' }); + assert.ok(expected.length > 0, 'fixture sanity: RULESET.TESTS.* must exist in the real CONTEXT.md'); + + const r = queryContextPredicates(['--prefix', 'RULESET.TESTS']); + assert.equal(r.success, true); + const parsed = JSON.parse(r.output); + const ids = (Array.isArray(parsed) ? parsed : parsed.predicates).map((p) => p.id).sort(); + assert.deepEqual(ids, expected.map((p) => p.id).sort()); + }); + + test('queryReturnsPredicatesForFreeTextContains', () => { + const expected = selectPredicates(REAL_PREDICATES, { contains: 'changeset' }); + assert.ok(expected.length > 0, 'fixture sanity: "changeset" must appear in some real predicate id/value'); + + const r = queryContextPredicates(['--contains', 'changeset']); + assert.equal(r.success, true); + const parsed = JSON.parse(r.output); + const ids = (Array.isArray(parsed) ? parsed : parsed.predicates).map((p) => p.id).sort(); + assert.deepEqual(ids, expected.map((p) => p.id).sort()); + }); + + test('queryReportsZeroMatchesStructurally', () => { + const r = queryContextPredicates(['--class', 'ZZZ-NO-SUCH-CLASS-EXISTS']); + assert.equal(r.success, true, 'a no-match query is not itself a failure'); + const parsed = JSON.parse(r.output); + assert.equal(parsed.matched, 0, 'a no-match result must be structurally distinguishable ({matched: 0}), not silent success or failure'); + }); + + test('queryWithoutSelectorExitsWithUsage', () => { + const r = queryContextPredicatesJsonErrors([]); + assert.equal(r.success, false); + assert.notEqual(r.exitCode, 0); + assert.ok(r.parsedError, 'failure must be structured JSON under --json-errors'); + assert.equal(r.parsedError.reason, ERROR_REASON.USAGE, 'missing selector must be a usage error, not an internal/unknown-command error'); + }); + + test('queryWithEmptyClassExitsNonZero', () => { + const r = queryContextPredicatesJsonErrors(['--class', '']); + assert.equal(r.success, false); + assert.equal(r.parsedError && r.parsedError.reason, ERROR_REASON.USAGE); + }); + + test('queryWithWhitespaceOnlyClassExitsNonZero', () => { + const r = queryContextPredicatesJsonErrors(['--class', ' ']); + assert.equal(r.success, false); + assert.equal(r.parsedError && r.parsedError.reason, ERROR_REASON.USAGE); + }); + + test('queryWithDuplicateClassFlagsResolvesDeterministically', () => { + const first = queryContextPredicates(['--class', 'META', '--class', 'RULESET']); + const second = queryContextPredicates(['--class', 'META', '--class', 'RULESET']); + assert.equal(first.exitCode, second.exitCode, 'duplicate flags must resolve the same way on every invocation'); + assert.equal(first.output, second.output, 'duplicate-flag resolution must be deterministic, not order-of-parse-dependent'); + }); + + test('queryWithConflictingSelectorsAppliesDocumentedPrecedence', () => { + const r = queryContextPredicates(['--class', 'META', '--prefix', 'RULESET.TESTS']); + assert.doesNotMatch(r.error || '', STACK_FRAME_RE, 'conflicting selectors must resolve via documented precedence, never crash'); + const repeat = queryContextPredicates(['--class', 'META', '--prefix', 'RULESET.TESTS']); + assert.equal(r.exitCode, repeat.exitCode, 'precedence resolution must be deterministic'); + }); + + test('queryWithMalformedAssignmentExitsNonZero', () => { + const eq = queryContextPredicatesJsonErrors(['--class=']); + assert.equal(eq.success, false); + assert.equal(eq.parsedError && eq.parsedError.reason, ERROR_REASON.USAGE); + + const doubleEq = queryContextPredicatesJsonErrors(['--class==A']); + assert.equal(doubleEq.success, false); + assert.equal(doubleEq.parsedError && doubleEq.parsedError.reason, ERROR_REASON.USAGE); + }); + + test('queryTreatsFlagLikeValueAsMissingValue', () => { + const r = queryContextPredicatesJsonErrors(['--class', '--weird']); + assert.equal(r.success, false, '--weird must not be silently consumed as the --class value'); + assert.equal(r.parsedError && r.parsedError.reason, ERROR_REASON.USAGE); + }); + + // #2928 review finding C: a flag-shaped selector value (e.g. searching CONTEXT.md + // for the literal text "--since") was previously unmatchable — the space-separated + // form always reads a following `--...` token as a missing value, with no escape hatch. + describe('inline-assignment escape hatch for flag-shaped values (#2928 finding C)', () => { + test('contains=value form matches a flag-shaped selector value', () => { + const expected = selectPredicates(REAL_PREDICATES, { contains: '--since' }); + assert.ok(expected.length > 0, 'fixture sanity: "--since" must appear in a real CONTEXT.md predicate value'); + + const r = queryContextPredicates(['--contains=--since']); + assert.equal(r.success, true, '--contains=--since must be accepted, not read as an unknown flag'); + const parsed = JSON.parse(r.output); + const ids = parsed.predicates.map((p) => p.id).sort(); + assert.deepEqual(ids, expected.map((p) => p.id).sort()); + }); + + test('space-separated form still treats a flag-shaped value as missing (no escape without =)', () => { + const r = queryContextPredicatesJsonErrors(['--contains', '--since']); + assert.equal(r.success, false, '--contains --since (space-separated) must remain a usage error'); + assert.equal(r.parsedError && r.parsedError.reason, ERROR_REASON.USAGE); + }); + + test('contains= with an empty value is still rejected', () => { + const r = queryContextPredicatesJsonErrors(['--contains=']); + assert.equal(r.success, false); + assert.equal(r.parsedError && r.parsedError.reason, ERROR_REASON.USAGE); + }); + + test('contains== (double-equals) is still rejected, not accepted as literal "=x"', () => { + const r = queryContextPredicatesJsonErrors(['--contains==x']); + assert.equal(r.success, false); + assert.equal(r.parsedError && r.parsedError.reason, ERROR_REASON.USAGE); + }); + + test('class=/prefix= inline-assignment form also works (not contains-only)', () => { + const expected = selectPredicates(REAL_PREDICATES, { klass: 'META' }); + const r = queryContextPredicates(['--class=META']); + assert.equal(r.success, true); + const parsed = JSON.parse(r.output); + assert.deepEqual( + parsed.predicates.map((p) => p.id).sort(), + expected.map((p) => p.id).sort(), + ); + }); + }); + + test('queryRejectsPrototypePollutingSelectorKeys', () => { + for (const hostile of ['__proto__', 'constructor', 'prototype']) { + const r = queryContextPredicates(['--class', hostile]); + assert.equal(r.success, true, `${hostile} must be treated as an ordinary (non-matching) class name, not crash`); + const parsed = JSON.parse(r.output); + assert.equal(parsed.matched, 0); + assert.equal(Object.prototype.toString.call({}), '[object Object]', 'sanity: global Object.prototype must be untouched'); + } + }); + + test('queryDoesNotInterpolateShellMetacharacters', () => { + const markerPath = path.join(ROOT, '.gsd-test-shell-injection-marker-2928'); + assert.equal(fs.existsSync(markerPath), false, 'fixture sanity: marker must not pre-exist'); + const hostileValue = `; touch ${markerPath} #`; + const r = queryContextPredicates(['--contains', hostileValue]); + assert.equal(fs.existsSync(markerPath), false, 'a shell metacharacter payload must never be interpolated into a shell'); + assert.doesNotMatch(r.error || '', STACK_FRAME_RE); + }); + + test('queryHandlesVeryLongSelectorValue', () => { + const longValue = 'x'.repeat(32 * 1024); + const r = queryContextPredicates(['--contains', longValue]); + assert.equal(typeof r.exitCode, 'number', 'a 32K selector value must not hang or crash the process'); + assert.doesNotMatch(r.error || '', STACK_FRAME_RE); + }); + + test('queryHandlesUnicodeSelectorValue', () => { + const unicodeValue = String.fromCodePoint(0x9884, 0x6d4b, 0x5909, 0x6570, 0x1f525); + const r = queryContextPredicates(['--contains', unicodeValue]); + assert.equal(typeof r.exitCode, 'number', 'a Unicode selector value must not crash the process'); + assert.doesNotMatch(r.error || '', STACK_FRAME_RE); + }); + + test('queryWorksFromSubdirectoryWithoutPlanningDir', (t) => { + const tmp = createTempDir('gci-query-subdir-'); + t.after(() => cleanup(tmp)); + const r = queryContextPredicates(['--class', 'META'], tmp); + assert.equal(r.success, true, 'the read-only selector must not require a .planning/ directory'); + }); + + test('queryFailureOutputContainsNoStackTrace', () => { + const r = queryContextPredicates([]); + assert.equal(r.success, false); + assert.doesNotMatch(r.error || '', STACK_FRAME_RE, 'no bare stack trace in non-debug failure output'); + }); +}); + +// allow-test-rule: source-text-is-the-product #2928 +// Wiring check for #2928's acceptance criterion — "an agent/orchestrator can invoke the +// selector through a wired `gsd_run query` surface to assemble a brief". Before this, nothing +// in the repo called `context-predicates`; it was a stranded CLI. docs/contributor-standards.md +// §"Pre-work requirements" is the real, tested brief-assembly site governing how an AI-agent +// prompt must cite CONTEXT.md's META.RULE predicates — this asserts it routes through the +// selector instead of a grep-by-eye read, and that the selector actually returns the predicate +// set that site names. +describe('context-predicates wired into a real brief-assembly site (#2928)', () => { + const STANDARDS_DOC = path.join(ROOT, 'docs', 'contributor-standards.md'); + const standardsText = fs.readFileSync(STANDARDS_DOC, 'utf8'); + + test('contributorStandardsRoutesPredicateCitationThroughTheQuerySelector', () => { + assert.ok( + standardsText.includes('node gsd-tools.cjs query context-predicates --class'), + 'docs/contributor-standards.md must invoke the context-predicates selector rather than instructing a grep-by-eye read' + ); + assert.ok( + standardsText.includes('META.RULE.brief-must-cite-doc') && standardsText.includes('META.RULE.brief-no-paraphrase'), + 'the wiring site must name the predicates it expects the selector to surface' + ); + }); + + test('theSelectorActuallyReturnsThePredicatesTheWiringSiteNames', () => { + const r = queryContextPredicates(['--class', 'META']); + assert.equal(r.success, true); + const parsed = JSON.parse(r.output); + const ids = parsed.predicates.map((p) => p.id); + assert.ok(ids.includes('META.RULE.brief-must-cite-doc')); + assert.ok(ids.includes('META.RULE.brief-no-paraphrase')); + }); +}); diff --git a/tests/context-predicates.property.test.cjs b/tests/context-predicates.property.test.cjs new file mode 100644 index 000000000..f1fdc69df --- /dev/null +++ b/tests/context-predicates.property.test.cjs @@ -0,0 +1,228 @@ +'use strict'; + +/** + * Property-based tests for src/context-predicates.cts (compiled to + * gsd-core/bin/lib/context-predicates.cjs). + * + * Document-shaped generators (CONTRIBUTING.md "Fixture provenance #2371"): + * these generators build arbitrary markdown documents out of prose lines, + * fences of varying tick-length, list items with varying markers, and + * predicate-shaped / predicate-lookalike lines — they are NOT seeded from + * this module's own writer/serializer. Seeding a property generator from the + * code under test's own render function make the document shape a constant + * and the property unable to fail; see 50-test-matrix.md Step 1. + * + * Deterministic per CONTRIBUTING.md: seed and numRuns are pinned by + * tests/helpers/fast-check-setup.cjs (seed 42, numRuns 200); failures print + * replay data via fast-check's own counterexample + seed reporting. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { parsePredicates, selectPredicates, buildIndex } = require('../gsd-core/bin/lib/context-predicates.cjs'); + +// ─── Document-shaped generators ──────────────────────────────────────────── + +// A predicate-shaped line: `CLASS.subkey=value` in one of the recognized +// declaration forms. `asDeclared` controls whether the caller wants this +// specific fixture to be a genuinely-recognized declaration (bare or `- ` +// list item — the two forms even today's implementation accepts) so property +// H1/H4 can reason about "predicates the parser actually extracts" without +// depending on the A4-A7 defects under test elsewhere. +const idClassArb = fc.stringMatching(/^[A-Z][A-Z0-9_-]{0,8}$/); +const idSubkeyArb = fc.stringMatching(/^[A-Za-z0-9_-]{1,8}$/); +const valueArb = fc.stringMatching(/^[A-Za-z0-9 _.-]{1,20}$/); + +const declaredPredicateLineArb = fc + .tuple(idClassArb, fc.option(idSubkeyArb, { nil: undefined }), valueArb, fc.boolean()) + .map(([klass, subkey, value, asListItem]) => { + const id = subkey ? `${klass}.${subkey}` : klass; + const inner = `\`${id}=${value}\``; + return { text: asListItem ? `- ${inner}` : inner, id, klass, value }; + }); + +// A prose line that is NOT a predicate declaration: plain text, a heading, a +// blockquote line, a table row, or an inline mid-prose backtick reference. +const proseLineArb = fc.oneof( + fc.stringMatching(/^[A-Za-z0-9 .,'"()-]{0,40}$/), + idClassArb.map((k) => `# ${k} heading`), + idClassArb.map((k) => `> quoting ${k}`), + idClassArb.map((k) => `| cell | \`${k}.x=y\` |`), + declaredPredicateLineArb.map((p) => `see ${p.text.replace(/^- /, '')} for details`), +); + +const fenceTickArb = fc.constantFrom('```', '~~~', '````'); + +// A whole document assembled from a mix of prose lines and declared +// predicate lines, optionally wrapping a contiguous run in a fence. +function documentArb() { + return fc + .array(fc.oneof({ arbitrary: declaredPredicateLineArb, weight: 2 }, { arbitrary: proseLineArb, weight: 3 }), { + minLength: 0, + maxLength: 12, + }) + .map((items) => items); +} + +// ─── H1: index preserves every parsed predicate ──────────────────────────── + +describe('property: parse <-> index contract', () => { + test('indexPreservesEveryParsedPredicate', () => { + fc.assert( + fc.property(documentArb(), (items) => { + const md = items.map((it) => (typeof it === 'string' ? it : it.text)).join('\n'); + const parsed = parsePredicates(md); + const index = buildIndex(parsed.predicates); + + assert.equal(index.count, parsed.predicates.length); + + const parsedIds = parsed.predicates.map((p) => p.id).sort(); + const indexIds = index.predicates.map((p) => p.id).sort(); + assert.deepEqual(indexIds, parsedIds, 'buildIndex must invent or lose no predicate id'); + + for (const p of parsed.predicates) { + const inIndex = index.predicates.find((ip) => ip.id === p.id && ip.value === p.value); + assert.ok(inIndex, `predicate ${p.id}=${p.value} from the parse must appear in the index`); + } + }), + ); + }); + + // ─── H2: buildIndex is deterministic and order-independent ──────────────── + + test('indexSerializationIsOrderIndependentAndDeterministic', () => { + fc.assert( + fc.property( + fc.array(declaredPredicateLineArb, { minLength: 0, maxLength: 10 }), + (decls) => { + // Dedupe by id so this property is not entangled with duplicate + // semantics (covered separately by the E-row unit tests) — the + // property under test here is pure ordering independence. + const seen = new Set(); + const uniqueDecls = decls.filter((d) => (seen.has(d.id) ? false : (seen.add(d.id), true))); + + const forwardMd = uniqueDecls.map((d) => d.text).join('\n'); + const reverseMd = uniqueDecls + .slice() + .reverse() + .map((d) => d.text) + .join('\n'); + + const forwardIndex = buildIndex(parsePredicates(forwardMd).predicates); + const reverseIndex = buildIndex(parsePredicates(reverseMd).predicates); + + assert.deepEqual(forwardIndex, reverseIndex, 'index must not depend on source declaration order'); + + // Determinism: building twice from the same parsed predicates must + // produce byte-identical JSON serialization. + const parsed = parsePredicates(forwardMd); + const a = JSON.stringify(buildIndex(parsed.predicates)); + const b = JSON.stringify(buildIndex(parsed.predicates)); + assert.equal(a, b); + }, + ), + ); + }); + + // ─── H3: fencing a region never increases the predicate count ───────────── + + test('fencingRegionNeverIncreasesPredicateCount', () => { + fc.assert( + fc.property( + fc.array(declaredPredicateLineArb, { minLength: 1, maxLength: 6 }), + fc.nat({ max: 5 }), + fenceTickArb, + (decls, wrapAt, fence) => { + const lines = decls.map((d) => d.text); + const before = parsePredicates(lines.join('\n')).predicates.length; + + const cut = Math.min(wrapAt, lines.length); + const fenced = [...lines.slice(0, cut), fence, ...lines.slice(cut), fence].join('\n'); + const after = parsePredicates(fenced).predicates.length; + + assert.ok(after <= before, `fencing must never increase the parsed count (before=${before}, after=${after})`); + }, + ), + ); + }); +}); + +// ─── H5: comment/fence mutual precedence (DEFECT.CONTEXT-PREDICATES-COMMENT- +// FENCE-BLIND, #2928 review) — a predicate genuinely OUTSIDE a wrapper +// (comment or fence) is always parsed live, and a predicate genuinely INSIDE +// it is never parsed live, even when the wrapper's own content contains +// tokens that LOOK LIKE the OTHER construct (fence delimiters inside a +// comment, or comment tokens inside a fence — the two directions the +// interleaved single-pass in `computeSkippedLineFlags` must both get right). +// Document-shaped generator: wraps a marked "inside" predicate between an +// open/close pair of ONE kind, salted with lookalike noise from the OTHER +// kind, with unrelated real predicates before/after the wrapper. ────────── + +// Noise lines that look like the OTHER construct's tokens, keyed by which +// kind is being used as the OUTER wrapper for a given run. +const FENCE_NOISE_LINES = [' mid-line', ''; + + const lines = [ + '`OUTSIDE_BEFORE=1`', + open, + ...beforeNoiseIdx.map((i) => noisePool[i]), + '`INSIDE_MARKER=2`', + ...afterNoiseIdx.map((i) => noisePool[i]), + close, + '`OUTSIDE_AFTER=3`', + ]; + + const md = lines.join('\n'); + const r = parsePredicates(md); + const ids = new Set(r.predicates.map((p) => p.id)); + + assert.ok(ids.has('OUTSIDE_BEFORE'), `OUTSIDE_BEFORE must always be live (wrapper=${wrapperKind})`); + assert.ok(ids.has('OUTSIDE_AFTER'), `OUTSIDE_AFTER must always be live (wrapper=${wrapperKind})`); + assert.ok( + !ids.has('INSIDE_MARKER'), + `INSIDE_MARKER must never be live inside a real ${wrapperKind}, even with ${ + wrapperKind === 'fence' ? 'comment' : 'fence' + }-lookalike noise around it`, + ); + }, + ), + ); + }); +}); + +describe('property: selectPredicates subset invariant', () => { + test('selectorReturnsOnlyMatchingSubset', () => { + fc.assert( + fc.property(fc.array(declaredPredicateLineArb, { minLength: 0, maxLength: 10 }), idClassArb, (decls, klass) => { + const md = decls.map((d) => d.text).join('\n'); + const all = parsePredicates(md).predicates; + const selected = selectPredicates(all, { klass }); + + for (const p of selected) { + assert.ok( + all.includes(p), + 'every selected predicate must be a reference from the original array (subset, not a copy with invented members)', + ); + assert.equal(p.klass, klass, `selectPredicates({klass}) must only return predicates whose klass === ${klass}`); + } + }), + ); + }); +}); diff --git a/tests/context-predicates.test.cjs b/tests/context-predicates.test.cjs new file mode 100644 index 000000000..891cc5f55 --- /dev/null +++ b/tests/context-predicates.test.cjs @@ -0,0 +1,684 @@ +'use strict'; + +/** + * Unit tests for src/context-predicates.cts (compiled to + * gsd-core/bin/lib/context-predicates.cjs) — the CONTEXT.md predicate + * fact-store parser/validator/selector (ADR-1671, #2928 Phase 1). + * + * FAILING-FIRST: this file targets the REQUIRED production behavior from + * .gsd/phase/chore-2928-context-predicate-store/40-design.md's behavior + * table, not today's prototype-carried-forward behavior. Eight row-groups + * are measured, provable defects in the current implementation and MUST be + * RED until a later commit fixes them: A4, A5, A6, A7 (indented-bare / `*` / + * `+` / numbered-list declaration forms are dropped), B2 (tilde fence not + * skipped), B3 (4-backtick fence containing a 3-backtick line mis-toggles), + * B5 (multi-line HTML comment not skipped). + * + * Fixture provenance (CONTRIBUTING.md #2371): the must-NOT-parse corpus + * (rows A8-A11, negative space, I4) is drawn from real repo documents that + * predate/ignore this grammar — the real CONTEXT.md and the real + * CONTRIBUTING.md — never hand-authored from the grammar spec itself. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + parsePredicates, + selectPredicates, + buildIndex, +} = require('../gsd-core/bin/lib/context-predicates.cjs'); + +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const CRLF = '\r\n'; + +// ─── A. Declaration recognition ─────────────────────────────────────────── + +describe('parsePredicates: declaration recognition (A)', () => { + test('parsesBareBacktickDeclaration', () => { + const r = parsePredicates('`FOO=bar`'); + assert.equal(r.predicates.length, 1); + assert.deepEqual( + { id: r.predicates[0].id, klass: r.predicates[0].klass, value: r.predicates[0].value }, + { id: 'FOO', klass: 'FOO', value: 'bar' }, + ); + }); + + test('parsesDashListItemDeclaration', () => { + const r = parsePredicates('- `FOO=bar`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].id, 'FOO'); + }); + + test('parsesIndentedDashListItemDeclaration', () => { + const r = parsePredicates(' - `FOO=bar`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].id, 'FOO'); + }); + + test('parsesIndentedBareDeclaration', () => { + // RED (measured defect): the prototype's bare-form check requires column + // 0 (`line.startsWith('`')` on a line whose only trim is trimEnd()), so + // leading whitespace with no list marker is silently dropped today. + const r = parsePredicates(' `FOO=bar`'); + assert.equal(r.predicates.length, 1, 'indented bare declaration must be tolerated (Postel honored on shape)'); + assert.equal(r.predicates[0].id, 'FOO'); + }); + + test('parsesStarListItemDeclaration', () => { + // RED (measured defect): today's list-item stripper only recognizes `-`. + const r = parsePredicates('* `FOO=bar`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].id, 'FOO'); + }); + + test('parsesPlusListItemDeclaration', () => { + // RED (measured defect): same as `*`, `+` is not recognized today. + const r = parsePredicates('+ `FOO=bar`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].id, 'FOO'); + }); + + test('parsesNumberedListItemDeclaration', () => { + // RED (measured defect): numbered list markers are not recognized today. + const r = parsePredicates('1. `FOO=bar`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].id, 'FOO'); + }); + + test('ignoresInlineReferenceInsideProse', () => { + const r = parsePredicates('see `FOO=bar` for details'); + assert.equal(r.predicates.length, 0, 'an inline mid-prose mention is a reference, not a declaration'); + }); + + test('ignoresPredicateShapeInTableCell', () => { + const r = parsePredicates('| x | `FOO=bar` |'); + assert.equal(r.predicates.length, 0); + }); + + test('ignoresPredicateShapeInHeading', () => { + const md = ['# `FOO=bar`', '`REAL.one=value`'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 1, 'the heading itself must never yield a predicate'); + assert.equal(r.predicates[0].id, 'REAL.one'); + assert.equal(r.predicates[0].section, '`FOO=bar`', 'the heading text (predicate-shaped or not) becomes the tracked section'); + }); + + test('ignoresPredicateShapeInBlockquote', () => { + const r = parsePredicates('> `FOO=bar`'); + assert.equal(r.predicates.length, 0); + }); + + test('tracksNearestPrecedingSectionHeading', () => { + const md = ['# My Section', '`FOO=bar`'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].section, 'My Section'); + }); +}); + +// ─── B. Fenced / commented regions ──────────────────────────────────────── + +describe('parsePredicates: fenced/commented regions (B)', () => { + test('ignoresDeclarationInsideBacktickFence', () => { + const md = ['```', '`FOO=bar`', '```'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0); + }); + + test('ignoresDeclarationInsideTildeFence', () => { + // RED (measured defect): the naive toggle only matches triple-backtick lines. + const md = ['~~~', '`FOO=bar`', '~~~'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0, 'a tilde fence must skip its contents exactly like a backtick fence'); + }); + + test('ignoresDeclarationInsideLongerFenceContainingShorterFence', () => { + // RED (measured defect): a naive backtick-count-agnostic toggle mis-flips + // on the inner 3-backtick line and un-skips the remainder. + const md = ['````', '```', '`FOO=bar`', '```', '````'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0, 'fence-length awareness must prevent the inner fence from un-skipping the outer one'); + }); + + test('ignoresDeclarationInsideLanguageTaggedFence', () => { + const md = ['```bash', '`FOO=bar`', '```'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0); + }); + + test('ignoresDeclarationInsideMultiLineHtmlComment', () => { + // RED (measured defect): the prototype has no HTML-comment awareness at + // all — a predicate-shaped line between `` on its own line + // parses as live today. + const md = [''].join('\n'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0, 'a commented-out predicate must not be read as live'); + }); + + test('ignoresDeclarationInsideSingleLineHtmlComment', () => { + const r = parsePredicates(''); + assert.equal(r.predicates.length, 0); + }); + + test('treatsUnclosedFenceAsSkippedToEndOfFile', () => { + const md = ['```', '`FOO=bar`', '`BAZ=qux`'].join('\n'); + assert.doesNotThrow(() => parsePredicates(md)); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0, 'everything after an unclosed fence must be treated as skipped, not crash or leak'); + }); + + test('parsesDeclarationInFourSpaceIndentedBlockAsDocumentedLimit', () => { + // Pins the documented limit (design known-limit 1): 4-space indentation + // is NOT treated as a code block, because CONTEXT.md authors real + // predicates as indented list items at that depth. + const r = parsePredicates(' - `FOO=bar`'); + assert.equal(r.predicates.length, 1, 'documented limit: 4-space indent is not code, so this must still parse'); + assert.equal(r.predicates[0].id, 'FOO'); + }); +}); + +// ─── B2. Comment/fence mutual precedence +// (DEFECT.CONTEXT-PREDICATES-COMMENT-FENCE-BLIND — BLOCKER review finding) ── +// +// The HTML-comment scan and the fence scan previously ran as two +// INDEPENDENT passes: `scanFencedBlocks` is comment-blind, so a fence +// delimiter appearing INSIDE an HTML comment (with no later matching close +// in the file) was treated as a real *unterminated* fence — silently +// dropping every remaining predicate to EOF. This is invisible to `--check` +// because `--check` diffs against a baseline generated by the same +// corrupted parse. This suite locks the chosen precedence (module doc +// comment, `computeSkippedLineFlags`): the two constructs are scanned in one +// interleaved pass and mutually suppress each other's open/close detection +// while active. + +describe('parsePredicates: comment/fence mutual precedence (B2)', () => { + test('fenceDelimiterInsideCommentWithNoLaterFenceDoesNotSwallowRemainingFile', () => { + // The exact BLOCKER repro: a fence delimiter inside an HTML comment, with + // no later real fence anywhere in the document, must not be treated as + // an unterminated fence — the later real predicate must still parse. + const md = ['', '`RULESET.REAL.PREDICATE=x`'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 1, 'the fence delimiter inside the comment must not swallow the rest of the file'); + assert.equal(r.predicates[0].id, 'RULESET.REAL.PREDICATE'); + }); + + test('fenceDelimiterInsideCommentWithLaterRealFenceStillBehavesCorrectly', () => { + const md = [ + '', + '`BEFORE=1`', + '```', + '`INSIDE=2`', + '```', + '`AFTER=3`', + ].join('\n'); + const r = parsePredicates(md); + const ids = r.predicates.map((p) => p.id); + assert.deepEqual(ids.sort(), ['AFTER', 'BEFORE'], 'the real fence after the comment must still skip its own content'); + }); + + test('arrowInsideRealFencedBlockDoesNotTerminateAnythingAndFenceStillSkipsItsContent', () => { + const md = ['```', '`INSIDE=1`', 'noise line with --> token', '```', '`AFTER=2`'].join('\n'); + const r = parsePredicates(md); + const ids = r.predicates.map((p) => p.id); + assert.deepEqual(ids, ['AFTER'], '--> inside real fence content must not open/close a comment; fence must still skip INSIDE'); + }); + + test('commentOpenerInsideRealFencedBlockDoesNotOpenAComment', () => { + const md = ['```', '', '`INSIDE=2`', '```', '`AFTER=3`'].join('\n'); + const infoStringResult = parsePredicates(infoStringMd); + assert.deepEqual( + infoStringResult.predicates.map((p) => p.id).sort(), + ['AFTER', 'BEFORE'], + 'a fence opener whose info string contains comment-like text must still open/close as a real fence', + ); + + // A self-closing single-line HTML comment whose content happens to + // contain a fence-delimiter-shaped token: the line still starts with + // `', '`AFTER=2`'].join('\n'); + const commentResult = parsePredicates(commentMd); + assert.deepEqual( + commentResult.predicates.map((p) => p.id).sort(), + ['AFTER', 'BEFORE'], + 'a single-line comment whose content contains fence-shaped text must not open a fence', + ); + }); +}); + +// ─── C. ID / value grammar boundaries ────────────────────────────────────── + +describe('parsePredicates: ID/value grammar boundaries (C)', () => { + test('rejectsBacktickContentAtLengthTwo', () => { + // `A=` — backtick content length 2 (limit-1 of the `inner.length > 2` guard). + const r = parsePredicates('`A=`'); + assert.equal(r.predicates.length, 0); + }); + + test('acceptsMinimalBacktickContentAtLengthThree', () => { + // `A=1` — length 3 (limit). + const r = parsePredicates('`A=1`'); + assert.equal(r.predicates.length, 1); + assert.deepEqual({ id: r.predicates[0].id, value: r.predicates[0].value }, { id: 'A', value: '1' }); + }); + + test('acceptsBacktickContentAboveMinimumLength', () => { + // `A=12` — length 4 (limit+1). + const r = parsePredicates('`A=12`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].value, '12'); + }); + + test('rejectsEqualsSignAtIndexZero', () => { + // `=value` — eqIdx 0 (limit-1 of the `eqIdx < 1` guard). + const r = parsePredicates('`=value`'); + assert.equal(r.predicates.length, 0); + }); + + test('acceptsEqualsSignAtIndexOne', () => { + // `A=value` — eqIdx 1 (limit). + const r = parsePredicates('`A=value`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].id, 'A'); + }); + + test('acceptsEqualsSignAboveIndexOne', () => { + // `AB=value` — eqIdx 2 (limit+1). + const r = parsePredicates('`AB=value`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].id, 'AB'); + }); + + test('rejectsInlineCodeWithoutEqualsSign', () => { + const r = parsePredicates('`ID`'); + assert.equal(r.predicates.length, 0); + }); + + test('reportsEmptyValueAsMalformedRatherThanDroppingSilently', () => { + const r = parsePredicates('`ID=`'); + assert.equal(r.predicates.length, 0); + assert.equal(r.malformed.length, 1, 'an empty value must surface as a diagnostic, not vanish silently'); + assert.equal(r.malformed[0].reason, 'empty-value'); + }); + + test('splitsOnFirstEqualsSignOnly', () => { + const r = parsePredicates('`ID=a=b=c`'); + assert.equal(r.predicates.length, 1); + assert.deepEqual({ id: r.predicates[0].id, value: r.predicates[0].value }, { id: 'ID', value: 'a=b=c' }); + }); + + test('rejectsLowercaseLeadingIdentifier', () => { + const r = parsePredicates('`foo.bar=x`'); + assert.equal(r.predicates.length, 0); + }); + + test('acceptsLowercaseSubSegments', () => { + const r = parsePredicates('`PRED.k320.rule=x`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].klass, 'PRED'); + }); + + test('acceptsHyphenatedClassSegment', () => { + const r = parsePredicates('`RELEASE-NOTES.x=y`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].klass, 'RELEASE-NOTES'); + }); + + test('rejectsWhitespaceInIdentifier', () => { + const r = parsePredicates('`FOO BAR=x`'); + assert.equal(r.predicates.length, 0); + }); + + test('acceptsSingleSegmentIdentifier', () => { + const r = parsePredicates('`FOO=x`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].klass, r.predicates[0].id); + }); + + test('preservesTrailingWhitespaceInValueVerbatim', () => { + const r = parsePredicates('`ID=1 `'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].value, '1 ', 'trailing whitespace in the value must not be trimmed'); + }); + + test('acceptsBacktickWithinValue', () => { + const r = parsePredicates('`ID=a`b`'); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].value, 'a`b'); + }); + + test('rejectsPathAndQueryCharactersInIdentifier', () => { + const r = parsePredicates('`A/B?c=d`'); + assert.equal(r.predicates.length, 0); + }); + + test('rejectsDoubledDotEmptySegment', () => { + // DEFECT.CONTEXT-PREDICATES-ID-REDOS (MAJOR review finding): the + // structural id validator (isValidId) splits on '.' and rejects any + // empty segment. This is a deliberate behavior CHANGE vs. the old + // `ID_RE` regex, which accepted `A..b` because `.` was inside the + // subsequent-segment character class. The real repo CONTEXT.md was + // checked (`grep -nE '`[A-Z][A-Za-z0-9_.-]*\\.\\.[A-Za-z0-9_.-]*=' + // CONTEXT.md`) and contains ZERO ids with a doubled dot, so this is a + // pure grammar-tightening with no behavior loss against real data — + // pinned here so a future change cannot silently re-loosen it. + const r = parsePredicates('`A..b=value`'); + assert.equal(r.predicates.length, 0, 'a doubled dot (empty segment) must be rejected, not silently accepted'); + }); + + test('idGrammarValidationIsLinearTimeAgainstManyConsecutiveDots', () => { + // DEFECT.CONTEXT-PREDICATES-ID-REDOS (MAJOR review finding): the old + // `ID_RE`'s `(?:\.[A-Za-z0-9_.-]+)*` group was exponential in the number + // of consecutive dots (measured: ~565ms for 40 dots). The structural + // per-segment validator is linear. A generous wall-clock bound is used + // only as a smoke check; the load-bearing assertion is that the result + // is a clean rejection (an id-shaped line with 60 consecutive dots has + // an empty segment at every step and must not parse). + const dots = '.'.repeat(60); + const md = `\`A${dots}x=value\``; + const start = Date.now(); + const r = parsePredicates(md); + const elapsedMs = Date.now() - start; + assert.equal(r.predicates.length, 0, 'an id with 60 consecutive dots has empty segments and must cleanly reject'); + assert.ok(elapsedMs < 1000, `expected well under 1s (linear time), got ${elapsedMs}ms — possible ReDoS regression`); + }); +}); + +// ─── D. CRLF / newline fidelity ──────────────────────────────────────────── + +describe('parsePredicates: CRLF/newline fidelity (D)', () => { + test('parsesBareDeclarationUnderCrlf', () => { + const r = parsePredicates('`FOO=bar`' + CRLF); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].id, 'FOO'); + }); + + test('parsesListItemDeclarationUnderCrlf', () => { + const r = parsePredicates('- `FOO=bar`' + CRLF); + assert.equal(r.predicates.length, 1); + }); + + test('parsesIndentedListDeclarationUnderCrlf', () => { + const r = parsePredicates(' - `FOO=bar`' + CRLF); + assert.equal(r.predicates.length, 1); + }); + + test('skipsFencedDeclarationUnderCrlf', () => { + const md = ['```', '`FOO=bar`', '```', ''].join(CRLF); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0); + }); + + test('skipsBlockquoteDeclarationUnderCrlf', () => { + const r = parsePredicates('> `FOO=bar`' + CRLF); + assert.equal(r.predicates.length, 0); + }); + + test('tracksSectionAndLineNumbersUnderCrlf', () => { + const md = ['# Section Name', '`FOO=bar`', ''].join(CRLF); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 1); + assert.equal(r.predicates[0].section, 'Section Name'); + assert.equal(r.predicates[0].line, 2); + }); + + test('parsesMixedLfAndCrlfDocument', () => { + const md = '`FOO=bar`' + CRLF + '- `BAZ=qux`' + '\n'; + const r = parsePredicates(md); + assert.equal(r.predicates.length, 2); + assert.deepEqual(r.predicates.map((p) => p.id).sort(), ['BAZ', 'FOO']); + }); + + test('yieldsNoPredicatesForLoneCrDocumentAsDocumentedLimit', () => { + // Pins the documented limit (design known-limit 2): lone-CR-only line + // endings are unsupported; no `\r`-only file exists in this repo. + const md = '`FOO=bar`\r`BAZ=qux`\r'; + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0); + }); +}); + +// ─── E. Duplicate detection + validation ─────────────────────────────────── + +describe('parsePredicates: duplicate detection + validation (E)', () => { + test('reportsDuplicateIdentifierWithDifferentValues', () => { + const md = ['`FOO=a`', '`FOO=b`'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.duplicates.length, 1); + assert.deepEqual(r.duplicates[0], { id: 'FOO', count: 2 }); + }); + + test('reportsDuplicateIdentifierEvenWhenValuesAreIdentical', () => { + const md = ['`FOO=a`', '`FOO=a`'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.duplicates.length, 1, 'identical-value duplicates must not be silently deduped'); + assert.deepEqual(r.duplicates[0], { id: 'FOO', count: 2 }); + }); + + test('reportsDuplicateCountForThreeOccurrences', () => { + const md = ['`FOO=a`', '`FOO=b`', '`FOO=c`'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.duplicates.length, 1); + assert.equal(r.duplicates[0].count, 3); + }); + + test('reportsNoDuplicateForSingleOccurrence', () => { + const r = parsePredicates('`FOO=a`'); + assert.equal(r.duplicates.length, 0); + }); + + test('doesNotCountFenceSkippedOccurrenceAsDuplicate', () => { + const md = ['`FOO=a`', '```', '`FOO=b`', '```'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.duplicates.length, 0, 'a fence-skipped occurrence is not a declaration'); + assert.equal(r.predicates.length, 1); + }); + + test('doesNotCountCommentedOccurrenceAsDuplicate', () => { + const md = ['`FOO=a`', ''].join('\n'); + const r = parsePredicates(md); + assert.equal(r.duplicates.length, 0); + assert.equal(r.predicates.length, 1); + }); + + test('reportsMalformedAndDuplicateDiagnosticsTogether', () => { + const md = ['`FOO=a`', '`FOO=b`', '`BAR=`'].join('\n'); + const r = parsePredicates(md); + assert.equal(r.duplicates.length, 1, 'the duplicate path must not be suppressed by the malformed path'); + assert.equal(r.malformed.length, 1, 'the malformed path must not be suppressed by the duplicate path'); + }); + + test('realContextMdHasNoDuplicateIdentifiers', () => { + const md = fs.readFileSync(path.join(ROOT, 'CONTEXT.md'), 'utf8'); + const r = parsePredicates(md); + assert.equal(r.duplicates.length, 0, 'the real CONTEXT.md must carry no duplicate predicate ids'); + }); +}); + +// ─── I. Independence + real-corpus regression ────────────────────────────── + +describe('parsePredicates: independence + real-corpus regression (I)', () => { + test('parserHasNoCrossTestSharedState', () => { + // Run in an order that would surface a leaking module-level cache: parse + // a document with a duplicate, then a clean document, then re-parse the + // first — results must be identical each time, regardless of call order. + const dupMd = ['`FOO=a`', '`FOO=b`'].join('\n'); + const cleanMd = '`BAR=x`'; + + const firstPass = parsePredicates(dupMd); + const cleanPass = parsePredicates(cleanMd); + const secondPass = parsePredicates(dupMd); + + assert.equal(cleanPass.duplicates.length, 0, 'a clean parse must never see the previous call\'s duplicate'); + assert.deepEqual(secondPass.duplicates, firstPass.duplicates, 'repeating the same input must repeat the same result'); + assert.equal(secondPass.predicates.length, firstPass.predicates.length); + }); + + test('realContributingMdYieldsNoPredicates', () => { + // Fixture provenance (#2371): CONTRIBUTING.md contains + // `GITHUB_BASE_REF=next …` (inside a blockquote) and + // `export GSD_BLOCKED_AUTHOR_REGEX='@example-corp\.com$'` (inside a + // fenced bash block) — uppercase, `=`-bearing, backtick-adjacent text + // authored by someone who never heard of this grammar. + const md = fs.readFileSync(path.join(ROOT, 'CONTRIBUTING.md'), 'utf8'); + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0, 'a document that never heard of the grammar must yield zero predicates'); + }); + + test('realContextMdParsesFullPredicateSet', () => { + const md = fs.readFileSync(path.join(ROOT, 'CONTEXT.md'), 'utf8'); + const r = parsePredicates(md); + assert.equal(r.duplicates.length, 0); + assert.ok(r.predicates.length > 0, 'the real CONTEXT.md must yield a non-empty predicate set'); + const classes = new Set(r.predicates.map((p) => p.klass)); + assert.ok(classes.size >= 20, `expected >= 20 classes in the live predicate set, got ${classes.size}`); + }); +}); + +// ─── selectPredicates / buildIndex smoke coverage (not in the row matrix, +// but exercised here since they are pure, no-I/O functions covered by unit +// tests per the risk table — the CLI/query surfaces are covered separately). ─── + +describe('selectPredicates + buildIndex: pure-function smoke coverage', () => { + test('selectPredicates filters by klass/prefix/contains independently', () => { + const r = parsePredicates(['`FOO.a=hello world`', '`FOO.b=other`', '`BAR.a=hello`'].join('\n')); + const byKlass = selectPredicates(r.predicates, { klass: 'FOO' }); + assert.equal(byKlass.length, 2); + const byPrefix = selectPredicates(r.predicates, { prefix: 'FOO.a' }); + assert.equal(byPrefix.length, 1); + const byContains = selectPredicates(r.predicates, { contains: 'hello' }); + assert.equal(byContains.length, 2); + }); + + test('buildIndex omits the line field from every entry (S5)', () => { + const r = parsePredicates('`FOO=bar`'); + const index = buildIndex(r.predicates); + assert.equal(index.predicates.length, 1); + assert.ok(!Object.prototype.hasOwnProperty.call(index.predicates[0], 'line')); + }); +}); + +// ─── Parity: fenced-line determination vs markdown-sectionizer.scanFencedBlocks +// (DEFECT.GENERATIVE-FIX) ─────────────────────────────────────────────────── +// +// context-predicates.cts derives its fenced-line skip flags from +// markdown-sectionizer.cts's exported `scanFencedBlocks` seam rather than +// carrying its own copy of the fence state machine. This suite asserts that +// parsePredicates' observable skip/keep decision for every ID-marker line +// agrees with what `scanFencedBlocks` independently reports for that same +// `lines` array, across the fence-shape table below — so a future change to +// the shared scanner cannot silently diverge from predicate parsing. + +describe('parsePredicates: fence-skip parity with markdown-sectionizer.scanFencedBlocks', () => { + const BARE_ID_RE = /^`([A-Za-z][A-Za-z0-9._-]*)=/; + + function assertFenceParity(name, lines) { + test(name, () => { + const md = lines.join('\n'); + const blocks = scanFencedBlocks(lines); + const expectedSkip = new Array(lines.length).fill(false); + for (const block of blocks) { + const end = block.closeLineIdx === -1 ? lines.length - 1 : block.closeLineIdx; + for (let i = block.openLineIdx; i <= end; i++) expectedSkip[i] = true; + } + + const r = parsePredicates(md); + const parsedIds = new Set(r.predicates.map((p) => p.id)); + + let sawMarker = false; + for (let i = 0; i < lines.length; i++) { + const m = BARE_ID_RE.exec(lines[i].trim()); + if (!m) continue; + sawMarker = true; + const id = m[1]; + if (expectedSkip[i]) { + assert.ok( + !parsedIds.has(id), + `${name}: line ${i} (${id}) is inside a scanFencedBlocks fence and must not be parsed as live`, + ); + } else { + assert.ok( + parsedIds.has(id), + `${name}: line ${i} (${id}) is outside any scanFencedBlocks fence and must be parsed as live`, + ); + } + } + assert.ok(sawMarker, `${name}: fixture must contain at least one ID marker line`); + }); + } + + assertFenceParity('3-backtick fence', ['`BEFORE=1`', '```', '`INSIDE=2`', '```', '`AFTER=3`']); + + assertFenceParity('3-tilde fence', ['`BEFORE=1`', '~~~', '`INSIDE=2`', '~~~', '`AFTER=3`']); + + assertFenceParity('4-backtick fence containing a nested 3-backtick fence', [ + '`BEFORE=1`', + '````', + '```', + '`INSIDE=2`', + '```', + '````', + '`AFTER=3`', + ]); + + assertFenceParity('language-tagged fence', [ + '`BEFORE=1`', + '```bash', + '`INSIDE=2`', + '```', + '`AFTER=3`', + ]); + + assertFenceParity('info string containing a backtick is not a valid opener', [ + '`BEFORE=1`', + '``` `evil` ', + '`STILL=2`', + '```', + '`INSIDE=3`', + '```', + '`AFTER=4`', + ]); + + assertFenceParity('indented (<=3 space) fence', [ + '`BEFORE=1`', + ' ```', + '`INSIDE=2`', + ' ```', + '`AFTER=3`', + ]); + + assertFenceParity('unterminated fence skips to end of file', [ + '`BEFORE=1`', + '```', + '`INSIDE=2`', + '`ALSOINSIDE=3`', + ]); +}); diff --git a/tests/gen-context-index.test.cjs b/tests/gen-context-index.test.cjs new file mode 100644 index 000000000..e6e90fac3 --- /dev/null +++ b/tests/gen-context-index.test.cjs @@ -0,0 +1,445 @@ +'use strict'; + +/** + * Integration tests for scripts/gen-context-index.cjs — the CI gate that + * keeps docs/CONTEXT-INDEX.json in sync with the predicates declared in the + * repo-root CONTEXT.md (ADR-1671, #2928 Phase 1, rows F1-F17). + * + * The committed artifact is plain JSON (not a `.cjs` CommonJS module): a + * shipped runtime module is the wrong place for ~120 KB of arbitrary + * CONTEXT.md prose, and embedding it there tripped both + * tests/cline-install.test.cjs (leaked `.claude/hooks/...` path literals) and + * tests/package-name-single-source.test.cjs (hardcoded package-name + * literals) — both true positives against runtime-code content scanning. + * docs/CONTEXT-INDEX.json mirrors docs/INVENTORY-MANIFEST.json's precedent: + * a committed, generated, `--check`-guarded JSON manifest that is not + * runtime code. + * + * Fixture isolation (ADR-1671 Phase 1 commit 3): gen-context-index.cjs now + * accepts `--context-path

` / `--index-path

` CLI overrides (and the + * same-named parameters on the exported `checkReport`/`buildFreshIndex` + * pure functions), so every test here spawns the real CLI (spawnSync, not an + * engine-direct call — an engine-direct call is false-green for CLI behavior + * per the design's own risk analysis) pointed directly at temp fixture + * files, with NO fs monkeypatching. The prior `--require` preload + * (tests/helpers/gen-context-index-fs-fixture.cjs) redirected two hardcoded + * absolute paths by patching fs.readFileSync/existsSync/writeFileSync — that + * indirection is no longer needed now that the paths are directly + * injectable, and the preload has been deleted. + * + * Prohibited: Raw Text Matching on Test Outputs (CONTRIBUTING.md). This + * generator's `--check --json` mode emits a typed `{ ok, reason, duplicates, + * count, classes }` report — `reason` is always one of the frozen `REASON` + * enum values. Rows F7-F10 assert on `report.reason === REASON.FAIL_X` + * (and, for F7, that `report.duplicates` names the duplicate id) instead of + * exit-code-only / stderr-substring assertions. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const { serializeIndex, buildFreshIndex, checkReport, REASON } = require('../scripts/gen-context-index.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'gen-context-index.cjs'); +const REAL_CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md'); + +const STACK_FRAME_RE = /\n\s+at\s+\S+\s+\(.*:\d+:\d+\)/; + +/** + * Spawn the real gen-context-index.cjs CLI with explicit `--context-path` / + * `--index-path` overrides — no fs monkeypatching, no `--require` preload. + * + * @param {string[]} args - CLI args (e.g. ['--check', '--json']). + * @param {{contextPath?: string, indexPath?: string}} [paths] - absolute + * fixture paths to pass via `--context-path`/`--index-path`. Omit a key to + * leave that seam at its real-repo default (read-only, untouched). + * @returns {{code: number, stdout: string, stderr: string}} + */ +function runGenContextIndex(args, paths = {}) { + const fullArgs = [...args]; + if (paths.contextPath !== undefined) fullArgs.push('--context-path', paths.contextPath); + if (paths.indexPath !== undefined) fullArgs.push('--index-path', paths.indexPath); + + try { + const stdout = execFileSync(process.execPath, [SCRIPT, ...fullArgs], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 30000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + return { + code: err.status ?? 1, + stdout: err.stdout ? err.stdout.toString() : '', + stderr: err.stderr ? err.stderr.toString() : '', + }; + } +} + +/** + * Parse the single JSON line `--check --json` writes to stdout. + * + * @param {string} stdout + * @returns {object} + */ +function parseJsonReport(stdout) { + return JSON.parse(stdout.trim()); +} + +describe('gen-context-index.cjs REASON enum (three-coordinated-changes lock)', () => { + test('REASON key set is exactly the documented set', () => { + // Locks the documented enum shape (CONTRIBUTING.md three-coordinated- + // changes pattern): adding a reason requires updating this assertion + // too, so the typed surface cannot silently drift from what tests expect. + assert.deepEqual(Object.keys(REASON).sort(), [ + 'FAIL_CONTEXT_MISSING', + 'FAIL_CONTEXT_UNREADABLE', + 'FAIL_DUPLICATE_IDS', + 'FAIL_INDEX_MISSING', + 'FAIL_INDEX_UNPARSEABLE', + 'FAIL_LIB_NOT_BUILT', + 'FAIL_STALE', + 'OK_UP_TO_DATE', + ]); + }); + + test('REASON is frozen', () => { + assert.ok(Object.isFrozen(REASON)); + }); +}); + +describe('gen-context-index.cjs --check (F)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gen-context-index-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('checkExitsZeroWhenIndexIsFresh', () => { + // Read-only against the real, already-fresh repo state — no override + // needed, and nothing is mutated. + const r = runGenContextIndex(['--check']); + assert.equal(r.code, 0); + }); + + test('checkExitsZeroAfterPureLineShift', () => { + // S5: the committed artifact carries no `line` field, so a pure line + // shift in CONTEXT.md must not perturb the byte-identical serialization. + const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8'); + const lines = real.split(/\r?\n/); + const shifted = [lines[0], '', '', ...lines.slice(1)].join('\n'); + const shiftedPath = path.join(tmpDir, 'CONTEXT-shifted.md'); + fs.writeFileSync(shiftedPath, shifted, 'utf8'); + + const r = runGenContextIndex(['--check'], { contextPath: shiftedPath }); + assert.equal(r.code, 0, 'a pure line shift must not fail the gate (Q4 resolution, S5)'); + }); + + test('checkExitsOneWhenPredicateValueChanged', () => { + const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8'); + const modified = real.replace( + /`RULESET\.PR-SCOPE\.one-concern-per-pr=[^`]*`/, + '`RULESET.PR-SCOPE.one-concern-per-pr=CHANGED VALUE FOR TEST`', + ); + assert.notEqual(modified, real, 'fixture setup sanity: the substitution must actually apply'); + const modifiedPath = path.join(tmpDir, 'CONTEXT-value-changed.md'); + fs.writeFileSync(modifiedPath, modified, 'utf8'); + + const r = runGenContextIndex(['--check'], { contextPath: modifiedPath }); + assert.equal(r.code, 1); + }); + + test('checkExitsOneWhenPredicateAdded', () => { + const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8'); + const added = real + '\n`ZZZTEST.added-by-test=value`\n'; + const addedPath = path.join(tmpDir, 'CONTEXT-added.md'); + fs.writeFileSync(addedPath, added, 'utf8'); + + const r = runGenContextIndex(['--check'], { contextPath: addedPath }); + assert.equal(r.code, 1); + }); + + test('checkExitsOneWhenPredicateRemoved', () => { + const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8'); + const removed = real.replace(/`RULESET\.PR-SCOPE\.one-concern-per-pr=[^`]*`\r?\n/, ''); + assert.notEqual(removed, real, 'fixture setup sanity: the removal must actually apply'); + const removedPath = path.join(tmpDir, 'CONTEXT-removed.md'); + fs.writeFileSync(removedPath, removed, 'utf8'); + + const r = runGenContextIndex(['--check'], { contextPath: removedPath }); + assert.equal(r.code, 1); + }); + + test('checkExitsOneWhenClassSetChanged', () => { + const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8'); + const classGained = real + '\n`BRANDNEWCLASSFORTEST.x=y`\n'; + const classGainedPath = path.join(tmpDir, 'CONTEXT-class-gained.md'); + fs.writeFileSync(classGainedPath, classGained, 'utf8'); + + const r = runGenContextIndex(['--check'], { contextPath: classGainedPath }); + assert.equal(r.code, 1); + }); + + test('checkExitsOneAndNamesDuplicateIdentifier (F7)', () => { + const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8'); + const dupPath = path.join(tmpDir, 'CONTEXT-dup.md'); + const dupIntroduced = real + '\n`RULESET.PR-SCOPE.one-concern-per-pr=duplicate copy for test`\n'; + fs.writeFileSync(dupPath, dupIntroduced, 'utf8'); + + const r = runGenContextIndex(['--check', '--json'], { contextPath: dupPath }); + assert.equal(r.code, 1); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no bare stack trace in non-debug failure output'); + + const report = parseJsonReport(r.stdout); + assert.equal(report.ok, false); + assert.equal(report.reason, REASON.FAIL_DUPLICATE_IDS); + assert.ok( + report.duplicates.some((d) => d.id === 'RULESET.PR-SCOPE.one-concern-per-pr'), + 'report.duplicates must name the duplicate id', + ); + }); + + test('checkExitsOneWithRemedyWhenIndexMissing (F8)', () => { + const missingIndexPath = path.join(tmpDir, 'does-not-exist.json'); + const r = runGenContextIndex(['--check', '--json'], { indexPath: missingIndexPath }); + assert.equal(r.code, 1); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE); + + const report = parseJsonReport(r.stdout); + assert.equal(report.ok, false); + assert.equal(report.reason, REASON.FAIL_INDEX_MISSING); + }); + + test('checkExitsOneWithNamedReasonWhenIndexCorrupt (F9)', () => { + const corruptIndexPath = path.join(tmpDir, 'corrupt-index.json'); + fs.writeFileSync(corruptIndexPath, 'this is not { valid javascript', 'utf8'); + + const r = runGenContextIndex(['--check', '--json'], { indexPath: corruptIndexPath }); + assert.equal(r.code, 1); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no bare stack trace for a corrupt committed index'); + + const report = parseJsonReport(r.stdout); + assert.equal(report.ok, false); + assert.equal(report.reason, REASON.FAIL_INDEX_UNPARSEABLE); + }); + + test('checkExitsOneWhenContextMdMissing (F10)', () => { + const missingContextPath = path.join(tmpDir, 'does-not-exist.md'); + const r = runGenContextIndex(['--check', '--json'], { contextPath: missingContextPath }); + assert.equal(r.code, 1); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no bare stack trace when CONTEXT.md is missing'); + + const report = parseJsonReport(r.stdout); + assert.equal(report.ok, false); + assert.equal(report.reason, REASON.FAIL_CONTEXT_MISSING); + }); + + test('checkExitsOneWhenContextMdUnreadable', () => { + // Fault injection via the mandated technique (CONTRIBUTING.md / + // CLAUDE.md cross-platform IO-failure rule): monkeypatch fs.readFileSync + // to throw an injected EACCES for one specific fixture path, restore in + // `finally`. Never chmod 0o000 (root bypasses mode bits). This is an + // in-process call to the exported `checkReport` pure function rather + // than a subprocess spawn — a subprocess's fs cannot be monkeypatched + // from the parent test process without a `--require` preload, and + // `checkReport` IS the typed surface under test here, so calling it + // directly is not an engine-direct false-green for CLI *argv* behavior + // (that risk is covered by the spawned-CLI tests above); it is the + // correct level to exercise a fault the CLI itself cannot inject. + const fixtureContextPath = path.join(tmpDir, 'unreadable-context.md'); + fs.writeFileSync(fixtureContextPath, '`FOO=bar`\n', 'utf8'); + + const origReadFileSync = fs.readFileSync; + fs.readFileSync = function patchedReadFileSync(p, ...rest) { + if (p === fixtureContextPath) { + const err = new Error(`EACCES: permission denied, open '${p}' (injected by test, never a real fs fault)`); + err.code = 'EACCES'; + throw err; + } + return origReadFileSync.call(fs, p, ...rest); + }; + try { + const report = checkReport(fixtureContextPath, path.join(tmpDir, 'unused-index.json')); + assert.equal(report.ok, false); + assert.equal(report.reason, REASON.FAIL_CONTEXT_UNREADABLE); + } finally { + fs.readFileSync = origReadFileSync; + } + }); + + test('checkIsCrlfAgnostic (F17)', () => { + // F17: a CRLF-committed index compared against the (LF) fresh real + // CONTEXT.md must still exit 0 — comparison is CRLF-normalized. + const freshSerialized = serializeIndex(buildFreshIndex()); + const crlfSerialized = freshSerialized.replace(/\n/g, '\r\n'); + const crlfIndexPath = path.join(tmpDir, 'context-index-crlf.json'); + fs.writeFileSync(crlfIndexPath, crlfSerialized, 'utf8'); + + const r = runGenContextIndex(['--check'], { indexPath: crlfIndexPath }); + assert.equal(r.code, 0, 'CRLF-vs-LF committed/fresh comparison must be normalized, not a false failure'); + }); +}); + +describe('gen-context-index.cjs --write / default / usage (F)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gen-context-index-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('writeThenCheckIsClean', () => { + const writeTarget = path.join(tmpDir, 'write-target.json'); + const w = runGenContextIndex(['--write'], { indexPath: writeTarget }); + assert.equal(w.code, 0); + assert.ok(fs.existsSync(writeTarget), '--write must create the fixture-redirected index file'); + + const c = runGenContextIndex(['--check'], { indexPath: writeTarget }); + assert.equal(c.code, 0, '--check must be clean immediately after --write'); + }); + + test('writeIsByteIdenticalAcrossRuns', () => { + const target1 = path.join(tmpDir, 'w1.json'); + const target2 = path.join(tmpDir, 'w2.json'); + assert.equal(runGenContextIndex(['--write'], { indexPath: target1 }).code, 0); + assert.equal(runGenContextIndex(['--write'], { indexPath: target2 }).code, 0); + + const content1 = fs.readFileSync(target1, 'utf8'); + const content2 = fs.readFileSync(target2, 'utf8'); + assert.equal(content1, content2, '--write must be deterministic across independent runs'); + }); + + test('writtenIndexContainsNoLineField', () => { + const target = path.join(tmpDir, 'no-line-field.json'); + assert.equal(runGenContextIndex(['--write'], { indexPath: target }).code, 0); + const content = fs.readFileSync(target, 'utf8'); + assert.equal(content.includes('"line"'), false, 'the committed artifact must carry no `line` field anywhere (S5)'); + }); + + test('defaultInvocationPrintsIndexToStdout', () => { + // Fully safe against the real repo: default mode only reads CONTEXT.md + // and the compiled predicates lib (read-only) and writes nothing. + const r = runGenContextIndex([]); + assert.equal(r.code, 0); + assert.ok(r.stdout.length > 0); + // Compare against the exact expected serialization (computed the same + // way the CLI does, via the exported pure functions) rather than + // hand-parsing the rendered text — avoids brittle delimiter-scanning + // over a JSON payload that legitimately contains ';' inside string values. + const expected = serializeIndex(buildFreshIndex()) + '\n'; + assert.equal(r.stdout, expected); + }); + + test('unknownFlagExitsWithUsage', () => { + // Safe against the real repo: the unknown-flag branch never reads + // CONTEXT.md or the committed index at all. + const r = runGenContextIndex(['--totally-bogus-flag']); + assert.notEqual(r.code, 0); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'usage output must never be a bare stack trace'); + }); + + // ─── DEFECT.GEN-CONTEXT-INDEX-PARSEARGS-GATE-BYPASS (MAJOR review finding): + // conflicting `--check --write` must be a hard usage error, not a silent + // `--write` win, and a missing/flag-shaped path value must never resolve + // to the cwd (which previously leaked a raw EISDIR stack trace). ──────── + + test('checkAndWriteTogetherIsUsageErrorNotASilentWrite (a)', () => { + const target = path.join(tmpDir, 'should-not-be-written.json'); + const r = runGenContextIndex(['--check', '--write'], { indexPath: target }); + assert.notEqual(r.code, 0, '--check --write together must not silently exit 0 as a write'); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'usage output must never be a bare stack trace'); + assert.equal(fs.existsSync(target), false, '--write must never win over --check and rewrite the index'); + }); + + test('writeAndCheckReversedOrderIsAlsoAUsageError (a)', () => { + const target = path.join(tmpDir, 'should-also-not-be-written.json'); + const r = runGenContextIndex(['--write', '--check'], { indexPath: target }); + assert.notEqual(r.code, 0, 'conflicting mode flags must be a usage error regardless of order'); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE); + assert.equal(fs.existsSync(target), false); + }); + + test('missingTrailingValueForContextPathIsUsageErrorNotEisdirStackTrace (b)', () => { + const target = path.join(tmpDir, 'should-not-be-written-2.json'); + // `--context-path` is the LAST arg: argv[i+1] is undefined, which used + // to resolve to the cwd via `path.resolve(undefined ?? '')`. + const r = runGenContextIndex(['--write', '--index-path', target, '--context-path']); + assert.notEqual(r.code, 0, 'a missing --context-path value must be a usage error'); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'must never leak a raw EISDIR (or any) stack trace'); + assert.equal(fs.existsSync(target), false, 'no write must happen when the path argument is rejected'); + }); + + test('flagShapedValueForIndexPathIsUsageErrorNotSwallowedAsALiteralPath (b)', () => { + // `--index-path` is immediately followed by another flag rather than a + // path — must be rejected, not silently swallowed as the literal path + // "--json". + const r = runGenContextIndex(['--write', '--index-path', '--json']); + assert.notEqual(r.code, 0, 'a flag-shaped --index-path value must be a usage error'); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE); + }); +}); + +// ─── DEFECT.GEN-CONTEXT-INDEX-DUPLICATE-GATE-UNPROVEN (MAJOR review finding): +// `FAIL_DUPLICATE_IDS` was only ever proven against synthetic fixtures — this +// branch hand-deleted the ONE live duplicate +// (`RULESET.WORKFLOW_MARKDOWN.FENCES`) from the real CONTEXT.md, so the +// committed docs/CONTEXT-INDEX.json ships `duplicates: []` and the gate has +// never been shown to catch a REAL duplicate in the real document. This +// suite re-inserts the exact deleted line (recovered from +// `git show origin/next:CONTEXT.md`) into a copy of the REAL CONTEXT.md and +// runs the real generator CLI against it. ─────────────────────────────────── + +describe('gen-context-index.cjs --check against a real-CONTEXT.md duplicate (real-data proof)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gen-context-index-real-dup-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('reinsertingTheDeletedRulesetWorkflowMarkdownFencesLineFailsWithNamedDuplicate', () => { + // The exact line this branch deleted from the real CONTEXT.md (does NOT + // mention MD040 — the live line that replaced it does). + const deletedLine = + '`RULESET.WORKFLOW_MARKDOWN.FENCES=when editing shell snippets inside workflow markdown, preserve the opening language fence; malformed fence can create fresh CodeRabbit threads`'; + + const real = fs.readFileSync(REAL_CONTEXT_PATH, 'utf8'); + assert.ok( + real.includes('RULESET.WORKFLOW_MARKDOWN.FENCES'), + 'sanity: the real CONTEXT.md must still carry the live (MD040) FENCES line', + ); + assert.ok(!real.includes(deletedLine), 'sanity: the deleted line must not already be present verbatim'); + + const reinserted = real + '\n' + deletedLine + '\n'; + const fixturePath = path.join(tmpDir, 'CONTEXT-real-with-reinserted-duplicate.md'); + fs.writeFileSync(fixturePath, reinserted, 'utf8'); + + const r = runGenContextIndex(['--check', '--json'], { contextPath: fixturePath }); + assert.equal(r.code, 1, 'a real duplicate reintroduced into the real CONTEXT.md must fail the gate'); + assert.doesNotMatch(r.stderr, STACK_FRAME_RE); + + const report = parseJsonReport(r.stdout); + assert.equal(report.ok, false); + assert.equal(report.reason, REASON.FAIL_DUPLICATE_IDS); + assert.ok( + report.duplicates.some((d) => d.id === 'RULESET.WORKFLOW_MARKDOWN.FENCES'), + 'report.duplicates must name RULESET.WORKFLOW_MARKDOWN.FENCES as the real duplicate', + ); + }); +}); diff --git a/tests/markdown-sectionizer.test.cjs b/tests/markdown-sectionizer.test.cjs index f7c3e26f2..def26dcec 100644 --- a/tests/markdown-sectionizer.test.cjs +++ b/tests/markdown-sectionizer.test.cjs @@ -33,6 +33,7 @@ const fc = require('./helpers/fast-check-setup.cjs'); const { stripFencedCode, extractFencedBlock, + scanFencedBlocks, tokenizeHeadings, collectSections, collectSection, @@ -350,6 +351,160 @@ describe('extractFencedBlock', () => { }); }); +// ─── scanFencedBlocks (public export) ───────────────────────────────────────── +// #2928: `export` was newly added to this function and its `FencedBlockRecord` +// interface, making it public API for the first time (context-predicates.cts +// consumes it directly). Hyrum's Law: a newly-public contract needs its own +// lock-in test, independent of the consumer that motivated exporting it. + +describe('scanFencedBlocks (public export)', () => { + test('is actually exported from the compiled module as a function', () => { + assert.equal(typeof scanFencedBlocks, 'function'); + }); + + test('returns {char, len, infoString, openLineIdx, closeLineIdx} with 0-based line indices', () => { + const lines = [ + 'before', + '```js', + 'const x = 1;', + '```', + 'after', + ]; + const blocks = scanFencedBlocks(lines); + assert.equal(blocks.length, 1); + assert.deepEqual(blocks[0], { + char: '`', + len: 3, + infoString: 'js', + openLineIdx: 1, + closeLineIdx: 3, + }); + }); + + test('closeLineIdx is -1 for an unterminated fence (EOF while open)', () => { + const lines = [ + 'before', + '```', + 'body, never closed', + ]; + const blocks = scanFencedBlocks(lines); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].openLineIdx, 1); + assert.equal(blocks[0].closeLineIdx, -1); + }); + + test('requires >=3 backticks or >=3 tildes to open a fence', () => { + assert.deepEqual(scanFencedBlocks(['``', 'not a fence']), []); + assert.deepEqual(scanFencedBlocks(['~~', 'not a fence']), []); + const backtickBlocks = scanFencedBlocks(['```', 'x', '```']); + assert.equal(backtickBlocks.length, 1); + assert.equal(backtickBlocks[0].char, '`'); + const tildeBlocks = scanFencedBlocks(['~~~', 'x', '~~~']); + assert.equal(tildeBlocks.length, 1); + assert.equal(tildeBlocks[0].char, '~'); + }); + + test('tolerates up to 3 spaces of indent on the opening delimiter', () => { + const blocks = scanFencedBlocks([' ```', 'body', '```']); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].openLineIdx, 0); + assert.equal(blocks[0].closeLineIdx, 2); + }); + + test('4+ spaces of indent is not recognised as a fence delimiter', () => { + const blocks = scanFencedBlocks([' ```', 'still not a fence']); + assert.deepEqual(blocks, []); + }); + + test('a closer must be the same delimiter char with run length >= the opener, and no trailing text', () => { + // Same char, longer run: valid closer. + const longerCloser = scanFencedBlocks(['```', 'body', '`````']); + assert.equal(longerCloser.length, 1); + assert.equal(longerCloser[0].closeLineIdx, 2); + + // Same char, shorter run: not a valid closer -> content, fence stays open (EOF -> -1). + const shorterCloser = scanFencedBlocks(['````', 'body', '```']); + assert.equal(shorterCloser.length, 1); + assert.equal(shorterCloser[0].closeLineIdx, -1); + }); + + test('mismatched delimiter char while a fence is open is CONTENT, not a boundary — a 3-backtick line inside a 4-backtick fence does not close it', () => { + const lines = [ + '````outer', + '```coverage', + 'nested body', + '```', + '````', + ]; + const blocks = scanFencedBlocks(lines); + assert.equal(blocks.length, 1, 'only the outer 4-backtick fence is a real block'); + assert.equal(blocks[0].char, '`'); + assert.equal(blocks[0].len, 4); + assert.equal(blocks[0].openLineIdx, 0); + assert.equal(blocks[0].closeLineIdx, 4); + }); + + test('a same-char run that is too short, encountered while open, is content not a closer', () => { + const lines = [ + '````', + '```', + 'still inside', + '````', + ]; + const blocks = scanFencedBlocks(lines); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].openLineIdx, 0); + assert.equal(blocks[0].closeLineIdx, 3); + }); + + test('a same-char, sufficient-length run carrying trailing non-whitespace text, encountered while open, is content not a closer', () => { + const lines = [ + '```', + '``` still inside (has trailing text)', + '```', + 'after', + ]; + const blocks = scanFencedBlocks(lines); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].openLineIdx, 0); + assert.equal(blocks[0].closeLineIdx, 2); + }); + + test('CommonMark §4.5: a backtick fence info string must not contain a backtick — such a line is not a valid opener', () => { + const blocks = scanFencedBlocks(['``` has ` a backtick', 'more text']); + assert.deepEqual(blocks, [], 'a backtick in the info string means the line is not a valid opener at all'); + }); + + test('infoString is the trimmed trailing text of the opener', () => { + const blocks = scanFencedBlocks(['``` js and stuff ', 'body', '```']); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].infoString, 'js and stuff'); + }); + + test('multiple sequential blocks in one document are all returned, in order', () => { + const lines = [ + 'a', + '```', + 'code1', + '```', + 'b', + '~~~py', + 'code2', + '~~~', + 'c', + ]; + const blocks = scanFencedBlocks(lines); + assert.equal(blocks.length, 2); + assert.equal(blocks[0].char, '`'); + assert.equal(blocks[0].openLineIdx, 1); + assert.equal(blocks[0].closeLineIdx, 3); + assert.equal(blocks[1].char, '~'); + assert.equal(blocks[1].infoString, 'py'); + assert.equal(blocks[1].openLineIdx, 5); + assert.equal(blocks[1].closeLineIdx, 7); + }); +}); + // ─── tokenizeHeadings ───────────────────────────────────────────────────────── describe('tokenizeHeadings', () => {