From 5a0a9f09722813d98ef251ec93c671c2eca691f1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 31 Jul 2026 15:44:19 -0400 Subject: [PATCH] fix(#2944): remove the catastrophic-backtracking regex from the ADR-1671 example (#2950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#2944): remove the catastrophic-backtracking regex from the example The non-shipping Option-E reference example carried its own copy of the predicate-id regex, which nested a dot-containing character class inside a dot-prefixed repeat. A run of N consecutive dots therefore had exponentially many partitions. Measured on next before this change: 30 dots 54ms, 35 66ms, 40 807ms — so roughly 55-60 dots hangs for hours. Not exploitable where it sits: the example is outside tsconfig.build.json, outside the npm package files list, outside the installer and outside tests, so no build step or CI job parses anything with it. Fixed because the entire point of a reference example is that people copy it forward, and ADR-1671 presents this one as the pattern for the platform. Ports the linear per-segment validation that #2928 gave the production module, so the two copies agree: both parse the real CONTEXT.md to 415 predicates across 20 classes with 0 duplicates. Doubled-dot ids are now rejected here too, matching production, and the grammar comment records it. Also refreshes the example's committed index, which #2928 made stale when it removed the duplicate predicate from CONTEXT.md. Closes #2944 * test(#2944): guard predicate-index sync and example/production parity Two regression tests for the two defects in this PR. Index sync: asserts the committed docs/CONTEXT-INDEX.json equals a fresh parse of CONTEXT.md, naming any diverging predicate ids. The merge race that reddened next was invisible to both PRs involved and only surfaced on the next PR to run lint:ci; this puts the same check inside the suite, which runs on every PR, and a mutation test proves the assertion is not vacuous. Example/production parity: asserts both copies of the parser report the same count, classes and duplicates for the real CONTEXT.md, and agree verdict-for- verdict over a table of id shapes. The divergence WAS the bug — production went linear-time while the example kept the backtracking regex, with nothing asserting they agreed. Also pins the example rejecting a 60-dot id, with the clean rejection as the binding assertion and wall-clock only as a smoke check. Notes a real tension rather than hiding it: ADR-1671 says the example sits outside tests/, and this imports it. The ADR's intent is that the example is not compiled, packaged or installed — not that it may silently rot. A parity guard does not ship it. The file states this so a reviewer can object. * fix(#2944): address both isolated review passes Two independent reviewers (correctness and security axes, neither the author). Security found nothing — it measured linearity to 100k chars across dots, hyphens, underscores and mixed classes, and showed prototype pollution is structurally unreachable because the first-segment pattern forbids lowercase and underscore-leading ids. The correctness pass found three blockers, all real. Blocker: the parity test violated ADR-1671 verbatim. The ADR lists FOUR exclusions for the reference example, the fourth being the CI test suite, and the test imported it from tests/ while its own justification comment cited only three -- constructing a rationale around the exclusion it broke. Moved to scripts/lint-example-parser-parity.cjs wired into lint:ci; a lint script is not the test suite, so the exclusion stands. The test file keeps only the docs/CONTEXT-INDEX.json sync check. Blocker: the mutation test leaked its temp dir. Its callback took no `t`, so a failing assertion skipped the bare cleanup call. Now registered via t.after(), matching the convention adr-index-gate.test.cjs documents. Blocker: the example's own committed index carries the identical merge-race staleness this PR fixes for the production one, and nothing guarded it. Deliberately NOT fixed by wiring the example's --check into CI: that artifact bakes line numbers, so it re-drifts on any unrelated CONTEXT.md line shift -- exactly ADR-1671 open question 4 -- and would make CI routinely red. The new lint asserts the line-INDEPENDENT facts instead: count, class map, duplicate set, and every (id, value) pair. Proven non-vacuous both ways: mutating a value fails and names the id, mutating only a line number passes. Major: a real divergence the parity claim would have missed. Production rejects values containing an embedded CR, LF, U+2028 or U+2029; the example did not, so a value with an embedded lone CR was rejected by one copy and accepted by the other. Ported, and now covered by the parity table. Also, found while verifying rather than reported: malformed diagnostics covered only empty values. A doubled-dot id, a space in an id, and a lowercase-leading id were all dropped silently. That contradicts the module's own intent -- a typo should be diagnosable, and a space in an id is a likely one -- and predicates are contractually cited, so a silently vanished predicate is the failure mode that matters. Each rejection class now carries a named reason in both copies, while ordinary inline code still yields none. Trues up counts my own change staled: the example README and ADR-1671's prototype figures said 416 and 393/18 against a real 415/20/0. Closes #2944 * chore(#2944): backfill changeset PR number 2950 --------- Co-authored-by: sim --- .changeset/kind-koalas-howl.md | 5 + ...671-dynamic-context-management-platform.md | 6 +- .../CONTEXT-INDEX.json | 696 +++++++++--------- examples/dynamic-context-management/README.md | 14 +- .../context-predicates.cjs | 177 ++++- package.json | 2 +- scripts/lint-example-parser-parity.cjs | 395 ++++++++++ src/context-predicates.cts | 81 +- tests/context-index-sync.test.cjs | 153 ++++ tests/context-predicates.test.cjs | 52 ++ 10 files changed, 1174 insertions(+), 407 deletions(-) create mode 100644 .changeset/kind-koalas-howl.md create mode 100644 scripts/lint-example-parser-parity.cjs create mode 100644 tests/context-index-sync.test.cjs diff --git a/.changeset/kind-koalas-howl.md b/.changeset/kind-koalas-howl.md new file mode 100644 index 000000000..afd33e3af --- /dev/null +++ b/.changeset/kind-koalas-howl.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2950 +--- +**Malformed predicate declarations are now reported instead of silently dropped.** A doubled-dot id, a space in an id, a lowercase-leading class, and a value with an embedded CR/LF are each surfaced as a distinct `malformed` diagnostic reason instead of vanishing with no trace; the example parser (examples/dynamic-context-management/) was also brought back into parity with production and its own index is now drift-guarded by a new lint script. (#2944) diff --git a/docs/adr/1671-dynamic-context-management-platform.md b/docs/adr/1671-dynamic-context-management-platform.md index 34e919d90..4a31273eb 100644 --- a/docs/adr/1671-dynamic-context-management-platform.md +++ b/docs/adr/1671-dynamic-context-management-platform.md @@ -103,14 +103,14 @@ A working prototype proves the platform pattern end-to-end. It ships as a **refe - `examples/dynamic-context-management/context-predicates.cjs` — pure parser/selector: `parsePredicates(markdown)` (handles bare and list-item backtick predicate forms, splits on first `=`, skips fenced code / blockquote prose, detects duplicate IDs), `selectPredicates(predicates, {klass, prefix, contains})` (the JIT "task → predicate set" selector), and `buildIndex(predicates)` (deterministic, sorted). - `examples/dynamic-context-management/gen-context-index.cjs` — self-contained CLI with `--check`/`--write` drift-guard plus a `--select ` mode demonstrating JIT brief assembly. -- `examples/dynamic-context-management/CONTEXT-INDEX.json` — sample generated index: **416 predicates, 20 classes** (regenerated 2026-07-31). Originally committed as **393 predicates, 18 classes** (2026-06-24); `CONTEXT.md` has since gained the `PROBE` (11) and `PROHIB` (10) classes, with `DEFECT` 161→167 and `RULESET` 59→56. The committed artifact had gone stale (`--check` exited 1) and was regenerated with `--write`. +- `examples/dynamic-context-management/CONTEXT-INDEX.json` — sample generated index: **415 predicates, 20 classes** (verified 2026-07-31; down from 416 after #2928/PR #2938 reconciled the last duplicate predicate ID, `RULESET.WORKFLOW_MARKDOWN.FENCES`). Originally committed as **393 predicates, 18 classes** (2026-06-24); `CONTEXT.md` has since gained the `PROBE` (11) and `PROHIB` (10) classes, with `DEFECT` 161→167 and `RULESET` 59→56→55. The committed artifact had gone stale (`--check` exited 1) and was regenerated with `--write`. - `examples/dynamic-context-management/demo.cjs` + `README.md` — runnable usage example and notes. -During research the slice was validated with 42 behavioral tests (predicate forms, fenced-code / prose skipping, duplicate-id detection, the selector, a deterministic index, and a fast-check property test); those return as CI tests under `tests/` with the production implementation. +During research the slice was validated with 42 behavioral tests (predicate forms, fenced-code / prose skipping, duplicate-id detection, the selector, a deterministic index, and a fast-check property test); those landed as CI tests under `tests/` with the production implementation (#2928/PR #2938). The prototype immediately surfaced **3 latent duplicate predicate IDs** in `CONTEXT.md` (`RULESET.WORKFLOW_MARKDOWN.FENCES`, `RULESET.GEMINI.TOOLS.ask_user`, `RULESET.GEMINI.TEST_SENTINEL`) — integrity drift no existing tool catches. -**Re-checked 2026-07-31:** only **one** remains — `RULESET.WORKFLOW_MARKDOWN.FENCES`. The two `RULESET.GEMINI.*` duplicates were removed along with the Gemini runtime, not reconciled deliberately. Production `--check` can be made to fail on *new* duplicates once that single remaining ID is reconciled. +**Re-checked 2026-07-31:** the two `RULESET.GEMINI.*` duplicates were removed along with the Gemini runtime, not reconciled deliberately; the remaining `RULESET.WORKFLOW_MARKDOWN.FENCES` duplicate was reconciled deliberately in #2928/PR #2938, which also productionized `--check` into CI so it now fails closed on any *new* duplicate ID. **Phase 0 acceptance status (2026-07-31).** The epic's Phase 0 criterion — "`gen-context-index --check` green in CI" — was **unmet**: `--check` exited 1 against `next`, and no CI job failed, because the example sits deliberately outside `tests/` — the red was invisible to the pipeline. The index has now been regenerated and `--check` exits 0. diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index 2ef6b00e6..2e1cdd392 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "count": 416, + "count": 415, "classes": { "ARCH": 1, "CI": 2, @@ -17,7 +17,7 @@ "PROC": 14, "PROHIB": 10, "RELEASE-NOTES": 31, - "RULESET": 56, + "RULESET": 55, "SESSION": 9, "WAVE": 5, "WORKSTREAM": 5, @@ -28,553 +28,553 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 545 + "line": 544 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 529 + "line": 528 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 528 + "line": 527 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 559 + "line": 558 }, { "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\")", - "line": 761 + "line": 760 }, { "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", - "line": 762 + "line": 761 }, { "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", - "line": 760 + "line": 759 }, { "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", - "line": 759 + "line": 758 }, { "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", - "line": 967 + "line": 966 }, { "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)", - "line": 966 + "line": 965 }, { "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", - "line": 968 + "line": 967 }, { "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", - "line": 969 + "line": 968 }, { "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", - "line": 965 + "line": 964 }, { "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", - "line": 741 + "line": 740 }, { "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)", - "line": 740 + "line": 739 }, { "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", - "line": 742 + "line": 741 }, { "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", - "line": 739 + "line": 738 }, { "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", - "line": 922 + "line": 921 }, { "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", - "line": 921 + "line": 920 }, { "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", - "line": 923 + "line": 922 }, { "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", - "line": 920 + "line": 919 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect", "klass": "DEFECT", "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls", - "line": 766 + "line": 765 }, { "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", - "line": 765 + "line": 764 }, { "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", - "line": 767 + "line": 766 }, { "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", - "line": 764 + "line": 763 }, { "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", - "line": 801 + "line": 800 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples", "klass": "DEFECT", "value": "#3309 v2 default flip from mid-flight to end-of-phase", - "line": 800 + "line": 799 }, { "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 \"", - "line": 802 + "line": 801 }, { "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)", - "line": 799 + "line": 798 }, { "id": "DEFECT.FORMAT", "klass": "DEFECT", "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable", - "line": 716 + "line": 715 }, { "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", - "line": 814 + "line": 813 }, { "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)", - "line": 813 + "line": 812 }, { "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)", - "line": 815 + "line": 814 }, { "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", - "line": 812 + "line": 811 }, { "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)", - "line": 810 + "line": 809 }, { "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", - "line": 809 + "line": 808 }, { "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", - "line": 808 + "line": 807 }, { "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", - "line": 958 + "line": 957 }, { "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)", - "line": 959 + "line": 958 }, { "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()", - "line": 957 + "line": 956 }, { "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", - "line": 956 + "line": 955 }, { "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)", - "line": 960 + "line": 959 }, { "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", - "line": 926 + "line": 925 }, { "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", - "line": 925 + "line": 924 }, { "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", - "line": 927 + "line": 926 }, { "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", - "line": 928 + "line": 927 }, { "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", - "line": 924 + "line": 923 }, { "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", - "line": 950 + "line": 949 }, { "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)", - "line": 952 + "line": 951 }, { "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", - "line": 951 + "line": 950 }, { "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", - "line": 949 + "line": 948 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream", "klass": "DEFECT", "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe", - "line": 953 + "line": 952 }, { "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", - "line": 791 + "line": 790 }, { "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)", - "line": 790 + "line": 789 }, { "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", - "line": 792 + "line": 791 }, { "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", - "line": 789 + "line": 788 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect", "klass": "DEFECT", "value": "hook re-fires on each invocation regardless of session-state read receipts", - "line": 796 + "line": 795 }, { "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", - "line": 795 + "line": 794 }, { "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", - "line": 797 + "line": 796 }, { "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", - "line": 955 + "line": 954 }, { "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", - "line": 794 + "line": 793 }, { "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", - "line": 974 + "line": 973 }, { "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", - "line": 756 + "line": 755 }, { "id": "DEFECT.INVENTORY-DRIFT.examples", "klass": "DEFECT", "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)", - "line": 755 + "line": 754 }, { "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)", - "line": 757 + "line": 756 }, { "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", - "line": 754 + "line": 753 }, { "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", - "line": 897 + "line": 896 }, { "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
\")", - "line": 896 + "line": 895 }, { "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", - "line": 898 + "line": 897 }, { "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", - "line": 895 + "line": 894 }, { "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", - "line": 786 + "line": 785 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples", "klass": "DEFECT", "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants", - "line": 785 + "line": 784 }, { "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", - "line": 787 + "line": 786 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom", "klass": "DEFECT", "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed", - "line": 784 + "line": 783 }, { "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)", - "line": 732 + "line": 731 }, { "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", - "line": 730 + "line": 729 }, { "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)", - "line": 729 + "line": 728 }, { "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", - "line": 731 + "line": 730 }, { "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", - "line": 728 + "line": 727 }, { "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", - "line": 849 + "line": 848 }, { "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", - "line": 848 + "line": 847 }, { "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)", - "line": 850 + "line": 849 }, { "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)", - "line": 851 + "line": 850 }, { "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", - "line": 847 + "line": 846 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect", "klass": "DEFECT", "value": "any new bare tag in agents/*.md", - "line": 751 + "line": 750 }, { "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)", - "line": 750 + "line": 749 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward", "klass": "DEFECT", "value": "hyphenate the tag (, ) — scanner regex matches bare names only", - "line": 752 + "line": 751 }, { "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", - "line": 749 + "line": 748 }, { "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", - "line": 720 + "line": 719 }, { "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", - "line": 719 + "line": 718 }, { "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", - "line": 721 + "line": 720 }, { "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)", - "line": 718 + "line": 717 }, { "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT", @@ -586,517 +586,517 @@ "id": "DEFECT.SCOPE.window", "klass": "DEFECT", "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287", - "line": 715 + "line": 714 }, { "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", - "line": 899 + "line": 898 }, { "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/)", - "line": 910 + "line": 909 }, { "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", - "line": 909 + "line": 908 }, { "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", - "line": 911 + "line": 910 }, { "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", - "line": 908 + "line": 907 }, { "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)", - "line": 912 + "line": 911 }, { "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", - "line": 805 + "line": 804 }, { "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", - "line": 806 + "line": 805 }, { "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", - "line": 804 + "line": 803 }, { "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", - "line": 736 + "line": 735 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples", "klass": "DEFECT", "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged", - "line": 735 + "line": 734 }, { "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)", - "line": 737 + "line": 736 }, { "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", - "line": 734 + "line": 733 }, { "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", - "line": 918 + "line": 917 }, { "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\"", - "line": 916 + "line": 915 }, { "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", - "line": 915 + "line": 914 }, { "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\")", - "line": 917 + "line": 916 }, { "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", - "line": 914 + "line": 913 }, { "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", - "line": 725 + "line": 724 }, { "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)", - "line": 724 + "line": 723 }, { "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)", - "line": 726 + "line": 725 }, { "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", - "line": 723 + "line": 722 }, { "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)", - "line": 964 + "line": 963 }, { "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)", - "line": 962 + "line": 961 }, { "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", - "line": 963 + "line": 962 }, { "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", - "line": 961 + "line": 960 }, { "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", - "line": 746 + "line": 745 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples", "klass": "DEFECT", "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)", - "line": 745 + "line": 744 }, { "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", - "line": 747 + "line": 746 }, { "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", - "line": 744 + "line": 743 }, { "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", - "line": 818 + "line": 817 }, { "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", - "line": 817 + "line": 816 }, { "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", - "line": 819 + "line": 818 }, { "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", - "line": 816 + "line": 815 }, { "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", - "line": 781 + "line": 780 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples", "klass": "DEFECT", "value": "a33cbe72 worktree fix bound git subprocesses with timeout", - "line": 780 + "line": 779 }, { "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", - "line": 782 + "line": 781 }, { "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", - "line": 779 + "line": 778 }, { "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", - "line": 903 + "line": 902 }, { "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", - "line": 902 + "line": 901 }, { "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", - "line": 904 + "line": 903 }, { "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)", - "line": 906 + "line": 905 }, { "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)", - "line": 901 + "line": 900 }, { "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", - "line": 905 + "line": 904 }, { "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", - "line": 776 + "line": 775 }, { "id": "DEFECT.WINDOWS-FS-OPS.examples", "klass": "DEFECT", "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim", - "line": 775 + "line": 774 }, { "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", - "line": 777 + "line": 776 }, { "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", - "line": 774 + "line": 773 }, { "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)", - "line": 835 + "line": 834 }, { "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", - "line": 834 + "line": 833 }, { "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", - "line": 836 + "line": 835 }, { "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,'/'))", - "line": 837 + "line": 836 }, { "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", - "line": 833 + "line": 832 }, { "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)", - "line": 843 + "line": 842 }, { "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", - "line": 842 + "line": 841 }, { "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.", - "line": 844 + "line": 843 }, { "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", - "line": 845 + "line": 844 }, { "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", - "line": 841 + "line": 840 }, { "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)", - "line": 829 + "line": 828 }, { "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", - "line": 828 + "line": 827 }, { "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", - "line": 830 + "line": 829 }, { "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", - "line": 831 + "line": 830 }, { "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", - "line": 827 + "line": 826 }, { "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", - "line": 823 + "line": 822 }, { "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", - "line": 822 + "line": 821 }, { "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)", - "line": 824 + "line": 823 }, { "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", - "line": 825 + "line": 824 }, { "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", - "line": 821 + "line": 820 }, { "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", - "line": 855 + "line": 854 }, { "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", - "line": 854 + "line": 853 }, { "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", - "line": 856 + "line": 855 }, { "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", - "line": 857 + "line": 856 }, { "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", - "line": 853 + "line": 852 }, { "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", - "line": 771 + "line": 770 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples", "klass": "DEFECT", "value": "this session, branch fix/3309-... and pr-3316", - "line": 770 + "line": 769 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:", - "line": 772 + "line": 771 }, { "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", - "line": 769 + "line": 768 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 942 + "line": 941 }, { "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", - "line": 944 + "line": 943 }, { "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)", - "line": 940 + "line": 939 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 945 + "line": 944 }, { "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)", - "line": 947 + "line": 946 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 946 + "line": 945 }, { "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", - "line": 943 + "line": 942 }, { "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)", - "line": 941 + "line": 940 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", @@ -1144,463 +1144,463 @@ "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", - "line": 610 + "line": 609 }, { "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", - "line": 611 + "line": 610 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 608 + "line": 607 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 609 + "line": 608 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 554 + "line": 553 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 555 + "line": 554 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 556 + "line": 555 }, { "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]", - "line": 533 + "line": 532 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 532 + "line": 531 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 614 + "line": 613 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 620 + "line": 619 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 621 + "line": 620 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 616 + "line": 615 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 623 + "line": 622 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 619 + "line": 618 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 622 + "line": 621 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 615 + "line": 614 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 613 + "line": 612 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 617 + "line": 616 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 618 + "line": 617 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 629 + "line": 628 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 627 + "line": 626 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 628 + "line": 627 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 626 + "line": 625 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 625 + "line": 624 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 634 + "line": 633 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 635 + "line": 634 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 632 + "line": 631 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 637 + "line": 636 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 636 + "line": 635 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 633 + "line": 632 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 631 + "line": 630 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 642 + "line": 641 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 641 + "line": 640 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 644 + "line": 643 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 643 + "line": 642 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 640 + "line": 639 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 639 + "line": 638 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 648 + "line": 647 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 650 + "line": 649 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 647 + "line": 646 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 649 + "line": 648 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 646 + "line": 645 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 655 + "line": 654 }, { "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/", - "line": 654 + "line": 653 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 656 + "line": 655 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 653 + "line": 652 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 652 + "line": 651 }, { "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?\"", - "line": 660 + "line": 659 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 661 + "line": 660 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 659 + "line": 658 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 658 + "line": 657 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 664 + "line": 663 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 667 + "line": 666 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 668 + "line": 667 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 666 + "line": 665 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 665 + "line": 664 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 663 + "line": 662 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 673 + "line": 672 }, { "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)", - "line": 671 + "line": 670 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 672 + "line": 671 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 670 + "line": 669 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 679 + "line": 678 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 676 + "line": 675 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 677 + "line": 676 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 678 + "line": 677 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 680 + "line": 679 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 675 + "line": 674 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 684 + "line": 683 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 683 + "line": 682 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 682 + "line": 681 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 689 + "line": 688 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 691 + "line": 690 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 688 + "line": 687 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 690 + "line": 689 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 687 + "line": 686 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 686 + "line": 685 }, { "id": "PROBE.ci.surface", @@ -1672,85 +1672,85 @@ "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 695 + "line": 694 }, { "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", "klass": "PROC", "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners", - "line": 694 + "line": 693 }, { "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]", - "line": 693 + "line": 692 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 699 + "line": 698 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 700 + "line": 699 }, { "id": "PROC.MERGE-WAVE.merge-tool-warning", "klass": "PROC", "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted", - "line": 701 + "line": 700 }, { "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]", - "line": 697 + "line": 696 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 698 + "line": 697 }, { "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", - "line": 972 + "line": 971 }, { "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", - "line": 970 + "line": 969 }, { "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", - "line": 971 + "line": 970 }, { "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", - "line": 977 + "line": 976 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 978 + "line": 977 }, { "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", - "line": 976 + "line": 975 }, { "id": "PROHIB.canon-referral", @@ -1816,187 +1816,187 @@ "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", - "line": 590 + "line": 589 }, { "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", - "line": 591 + "line": 590 }, { "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", - "line": 592 + "line": 591 }, { "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", - "line": 566 + "line": 565 }, { "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", - "line": 594 + "line": 593 }, { "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", - "line": 596 + "line": 595 }, { "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", - "line": 595 + "line": 594 }, { "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", - "line": 567 + "line": 566 }, { "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", - "line": 569 + "line": 568 }, { "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", - "line": 568 + "line": 567 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 601 + "line": 600 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 602 + "line": 601 }, { "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)", - "line": 565 + "line": 564 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 581 + "line": 580 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 580 + "line": 579 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 582 + "line": 581 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 583 + "line": 582 }, { "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.", - "line": 573 + "line": 572 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 577 + "line": 576 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 575 + "line": 574 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 576 + "line": 575 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 572 + "line": 571 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 578 + "line": 577 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 574 + "line": 573 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 571 + "line": 570 }, { "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**: ", - "line": 598 + "line": 597 }, { "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**: ", - "line": 599 + "line": 598 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 585 + "line": 584 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 588 + "line": 587 }, { "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", - "line": 587 + "line": 586 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 586 + "line": 585 }, { "id": "RULESET.ADR-HEADER", @@ -2007,7 +2007,7 @@ { "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", + "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 ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) 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", "line": 488 }, { @@ -2092,7 +2092,7 @@ "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)", - "line": 839 + "line": 838 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", @@ -2127,7 +2127,7 @@ { "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`", + "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 a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), 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. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other, and a fragment left on `next` after merge is inert rather than a shared cell; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins. `tests/emitted-drift-ack.json` (the LEGACY file specifically, NOT the fragment directory) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; a persisting FRAGMENT is harmless by construction and is deliberately not what this guard checks. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next` (`assertAbsentOnNext`), which fails on the LEGACY file's PRESENCE alone, valid or not — a PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", "line": 489 }, { @@ -2140,7 +2140,7 @@ "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", - "line": 930 + "line": 929 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", @@ -2152,13 +2152,13 @@ "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.", - "line": 932 + "line": 931 }, { "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\"", - "line": 934 + "line": 933 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", @@ -2322,212 +2322,198 @@ "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", "line": 486 }, - { - "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", - "klass": "RULESET", - "value": "when editing shell snippets inside workflow markdown, preserve the opening language fence; malformed fence can create fresh CodeRabbit threads", - "line": 527 - }, { "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", + "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 an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + 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", "line": 487 }, { "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_*]", - "line": 885 + "line": 884 }, { "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", - "line": 886 + "line": 885 }, { "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]", - "line": 887 + "line": 886 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 888 + "line": 887 }, { "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]", - "line": 889 + "line": 888 }, { "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]", - "line": 890 + "line": 889 }, { "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]", - "line": 891 + "line": 890 }, { "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]", - "line": 892 + "line": 891 }, { "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]", - "line": 893 + "line": 892 }, { "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", - "line": 708 + "line": 707 }, { "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", - "line": 705 + "line": 704 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 706 + "line": 705 }, { "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", - "line": 709 + "line": 708 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 707 + "line": 706 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 541 + "line": 540 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 542 + "line": 541 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 557 + "line": 556 }, { "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", - "line": 558 + "line": 557 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 543 + "line": 542 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 551 + "line": 550 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 535 + "line": 534 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 539 + "line": 538 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 538 + "line": 537 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 536 + "line": 535 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 537 + "line": 536 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 549 + "line": 548 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 550 + "line": 549 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 553 + "line": 552 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs", - "line": 552 + "line": 551 }, { "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]", - "line": 548 + "line": 547 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 547 + "line": 546 } ], - "duplicates": [ - { - "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", - "lines": [ - 486, - 527 - ] - } - ] + "duplicates": [] } diff --git a/examples/dynamic-context-management/README.md b/examples/dynamic-context-management/README.md index 9f759fba7..c4c8241d5 100644 --- a/examples/dynamic-context-management/README.md +++ b/examples/dynamic-context-management/README.md @@ -21,7 +21,7 @@ hand-citing a 200 KB file. - `context-predicates.cjs` — parser + selector + deterministic index builder (self-contained). - `gen-context-index.cjs` — `--check` / `--write` drift-guarded generator + `--select`. -- `CONTEXT-INDEX.json` — sample generated output (393 predicates, 18 classes). +- `CONTEXT-INDEX.json` — sample generated output (415 predicates, 20 classes). - `demo.cjs` — runnable usage example. ## Run (from the repo root) @@ -36,9 +36,13 @@ node examples/dynamic-context-management/gen-context-index.cjs --check During research this slice was validated with 42 behavioral tests — predicate forms, fenced-code / prose skipping, duplicate-id detection, the selector, a -deterministic index, and a fast-check property test. Those return as CI tests -under `tests/` when the production implementation lands. +deterministic index, and a fast-check property test. The production +implementation has since landed, with `tests/context-predicates.test.cjs` and +`tests/context-index-sync.test.cjs` as its behavioral tests under `tests/`, +and `scripts/lint-example-parser-parity.cjs` (wired into `npm run lint:ci`) +asserting this example and production agree. -It also surfaced 3 latent duplicate predicate IDs in `CONTEXT.md` +Research also surfaced 3 latent duplicate predicate IDs in `CONTEXT.md` (`RULESET.WORKFLOW_MARKDOWN.FENCES`, `RULESET.GEMINI.TOOLS.ask_user`, -`RULESET.GEMINI.TEST_SENTINEL`), recorded in the index `duplicates` field. +`RULESET.GEMINI.TEST_SENTINEL`) at the time; all three have since been +resolved and the current index carries 0 duplicate ids. diff --git a/examples/dynamic-context-management/context-predicates.cjs b/examples/dynamic-context-management/context-predicates.cjs index a3464f78d..736b0c531 100644 --- a/examples/dynamic-context-management/context-predicates.cjs +++ b/examples/dynamic-context-management/context-predicates.cjs @@ -17,6 +17,8 @@ * * ID grammar: CLASS(.subkey)* where CLASS = first dot-separated segment. * ID chars: [A-Za-z0-9._-] (CLASS always uppercase; subkeys may be mixed). + * A doubled dot (empty segment, e.g. `A..b`) is REJECTED — see the + * ID-validation comment below for why. * Split on FIRST '=' only; everything before is the ID, everything after is * the value (up to the closing backtick). * @@ -25,13 +27,105 @@ * - Prose lines (headings, blank lines, list items without a predicate) * - The "PR fix discipline" section (pure prose, no predicates) * - Session-log blockquote preamble + * + * `ParseResult.malformed` collects backtick lines that look like a predicate + * declaration attempt (contains a backtick-wrapped `id=value`-shaped inner + * with an `=` at index >= 1) but are rejected, with a distinct named `reason` + * per rejection class: `empty-value`, `empty-segment` (doubled dot), + * `invalid-id-chars` (disallowed characters, e.g. a space), + * `lowercase-leading-class` (id's first segment starts lowercase), and + * `value-contains-newline` (embedded CR/LF/U+2028/U+2029 in the value). A + * line with no `=` at all (ordinary inline code, e.g. `` `ID` ``) never + * produces a diagnostic. Mirrors production's src/context-predicates.cts. */ -// Regex matching the predicate ID grammar: one or more dot-separated segments. -// First segment must start with an uppercase letter (CLASS). -// Subsequent segments may start with letter/digit and include hyphens/underscores. -// We intentionally allow lowercase-starting sub-segments (e.g. PRED.k320.rule). -const ID_RE = /^([A-Z][A-Z0-9_-]*(?:\.[A-Za-z0-9_.-]+)*)=(.+)$/; +// 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). +// +// 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. +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), returning WHY it + * is invalid so malformed diagnostics can name the exact rejection class. + * + * @param {string} id - candidate id (everything before the first '=') + * @returns {{ valid: boolean, reason?: string }} + */ +function validateIdDetailed(id) { + const segments = id.split('.'); + + // A doubled dot (or leading/trailing dot) produces an empty segment. + if (segments.some((seg) => seg === '')) return { valid: false, reason: 'empty-segment' }; + + const first = segments[0]; + if (!ID_FIRST_SEGMENT_RE.test(first)) { + // Distinguish "starts lowercase" (a highly plausible typo, e.g. + // `foo.bar=1`) from any other first-segment character-set violation + // (e.g. a space, `FOO BAR=1`). + if (/^[a-z]/.test(first)) return { valid: false, reason: 'lowercase-leading-class' }; + return { valid: false, reason: 'invalid-id-chars' }; + } + for (let i = 1; i < segments.length; i++) { + if (!ID_SUBSEQUENT_SEGMENT_RE.test(segments[i])) return { valid: false, reason: 'invalid-id-chars' }; + } + return { valid: true }; +} + +/** + * Structurally validate a candidate predicate id (linear time — no ambiguous + * backtracking quantifier; see the ID grammar comment above). + * + * @param {string} id - candidate id (everything before the first '=') + * @returns {boolean} + */ +function isValidId(id) { + return validateIdDetailed(id).valid; +} + +/** + * Strip a source line down to its backtick-wrapped "inner" content, if any. + * Handles both line forms: + * 1. Bare backtick line: `ID=value` (starts with backtick at column 0) + * 2. List-item backtick: - `ID=value` (list-item with leading "- ") + * Also tolerates " - `ID=value`" (indented list item — observed in CONTEXT.md). + * + * @param {string} raw - the original source line (with newline stripped) + * @returns {string | null} + */ +function extractInner(raw) { + const line = raw.trimEnd(); + + if (line.startsWith('`') && line.endsWith('`') && line.length > 2) { + // bare backtick line + return line.slice(1, -1); + } + + // strip optional leading whitespace + "- " then check for backtick wrapping + const stripped = line.replace(/^\s*-\s+/, ''); + if (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, @@ -41,24 +135,7 @@ const ID_RE = /^([A-Z][A-Z0-9_-]*(?:\.[A-Za-z0-9_.-]+)*)=(.+)$/; * @returns {{ id: string, value: string } | null} */ function extractPredicate(raw) { - const line = raw.trimEnd(); - - // Form 1: `ID=value` (starts with backtick at column 0) - // Form 2: - `ID=value` (list-item with leading "- ") - // Also tolerate " - `ID=value`" (indented list item — observed in CONTEXT.md). - let inner = null; - - if (line.startsWith('`') && line.endsWith('`') && line.length > 2) { - // bare backtick line - inner = line.slice(1, -1); - } else { - // strip optional leading whitespace + "- " then check for backtick wrapping - const stripped = line.replace(/^\s*-\s+/, ''); - if (stripped.startsWith('`') && stripped.endsWith('`') && stripped.length > 2) { - inner = stripped.slice(1, -1); - } - } - + const inner = extractInner(raw); if (inner === null) return null; // Now match the ID grammar. Split on FIRST '=' only. @@ -68,12 +145,52 @@ function extractPredicate(raw) { const id = inner.slice(0, eqIdx); const value = inner.slice(eqIdx + 1); - // Validate ID — must match the grammar (no spaces, correct char set). - if (!ID_RE.test(inner)) return null; + // Value must be non-empty and must contain no embedded ECMAScript + // LineTerminator character (LF, CR, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH + // SEPARATOR) — mirrors production's src/context-predicates.cts. 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 predicate declaration attempt but is rejected" + * malformed case for a line that {@link extractPredicate} already rejected — + * naming WHY. Only fires when the line is backtick-wrapped AND contains an + * `=` at index >= 1 — a plain inline-code line with no `=` at all (e.g. + * `` `ID` ``) is not a declaration attempt and never produces a diagnostic. + * Does not change any accept/reject decision — diagnostic only. Mirrors + * production's src/context-predicates.cts detectMalformed. + * + * @param {string} raw - the original source line (with newline stripped) + * @returns {{ text: string, reason: string } | null} + */ +function detectMalformed(raw) { + 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); + + const idCheck = validateIdDetailed(id); + if (!idCheck.valid) { + return { text: raw.trimEnd(), reason: idCheck.reason }; + } + if (value === '') { + return { text: raw.trimEnd(), reason: 'empty-value' }; + } + if (/[\n\r\u2028\u2029]/.test(value)) { + return { text: raw.trimEnd(), reason: 'value-contains-newline' }; + } + + return null; +} + /** * Parse all predicates from a CONTEXT.md markdown string. * @@ -81,12 +198,14 @@ function extractPredicate(raw) { * @returns {{ * predicates: Array<{ id: string, klass: string, value: string, line: number, section: string }>, * duplicates: Array<{ id: string, lines: number[] }>, + * malformed: Array<{ line: number, text: string, reason: string }>, * skippedSections: string[] * }} */ function parsePredicates(markdown) { const lines = markdown.split('\n'); const predicates = []; + const malformed = []; // Track id -> list of line numbers for duplicate detection const idLines = new Map(); // id -> number[] @@ -131,7 +250,11 @@ function parsePredicates(markdown) { // Attempt extraction. const pred = extractPredicate(raw); - if (!pred) continue; + 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({ @@ -164,7 +287,7 @@ function parsePredicates(markdown) { const activeSections = new Set(predicates.map((p) => p.section)); const skippedSections = allSections.filter((s) => !activeSections.has(s)); - return { predicates, duplicates, skippedSections }; + return { predicates, duplicates, malformed, skippedSections }; } /** diff --git a/package.json b/package.json index 8d6384a8d..373ef14d6 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/", "lint:fix": "eslint . --fix", "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/lint-example-parser-parity.cjs b/scripts/lint-example-parser-parity.cjs new file mode 100644 index 000000000..f9b2dfc0a --- /dev/null +++ b/scripts/lint-example-parser-parity.cjs @@ -0,0 +1,395 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-example-parser-parity.cjs — asserts examples/dynamic-context-management/ + * context-predicates.cjs (reference prototype, ADR-1671) and production's + * src/context-predicates.cts (compiled to gsd-core/bin/lib/context-predicates.cjs) + * agree on parsing behavior, and that the example's own committed + * CONTEXT-INDEX.json has not silently drifted from a fresh parse. + * + * Lives OUTSIDE tests/ deliberately. ADR-1671 + * (docs/adr/1671-dynamic-context-management-platform.md:102) places + * examples/dynamic-context-management/ outside FOUR surfaces: the build + * (src/ -> bin/lib/), the npm package files[], the installer, and the CI test + * suite (tests/). A lint script wired into `npm run lint:ci` is none of + * those four — it does not compile, package, install, or test-suite-import + * the example; it only READS both modules from a repo-root script and + * asserts they agree, the same shape as every other scripts/lint-*.cjs in + * this repo that reads source it does not own (see e.g. + * lint-compiled-artifact-sync.cjs). + * + * Two parity surfaces: + * 1. production vs. example, parsed against the real repo-root CONTEXT.md: + * predicate count, class map, duplicate-id set, the full set of + * (id, value) pairs, and a representative accept/reject id-shape table + * (including the embedded-CR-in-value case production already rejected + * and the example silently accepted before this fix). + * 2. the example's OWN committed CONTEXT-INDEX.json vs. a fresh parse by + * the example's own parser of the real CONTEXT.md — LINE-NUMBER- + * INDEPENDENT (see below). This guards the same #2944 merge-race + * staleness class this PR fixed for docs/CONTEXT-INDEX.json, but for the + * example's own copy, which nothing previously guarded at all. + * + * Why `line` is excluded from surface 2 (ADR-1671 open question 4): the + * example's CONTEXT-INDEX.json bakes source line numbers into every + * predicate entry. Any unrelated CONTEXT.md line-shift (e.g. inserting a + * sentence above the predicates) re-drifts that artifact even though every + * predicate id/value/class is byte-identical. Wiring the example's own + * `gen-context-index.cjs --check` into lint:ci — which DOES compare line + * numbers — would make CI routinely red for reasons unrelated to predicate + * integrity. This lint instead derives the LINE-INDEPENDENT facts from the + * committed index (count, class map, duplicate-id set, (id,value) pairs) and + * compares those to a fresh parse: genuine content drift (the #2944 + * merge-race defect class) is still caught; a pure line-shift is not. + * + * `--context-path` / `--example-index-path` override the two hardcoded paths + * (mirrors scripts/gen-context-index.cjs) so this script's non-vacuousness + * can be proven against a throwaway temp fixture without ever touching the + * real committed artifacts. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const PROD_PREDICATES_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'context-predicates.cjs'); +const EXAMPLE_DIR = path.join(ROOT, 'examples', 'dynamic-context-management'); +const EXAMPLE_PREDICATES_PATH = path.join(EXAMPLE_DIR, 'context-predicates.cjs'); +const DEFAULT_EXAMPLE_INDEX_PATH = path.join(EXAMPLE_DIR, 'CONTEXT-INDEX.json'); +const DEFAULT_CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md'); + +// ─── Loaders ────────────────────────────────────────────────────────────────── + +/** + * Load the compiled production predicates library. Throws a clean ExitError + * (never a bare MODULE_NOT_FOUND stack) naming the remedy when it is missing. + */ +function loadProdPredicates() { + try { + delete require.cache[require.resolve(PROD_PREDICATES_PATH)]; + return require(PROD_PREDICATES_PATH); + } catch (err) { + throw new ExitError( + 1, + `Cannot load ${path.relative(ROOT, PROD_PREDICATES_PATH)}: ${err && err.message}\n` + + 'Run:\n npm run build:lib\n', + ); + } +} + +/** Load the example's self-contained predicates module (no build step). */ +function loadExamplePredicates() { + delete require.cache[require.resolve(EXAMPLE_PREDICATES_PATH)]; + return require(EXAMPLE_PREDICATES_PATH); +} + +/** + * Read a CONTEXT.md-shaped markdown file. Throws a clean ExitError naming the + * path when it is missing or unreadable. + * + * @param {string} contextPath + * @returns {string} + */ +function readContextMarkdown(contextPath) { + try { + return fs.readFileSync(contextPath, 'utf8'); + } catch (err) { + throw new ExitError(1, `Cannot read ${path.relative(ROOT, contextPath)}: ${err && err.message}`); + } +} + +// ─── Diffing helpers ──────────────────────────────────────────────────────── + +/** + * Diff two predicate-shaped arrays by id, comparing ONLY `klass`/`value` + * (never `line`/`section` — see the module doc comment's "Why `line` is + * excluded"). Returns human-readable divergence strings naming the exact + * predicate id(s), so a failure reads as an actionable list, never "objects + * differ". + * + * @param {Array<{id:string,klass:string,value:string}>} leftPredicates + * @param {Array<{id:string,klass:string,value:string}>} rightPredicates + * @param {string} leftLabel + * @param {string} rightLabel + * @returns {string[]} + */ +function diffPredicatesById(leftPredicates, rightPredicates, leftLabel, rightLabel) { + const leftMap = new Map(leftPredicates.map((p) => [p.id, p])); + const rightMap = new Map(rightPredicates.map((p) => [p.id, p])); + const allIds = new Set([...leftMap.keys(), ...rightMap.keys()]); + const diffs = []; + for (const id of Array.from(allIds).sort()) { + const l = leftMap.get(id); + const r = rightMap.get(id); + if (l && !r) { + diffs.push(`${id}: present in ${leftLabel} but absent from ${rightLabel}`); + } else if (!l && r) { + diffs.push(`${id}: present in ${rightLabel} but absent from ${leftLabel}`); + } else if (l.value !== r.value) { + diffs.push( + `${id}: value diverged (${leftLabel}=${JSON.stringify(l.value)}, ${rightLabel}=${JSON.stringify(r.value)})`, + ); + } else if (l.klass !== r.klass) { + diffs.push( + `${id}: klass diverged (${leftLabel}=${JSON.stringify(l.klass)}, ${rightLabel}=${JSON.stringify(r.klass)})`, + ); + } + } + return diffs; +} + +/** Sorted, deduplicated id list from a `duplicates` array (either `{id,count}` or `{id,lines}` shape). */ +function duplicateIds(duplicates) { + return duplicates.map((d) => d.id).sort(); +} + +/** Build a single-line `` `id=value` `` markdown fixture wrapping one candidate id=value pair. */ +function backtickLine(idEqualsValue) { + return '`' + idEqualsValue + '`'; +} + +// Representative accept/reject id-shape table both parsers must agree on — +// this is what PROVES the parity claim rather than merely asserting it. +// FINDING 4 (ADR-1671 PR review): the embedded-CR-in-value case below was the +// one real divergence the earlier parity claim missed — production rejected +// it, the example silently accepted it. Both now reject it (ported fix). +const ID_SHAPE_CASES = [ + { idEqualsValue: 'A=1', expectAccept: true }, + { idEqualsValue: 'FOO=x', expectAccept: true }, + { idEqualsValue: 'PRED.k320.rule=x', expectAccept: true }, + { idEqualsValue: 'RELEASE-NOTES.x=y', expectAccept: true }, + { idEqualsValue: 'A..b=1', expectAccept: false }, + { idEqualsValue: 'foo.bar=x', expectAccept: false }, + { idEqualsValue: 'FOO BAR=x', expectAccept: false }, + { idEqualsValue: '=v', expectAccept: false }, + { idEqualsValue: 'ID', expectAccept: false }, +]; + +/** + * The embedded-CR case needs its own fixture, not the plain + * `` `ID=value` `` shape backtickLine() builds: a lone CR with no following + * LF never reaches a closing backtick at all (a separate, documented parser + * limit — see src/context-predicates.cts's module doc comment), so the CR + * must sit INSIDE an otherwise well-formed, LF-terminated backtick line. + */ +const CR_VALUE_MARKDOWN = '`ID=ab\rcd`\n'; + +// ─── Parity checks (surface 1: production vs. example) ──────────────────────── + +/** + * @param {object} prodPredicates + * @param {object} examplePredicates + * @param {string} markdown + * @returns {string[]} diagnostics; empty when the two parsers fully agree + */ +function checkProdExampleParity(prodPredicates, examplePredicates, markdown) { + const diagnostics = []; + + const prodParsed = prodPredicates.parsePredicates(markdown); + const exampleParsed = examplePredicates.parsePredicates(markdown); + const prodIndex = prodPredicates.buildIndex(prodParsed.predicates); + const exampleIndex = examplePredicates.buildIndex(exampleParsed.predicates); + + if (exampleIndex.count !== prodIndex.count) { + diagnostics.push(`predicate count diverged: example=${exampleIndex.count} production=${prodIndex.count}`); + } + + const prodClassKeys = Object.keys(prodIndex.classes).sort(); + const exampleClassKeys = Object.keys(exampleIndex.classes).sort(); + if (JSON.stringify(exampleClassKeys) !== JSON.stringify(prodClassKeys)) { + diagnostics.push( + `class set diverged: example=${JSON.stringify(exampleClassKeys)} production=${JSON.stringify(prodClassKeys)}`, + ); + } else if (JSON.stringify(exampleIndex.classes) !== JSON.stringify(prodIndex.classes)) { + diagnostics.push( + `per-class predicate counts diverged: example=${JSON.stringify(exampleIndex.classes)} ` + + `production=${JSON.stringify(prodIndex.classes)}`, + ); + } + + const prodDupIds = duplicateIds(prodIndex.duplicates); + const exampleDupIds = duplicateIds(exampleIndex.duplicates); + if (JSON.stringify(exampleDupIds) !== JSON.stringify(prodDupIds)) { + diagnostics.push( + `duplicate-id set diverged: example=${JSON.stringify(exampleDupIds)} production=${JSON.stringify(prodDupIds)}`, + ); + } + + diagnostics.push(...diffPredicatesById(exampleIndex.predicates, prodIndex.predicates, 'example', 'production')); + + for (const { idEqualsValue, expectAccept } of ID_SHAPE_CASES) { + const line = backtickLine(idEqualsValue); + const exampleCount = examplePredicates.parsePredicates(line).predicates.length; + const prodCount = prodPredicates.parsePredicates(line).predicates.length; + if (exampleCount !== prodCount) { + diagnostics.push( + `id-shape verdict diverged for ${JSON.stringify(idEqualsValue)}: ` + + `example found ${exampleCount} predicate(s), production found ${prodCount}`, + ); + continue; + } + const actualAccept = prodCount === 1; + if (actualAccept !== expectAccept) { + diagnostics.push( + `id-shape verdict wrong for ${JSON.stringify(idEqualsValue)}: expected ` + + `${expectAccept ? 'ACCEPT' : 'REJECT'}, both modules agreed on ${actualAccept ? 'ACCEPT' : 'REJECT'} instead`, + ); + } + } + + const exampleCrCount = examplePredicates.parsePredicates(CR_VALUE_MARKDOWN).predicates.length; + const prodCrCount = prodPredicates.parsePredicates(CR_VALUE_MARKDOWN).predicates.length; + if (exampleCrCount !== prodCrCount) { + diagnostics.push( + `id-shape verdict diverged for embedded-CR-in-value: example found ${exampleCrCount} predicate(s), ` + + `production found ${prodCrCount}`, + ); + } else if (prodCrCount !== 0) { + diagnostics.push( + `embedded-CR-in-value must be REJECTED by both parsers; both instead ACCEPTED it (${prodCrCount} predicate(s))`, + ); + } + + return diagnostics; +} + +// ─── Parity check (surface 2: example's committed index vs. fresh parse) ────── + +/** Strip a predicate down to the line-number-independent fields used for comparison. */ +function toLineIndependent(p) { + return { id: p.id, klass: p.klass, value: p.value }; +} + +/** + * @param {object} examplePredicates + * @param {string} markdown + * @param {string} exampleIndexPath + * @returns {string[]} diagnostics; empty when the committed index and a fresh + * parse agree on every LINE-NUMBER-INDEPENDENT fact + */ +function checkExampleIndexParity(examplePredicates, markdown, exampleIndexPath) { + const diagnostics = []; + + let committed; + try { + committed = JSON.parse(fs.readFileSync(exampleIndexPath, 'utf8')); + } catch (err) { + diagnostics.push(`cannot read/parse ${exampleIndexPath}: ${err && err.message}`); + return diagnostics; + } + if (!committed || !Array.isArray(committed.predicates)) { + diagnostics.push(`${exampleIndexPath} does not have the expected ContextIndex shape`); + return diagnostics; + } + + const { predicates } = examplePredicates.parsePredicates(markdown); + const fresh = examplePredicates.buildIndex(predicates); + + if (committed.count !== fresh.count) { + diagnostics.push(`example index predicate count diverged: committed=${committed.count} fresh=${fresh.count}`); + } + if (JSON.stringify(committed.classes) !== JSON.stringify(fresh.classes)) { + diagnostics.push( + `example index class-count map diverged: committed=${JSON.stringify(committed.classes)} ` + + `fresh=${JSON.stringify(fresh.classes)}`, + ); + } + + const committedDupIds = duplicateIds(committed.duplicates || []); + const freshDupIds = duplicateIds(fresh.duplicates); + if (JSON.stringify(committedDupIds) !== JSON.stringify(freshDupIds)) { + diagnostics.push( + `example index duplicate-id set diverged: committed=${JSON.stringify(committedDupIds)} ` + + `fresh=${JSON.stringify(freshDupIds)}`, + ); + } + + const committedIndependent = committed.predicates.map(toLineIndependent); + const freshIndependent = fresh.predicates.map(toLineIndependent); + const diffs = diffPredicatesById(committedIndependent, freshIndependent, 'committed example index', 'fresh parse'); + diagnostics.push(...diffs.map((d) => `example index: ${d}`)); + + return diagnostics; +} + +// ─── Argument parsing ───────────────────────────────────────────────────────── + +/** + * @param {string[]} argv - process.argv.slice(2) + * @returns {{ contextPath: string, exampleIndexPath: string }} + */ +function parseArgs(argv) { + const opts = { contextPath: DEFAULT_CONTEXT_PATH, exampleIndexPath: DEFAULT_EXAMPLE_INDEX_PATH }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--context-path') { + opts.contextPath = path.resolve(argv[++i] ?? ''); + } else if (arg === '--example-index-path') { + opts.exampleIndexPath = path.resolve(argv[++i] ?? ''); + } else { + throw new ExitError( + 1, + `Unknown argument: ${arg}\n` + + 'Usage: lint-example-parser-parity.cjs [--context-path ] [--example-index-path ]\n', + ); + } + } + return opts; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +function main() { + const opts = parseArgs(process.argv.slice(2)); + + const prodPredicates = loadProdPredicates(); + const examplePredicates = loadExamplePredicates(); + const markdown = readContextMarkdown(opts.contextPath); + + const diagnostics = [ + ...checkProdExampleParity(prodPredicates, examplePredicates, markdown), + ...checkExampleIndexParity(examplePredicates, markdown, opts.exampleIndexPath), + ]; + + if (diagnostics.length > 0) { + process.stderr.write( + `\nERROR lint-example-parser-parity: ${diagnostics.length} divergence(s) found\n\n`, + ); + for (const d of diagnostics) process.stderr.write(` - ${d}\n`); + process.stderr.write( + '\nexamples/dynamic-context-management/context-predicates.cjs and ' + + 'src/context-predicates.cts (gsd-core/bin/lib/context-predicates.cjs) must agree; ' + + 'the committed examples/dynamic-context-management/CONTEXT-INDEX.json must match a ' + + 'fresh parse of CONTEXT.md on every line-number-independent fact (run:\n' + + ' node examples/dynamic-context-management/gen-context-index.cjs --write\n' + + ').\n', + ); + throw new ExitError(1); + } + + process.stdout.write( + 'ok lint-example-parser-parity: production and example parsers agree on the real CONTEXT.md ' + + `(count/classes/duplicates/(id,value) pairs), on ${ID_SHAPE_CASES.length + 1} representative ` + + 'accept/reject id-shape cases, and the committed example index matches a fresh parse ' + + '(line-number-independent)\n', + ); +} + +module.exports = { + loadProdPredicates, + loadExamplePredicates, + readContextMarkdown, + diffPredicatesById, + checkProdExampleParity, + checkExampleIndexParity, + parseArgs, + ID_SHAPE_CASES, + CR_VALUE_MARKDOWN, +}; + +if (require.main === module) { + runMain(main); +} diff --git a/src/context-predicates.cts b/src/context-predicates.cts index 29ca4970f..f2f873e99 100644 --- a/src/context-predicates.cts +++ b/src/context-predicates.cts @@ -18,10 +18,17 @@ * `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. + * backtick lines that look like a predicate declaration attempt (contains a + * backtick-wrapped `id=value`-shaped inner with an `=` at index >= 1) but are + * rejected, with a distinct named `reason` per rejection class: `empty-value` + * (e.g. `` `ID=` ``), `empty-segment` (a doubled dot in the id, e.g. + * `` `A..b=1` ``), `invalid-id-chars` (disallowed characters in the id, e.g. a + * space: `` `FOO BAR=1` ``), `lowercase-leading-class` (id's first segment + * starts lowercase, e.g. `` `foo.bar=1` ``), and `value-contains-newline` (an + * embedded CR/LF/U+2028/U+2029 in the value). A line with no `=` at all (e.g. + * `` `ID` ``, ordinary inline code) is NOT a declaration attempt and never + * produces a diagnostic. This does not change any accept/reject outcome — + * only adds diagnostics. * * Grammar (from discovery facts): * Two line forms, each on exactly one source line: @@ -184,6 +191,39 @@ export interface ContextIndex { const ID_FIRST_SEGMENT_RE = /^[A-Z][A-Z0-9_-]*$/; const ID_SUBSEQUENT_SEGMENT_RE = /^[A-Za-z0-9_-]+$/; +/** Result of {@link validateIdDetailed}: valid, or invalid with a named reason. */ +interface IdValidation { + valid: boolean; + reason?: 'empty-segment' | 'invalid-id-chars' | 'lowercase-leading-class'; +} + +/** + * Structurally validate a candidate predicate id (linear time — no ambiguous + * backtracking quantifier; see the ID grammar comment above), returning WHY it + * is invalid so malformed diagnostics can name the exact rejection class. + * + * @param id - candidate id (everything before the first '=') + */ +function validateIdDetailed(id: string): IdValidation { + const segments = id.split('.'); + + // A doubled dot (or leading/trailing dot) produces an empty segment. + if (segments.some((seg) => seg === '')) return { valid: false, reason: 'empty-segment' }; + + const first = segments[0]; + if (!ID_FIRST_SEGMENT_RE.test(first)) { + // Distinguish "starts lowercase" (a highly plausible typo, e.g. + // `foo.bar=1`) from any other first-segment character-set violation + // (e.g. a space, `FOO BAR=1`). + if (/^[a-z]/.test(first)) return { valid: false, reason: 'lowercase-leading-class' }; + return { valid: false, reason: 'invalid-id-chars' }; + } + for (let i = 1; i < segments.length; i++) { + if (!ID_SUBSEQUENT_SEGMENT_RE.test(segments[i])) return { valid: false, reason: 'invalid-id-chars' }; + } + return { valid: true }; +} + /** * Structurally validate a candidate predicate id (linear time — no ambiguous * backtracking quantifier; see the ID grammar comment above). @@ -191,12 +231,7 @@ const ID_SUBSEQUENT_SEGMENT_RE = /^[A-Za-z0-9_-]+$/; * @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; + return validateIdDetailed(id).valid; } // List markers recognized ahead of a backtick-wrapped declaration: @@ -273,11 +308,18 @@ function extractPredicate(raw: string): { id: string; value: string } | null { } /** - * 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. + * Detect the "looks like a predicate declaration attempt but is rejected" + * malformed case for a line that {@link extractPredicate} already rejected — + * naming WHY, so a maintainer's typo is diagnosable instead of silently + * vanishing. Only fires when the line is backtick-wrapped (in either + * recognized form) AND contains an `=` at index >= 1 — a plain inline-code + * line with no `=` at all (e.g. `` `ID` ``) is not a declaration attempt and + * never produces a diagnostic. Does not change any accept/reject decision — + * diagnostic only. + * + * Reason precedence when a line fails more than one check at once: id + * validity is checked first (an invalid id makes the value irrelevant), then + * empty-value, then embedded-newline. * * @param raw - the original source line (with newline stripped) */ @@ -291,9 +333,16 @@ function detectMalformed(raw: string): { text: string; reason: string } | null { const id = inner.slice(0, eqIdx); const value = inner.slice(eqIdx + 1); - if (value === '' && isValidId(id)) { + const idCheck = validateIdDetailed(id); + if (!idCheck.valid) { + return { text: raw.trimEnd(), reason: idCheck.reason as string }; + } + if (value === '') { return { text: raw.trimEnd(), reason: 'empty-value' }; } + if (/[\n\r\u2028\u2029]/.test(value)) { + return { text: raw.trimEnd(), reason: 'value-contains-newline' }; + } return null; } diff --git a/tests/context-index-sync.test.cjs b/tests/context-index-sync.test.cjs new file mode 100644 index 000000000..bf8a48c8a --- /dev/null +++ b/tests/context-index-sync.test.cjs @@ -0,0 +1,153 @@ +'use strict'; + +/** + * Regression test for #2944 — 85140eac8 "refresh the predicate index staled + * by a merge race on next" (docs/CONTEXT-INDEX.json) shipped with zero + * behavioral test coverage. + * + * docs/CONTEXT-INDEX.json is a committed generated artifact guarded by + * `scripts/gen-context-index.cjs --check` in `lint:generated-sync`. A + * concurrent-merge race landed one PR's CONTEXT.md alongside another PR's + * index; each PR was green alone (lint only runs on the PR's own diff), the + * combination was red, and it only surfaced on the NEXT PR to run `lint:ci`. + * This file puts the same drift check inside the test suite (which + * `gsd-test` runs on every PR), so a future racing merge fails here directly + * instead of waiting for a downstream PR to trip over `lint:generated-sync`. + * + * The example/production parser parity guard (the OTHER defect #2944 + * introduced regression coverage for) lives in + * scripts/lint-example-parser-parity.cjs, wired into `npm run lint:ci` — NOT + * here. ADR-1671 ("Dynamic context management platform", + * docs/adr/1671-dynamic-context-management-platform.md:102) places + * examples/dynamic-context-management/ deliberately outside four surfaces: + * the build (src/ -> bin/lib/), the npm package files[], the installer, and + * the CI test suite (tests/) — this file. A `require()` of the example from + * inside tests/ would violate that fourth exclusion directly; a lint script + * that only reads both modules from a repo-root script does not. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const helpers = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const LIB_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib'); +const PROD_PREDICATES_PATH = path.join(LIB_DIR, 'context-predicates.cjs'); +const CONTEXT_PATH = path.join(REPO_ROOT, 'CONTEXT.md'); +const INDEX_PATH = path.join(REPO_ROOT, 'docs', 'CONTEXT-INDEX.json'); + +const prodPredicates = require(PROD_PREDICATES_PATH); + +function readContextMarkdown() { + return fs.readFileSync(CONTEXT_PATH, 'utf8'); +} + +function readCommittedIndex() { + return JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8')); +} + +/** + * Diff two ContextIndex-shaped `{predicates:[{id,klass,value}]}` objects by + * id, returning human-readable divergence strings naming the exact + * predicate id(s) involved — so a failure reads as an actionable list, never + * "objects differ". + */ +function diffPredicatesById(leftPredicates, rightPredicates, leftLabel, rightLabel) { + const leftMap = new Map(leftPredicates.map((p) => [p.id, p])); + const rightMap = new Map(rightPredicates.map((p) => [p.id, p])); + const allIds = new Set([...leftMap.keys(), ...rightMap.keys()]); + const diffs = []; + for (const id of Array.from(allIds).sort()) { + const l = leftMap.get(id); + const r = rightMap.get(id); + if (l && !r) { + diffs.push(`${id}: present in ${leftLabel} but absent from ${rightLabel}`); + } else if (!l && r) { + diffs.push(`${id}: present in ${rightLabel} but absent from ${leftLabel}`); + } else if (l.value !== r.value) { + diffs.push( + `${id}: value diverged (${leftLabel}=${JSON.stringify(l.value)}, ${rightLabel}=${JSON.stringify(r.value)})`, + ); + } else if (l.klass !== r.klass) { + diffs.push( + `${id}: klass diverged (${leftLabel}=${JSON.stringify(l.klass)}, ${rightLabel}=${JSON.stringify(r.klass)})`, + ); + } + } + return diffs; +} + +// ─── docs/CONTEXT-INDEX.json vs. a fresh CONTEXT.md parse ──────────────────── + +describe('docs/CONTEXT-INDEX.json sync with CONTEXT.md (#2944 concurrent-merge regression)', () => { + test('committed index matches a fresh parse of the real CONTEXT.md, predicate-by-predicate', () => { + const { parsePredicates, buildIndex } = prodPredicates; + const markdown = readContextMarkdown(); + const { predicates } = parsePredicates(markdown); + const fresh = buildIndex(predicates); + const committed = readCommittedIndex(); + + const diffs = diffPredicatesById( + committed.predicates, + fresh.predicates, + 'committed docs/CONTEXT-INDEX.json', + 'fresh CONTEXT.md parse', + ); + assert.deepEqual( + diffs, + [], + 'docs/CONTEXT-INDEX.json has drifted from CONTEXT.md for predicate id(s) ' + + '(run `node scripts/gen-context-index.cjs --write` to refresh):\n' + + diffs.join('\n'), + ); + assert.equal( + committed.count, + fresh.count, + `predicate count diverged: committed=${committed.count} fresh=${fresh.count}`, + ); + assert.deepEqual( + committed.classes, + fresh.classes, + 'class-count map diverged between committed index and fresh CONTEXT.md parse:\n' + + `committed=${JSON.stringify(committed.classes)}\nfresh=${JSON.stringify(fresh.classes)}`, + ); + }); + + test('divergence detector is not vacuous: catches a mutated index in a throwaway temp copy (never the real artifact)', (t) => { + const { parsePredicates, buildIndex } = prodPredicates; + const markdown = readContextMarkdown(); + const { predicates } = parsePredicates(markdown); + const fresh = buildIndex(predicates); + + // Mutate a COPY of the fresh index in a fresh temp dir. docs/CONTEXT- + // INDEX.json itself is never opened for write anywhere in this file. + const mutated = JSON.parse(JSON.stringify(fresh)); + const target = + mutated.predicates.find((p) => p.id === 'RULESET.WORKFLOW_SIZE_BUDGET') || mutated.predicates[0]; + target.value = target.value + ' MUTATED-FOR-TEST'; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'context-index-sync-')); + t.after(() => helpers.cleanup(tmpDir)); + const tmpIndexPath = path.join(tmpDir, 'CONTEXT-INDEX.json'); + fs.writeFileSync(tmpIndexPath, JSON.stringify(mutated, null, 2) + '\n', 'utf8'); + const mutatedCommitted = JSON.parse(fs.readFileSync(tmpIndexPath, 'utf8')); + + const diffs = diffPredicatesById( + mutatedCommitted.predicates, + fresh.predicates, + 'mutated temp-copy index', + 'fresh CONTEXT.md parse', + ); + + assert.ok(diffs.length > 0, 'expected the mutated temp copy to diverge from the fresh parse'); + assert.ok( + diffs.some((d) => d.startsWith(`${target.id}:`)), + `expected the diff to name ${target.id}; got:\n${diffs.join('\n')}`, + ); + }); +}); diff --git a/tests/context-predicates.test.cjs b/tests/context-predicates.test.cjs index 891cc5f55..e1fc388cc 100644 --- a/tests/context-predicates.test.cjs +++ b/tests/context-predicates.test.cjs @@ -320,6 +320,11 @@ describe('parsePredicates: ID/value grammar boundaries (C)', () => { test('rejectsInlineCodeWithoutEqualsSign', () => { const r = parsePredicates('`ID`'); assert.equal(r.predicates.length, 0); + assert.equal( + r.malformed.length, + 0, + 'ordinary inline code with no "=" at all is not a declaration attempt and must not be diagnosed', + ); }); test('reportsEmptyValueAsMalformedRatherThanDroppingSilently', () => { @@ -523,6 +528,53 @@ describe('parsePredicates: duplicate detection + validation (E)', () => { }); }); +// ─── E2. Malformed diagnostics — one distinct reason per rejection class ─── + +describe('parsePredicates: malformed diagnostics name the exact rejection reason (E2)', () => { + test('reportsEmptySegmentForDoubledDot', () => { + const r = parsePredicates('`A..b=1`'); + assert.equal(r.predicates.length, 0); + assert.equal(r.malformed.length, 1); + assert.equal(r.malformed[0].reason, 'empty-segment'); + }); + + test('reportsInvalidIdCharsForSpaceInId', () => { + const r = parsePredicates('`FOO BAR=1`'); + assert.equal(r.predicates.length, 0); + assert.equal(r.malformed.length, 1); + assert.equal(r.malformed[0].reason, 'invalid-id-chars'); + }); + + test('reportsLowercaseLeadingClassForLowercaseFirstSegment', () => { + const r = parsePredicates('`foo.bar=1`'); + assert.equal(r.predicates.length, 0); + assert.equal(r.malformed.length, 1); + assert.equal(r.malformed[0].reason, 'lowercase-leading-class'); + }); + + test('reportsValueContainsNewlineForEmbeddedCr', () => { + // A lone embedded CR inside an otherwise well-formed, LF-terminated + // backtick line (distinct from the documented lone-CR-only-line limit + // pinned by yieldsNoPredicatesForLoneCrDocumentAsDocumentedLimit above, + // which never reaches a closing backtick at all). + const md = '`ID=ab\rcd`\n'; + const r = parsePredicates(md); + assert.equal(r.predicates.length, 0, 'a value with an embedded CR must be rejected, not silently accepted'); + assert.equal(r.malformed.length, 1); + assert.equal(r.malformed[0].reason, 'value-contains-newline'); + }); + + test('idCheckTakesPrecedenceOverEmptyValueWhenBothFail', () => { + // `foo.bar=` fails BOTH the id (lowercase-leading) and the value (empty) + // checks — id validity is checked first per detectMalformed's documented + // precedence. + const r = parsePredicates('`foo.bar=`'); + assert.equal(r.predicates.length, 0); + assert.equal(r.malformed.length, 1); + assert.equal(r.malformed[0].reason, 'lowercase-leading-class'); + }); +}); + // ─── I. Independence + real-corpus regression ────────────────────────────── describe('parsePredicates: independence + real-corpus regression (I)', () => {