From c28134ab39b73c3aa38c913895d2bb6c7324ce78 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 9 Aug 2026 23:41:44 -0400 Subject: [PATCH] fix(#3271): delete 25 duplicated folded test suites and fix three runner defects found doing it (#3285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3271): guard against a folded suite appearing twice in one host Adds local/no-duplicate-fold-marker, an AST rule that reports the second and every subsequent `folded:` marker in a host file, plus RuleTester cases and a tree-wide regression assertion. Failing-first on purpose: the rule is registered at error and the 25 duplicated regions are still present, so eslint and the new tree-wide test are RED. The deletions land in the next commit. The marker key is the whitespace-delimited token after `folded:` — not the issue's `[a-z0-9-]*` slice, which truncates at `.` and false-positives on tests/model-resolver.test.cjs where feat-443-effort-fast-mode.integration and feat-443-effort-fast-mode are two distinct folded suites. Refs #3271 * fix(#3271): delete 25 duplicated folded suites from three install hosts Three consolidated install suites each carried a verbatim second copy of a contiguous run of #1969 B1 folded blocks. Byte-identical, constant offset, and green — each duplicated block registered and ran twice on every lane. tests/install.test.cjs 5981-9937 (3957 lines, 18 blocks) tests/install-minimal-hooks.test.cjs 2734-4015 (1282 lines, 5 blocks) tests/install-write-confinement.test.cjs 1754-2321 ( 568 lines, 2 blocks) Introduced by 6d072435d (#1975 re-applying #1970's hunks on a tree that already had them, 2026-07-03) — one stale-base re-application, three files, one commit. Verified by marker-count bisect: 1 at 4f779eda4 and 0cc7a1a42, 2 from 6d072435d onward. The later copy is deleted in each case, so every file returns to what its authoring batch produced and blame on the surviving lines stays accurate. local/no-duplicate-fold-marker, red on the previous commit, is now green. tests/model-resolver.test.cjs is untouched: the issue lists it, but its two blocks are folded from two different files and are not identical. It is a false positive of the issue's own grep, whose `[a-z0-9-]*` key truncates at `.`. Fixes #3271 * test(#3271): property-test marker identity and pin the alias non-goal Three review findings, all fixed inline: 1. foldMarkerOf is a parser and carried no fast-check property test. Raised independently by the /code-review standards axis and the isolated adversarial pass; the file already establishes the fc.property-driving-ruleTester idiom for a sibling rule. Added, two arms over markers generated from [a-z0-9-._]: the same marker twice always reports exactly once against firstLine 1, and two distinct markers never collide. The alphabet includes `.` on purpose — an implementation keyed on the issue's [a-z0-9-]* slice passes arm 1 and fails arm 2, which is exactly the model-resolver false positive. 2. meta.docs.category was the novel value 'Test hygiene'; all 16 sibling local rules use 'Best Practices', 'Portability' or 'Reliability'. Now 'Best Practices'. 3. A call through a further alias (const d = __foldDescribe) was unreported and undocumented — accidental rather than deliberate. It is now the fourth entry in the rule's documented non-goals, with the reason, and pinned by a valid RuleTester case so it cannot drift silently. Refs #3271 * test(#3271): name the step and elapsed time when a baseline build fails buildBaselineAtRef runs four bounded steps and, when one exceeded its bound, threw a bare "spawnSync ETIMEDOUT" naming neither the step nor how long anything took. Diagnosing one real failure took four separate experiments to recover information the throw already had. Each step is now timed, and any throw carries the breakdown: which step failed, its elapsed time, the timings of every step that completed before it, all three bounds, and the tail of the child's captured stdout/stderr. The failure message is deliberately the carrier. On the remote runner the captured output field comes back empty in failures.json while error and stack survive verbatim, so the message is the only channel that reaches a reader of a remote verdict. Refs #3271 * fix(#3271): size the baseline generator bound for the machine it runs on Instrumentation from a real remote-runner failure gave the breakdown: git-worktree-add=15.1s npm-run-build-lib=19.8s gen-emitted-baseline=FAILED@300.1s Steps 1 and 2 are comfortable. Only the generator exceeds its bound, and it is not hung — it needs more than 300s there. Measured ladder for that step: ~22s idle in a container, ~39s end-to-end in a clean container, ~142s with 8 CPU burners on 8 cores, and >300s under the real suite. Its cost is 19 sequential installer spawns, and spawn latency is exactly where a container degrades worst (3.9x slower than host, against 1.1x for file IO) — which is why a CPU-only load test did not reproduce it and why four earlier hypotheses (container slowness, network, shallow clone, CPU contention) all measured clean. The 300s bound was sized on an idle machine for a step that never runs on one. Under the remote runner the on-disk baseline cache is structurally absent — CI restores it via actions/cache keyed on github.event.pull_request.base.sha, a key that exists only inside GitHub Actions — so this slow path runs on every remote verification. The result: this gate has passed 0 times in 754 runs, failing 80 times and never once executing successfully. Raised to the 600000ms ceiling that local/no-unbounded-spawn treats as the largest meaningful bound; the other two bounds are untouched. This makes the gate RUN, which is the point: the alternative considered and rejected was degrading the timeout to a skip, and that was measured to turn the suite green with the gate silently not running at all. The real remedy is making the cache reachable from the remote runner so the in-job build returns to being the rare fallback ADR-2719 §5 describes. That is a gsd-test-runner change, not one this repo can make. Refs #3271 * fix(#3271): tolerate an overlay source that vanishes mid-walk Observed on the remote runner, three runs across three different branches: ENOENT: no such file or directory, link '/work/hooks/dist/gsd-config-reload.js' -> '/tmp/gsd-2930-overlay-6nOZay/hooks/dist/gsd-config-reload.js' buildOverlayRepo enumerates names with readdirSync and then acts on each one, so statSync, copyFileSync and linkSync all sit in a TOCTOU window. hooks/dist is regenerated by an ATOMIC REPLACE (scripts/build-hooks.js unlinks and renames), so any concurrently running test that rebuilds hooks retires a just-listed name mid-walk and the overlay dies on it. linkOrCopyFile already tolerated EXDEV and EPERM; ENOENT went straight through. On ENOENT the source is now re-examined ONCE rather than slept on. An atomic rename is a single syscall, so by the time the failure surfaces the successor is either already in place (the retry succeeds) or the path has genuinely left the tree, in which case there is nothing to mirror and the leaf is skipped. No sleep and no spin: a timing-based wait here would be the very flake being fixed. Every other errno still propagates untouched, so a real permission or IO fault stays a hard failure. Five tests hold the boundary: gone-for-good skips without retrying, mid-replace retries exactly once and places the file, EACCES still throws, a real linkSync ENOENT is injected by monkeypatching fs and restoring it in a finally (never a mode-bit trick, which root bypasses), and isMissingPath accepts only ENOENT. Refs #3271 * fix(#3271): order the timeout ladder inward-out and lock it Two review blockers, both real. The generator bound had been raised to 600000ms — exactly the whole-chunk timeout in scripts/run-tests.cjs:973. A step bound equal to the chunk ceiling loses the race: the chunk is killed first and the failure arrives as an opaque "no failed step" kill, so the per-step diagnostic added a commit earlier was built and then made unreachable in the same change. Separately the #2767 test declared a per-test timeout of 300000ms, BELOW the inner bound it was meant to permit, so it could still die at the exact 300s ceiling this was supposed to lift — via node:test's timeout rather than spawnSync's. Its sibling declared 900000ms, above the chunk ceiling, which is the same opaque-kill hazard from the other direction. The three bounds only produce a useful failure if they fire inward-out, so they now do: step 360s, per-test 480s, chunk 600s. 360s is ~3x the passing observation (91.6s / 115.8s) and 20% above the censored 300.1s timeout, while leaving 240s of chunk headroom for every other file sharing it. Four tests lock the ordering, including a drift guard on the exported values — without it, editing a call site's literal timeout would leave the ordering assertions passing while the real ladder inverted. Also from review: - err.gsdBaselineStep and err.gsdBaselineTimings were written and never read anywhere in the tree; only the rewritten message is consumed. Removed rather than kept as speculative surface. - buildOverlayRepo discarded placeVanishableLeaf's boolean at both call sites, so a vanished leaf left the overlay with no accounting at all. It now collects the skipped paths and warns once. Not thrown: a source that left the tree really is not part of the snapshot, and throwing would reintroduce the crash the tolerance removes — but silence would let a dropped leaf resurface later as an unrelated missing-file assertion. - The instrumentation commit shipped no test. One now drives a real failure and asserts the message names the step, its elapsed time, and the bounds. Refs #3271 * chore(#3271): backfill the changeset PR number * fix(#3271): bound a hook fan-out as its own class, not as a bare probe CI failure on PR #3285, job full test (windows-latest, 22, shard 2/3) — every other lane green, including windows-latest node 24 across all three shards: not ok 1 - blocks push when any to-be-pushed commit matches local blocked regex error: bash .githooks\pre-push failed — outcome=timed_out exitCode=null stderr= duration_ms: 15040.2168 A bound, not a hang: the test supplies stdin via input:, so the hook is not blocked reading its ref list, and the duration lands exactly on the 15000ms bound. The site used PROBE_TIMEOUT_MS, which tests/helpers/timeouts.cjs documents as "a single short CLI query or node -e probe against a temp fixture". This is not that. It spawns bash running .githooks/pre-push, and the hook then invokes a MOCK git that is itself a bash script, so one runHook is roughly four Git Bash spawns. On Windows each is Defender-scanned and the first hook test in a file pays cold start on top. That module's own docstring warns against precisely this: a call site that differs from its class must not be forced onto a shared value that does not describe it. HOOK_FANOUT_TIMEOUT_MS is that missing class — 60000ms, 4x the bound that failed and half INSTALL_TIMEOUT_MS, which is the right order: a hook fan-out is much lighter than a full installer run and far heavier than reading back a version string. Two tests lock the ordering against both neighbours, including one asserting real margin over the censored 15040ms observation, since a bound that merely matched what was measured would be the same defect again. Scoped deliberately: the other ~360 runHook sites keep their current bounds. This adds the norm and applies it where a real failure demonstrated the need, rather than sweeping a value across sites with no evidence for any of them. Refs #3271 --------- Co-authored-by: sim --- .changeset/jolly-seals-purr.md | 5 + CONTEXT.md | 2 + docs/CONTEXT-INDEX.json | 14 +- docs/TESTING-SUITES.md | 39 + eslint-rules/no-duplicate-fold-marker.cjs | 184 + eslint.config.mjs | 5 + .../CONTEXT-INDEX.json | 798 ++-- tests/emitted-attribution.test.cjs | 77 +- tests/eslint-rules.test.cjs | 375 +- ...t-single-edit-propagation.install.test.cjs | 110 +- tests/helpers/emitted-runtime.cjs | 90 +- tests/helpers/overlay-repo.cjs | 105 +- tests/helpers/timeouts.cjs | 25 + tests/install-minimal-hooks.test.cjs | 1282 ------ tests/install-write-confinement.test.cjs | 568 --- tests/install.test.cjs | 3957 ----------------- tests/prepush-enterprise-email-hook.test.cjs | 12 +- tests/process-seam.test.cjs | 29 + 18 files changed, 1435 insertions(+), 6242 deletions(-) create mode 100644 .changeset/jolly-seals-purr.md create mode 100644 eslint-rules/no-duplicate-fold-marker.cjs diff --git a/.changeset/jolly-seals-purr.md b/.changeset/jolly-seals-purr.md new file mode 100644 index 000000000..cc374f59a --- /dev/null +++ b/.changeset/jolly-seals-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3285 +--- +**Twenty-five folded test suites no longer run twice on every CI lane** — three consolidated install suites each carried a verbatim second copy of a contiguous run of folded regression blocks (~5,800 lines), left behind by a stale-base re-application during the test-consolidation epic. Every duplicated block registered and passed twice, so nothing reported it, and a contributor fixing one of those regressions could edit one copy and leave the other asserting the old behavior with the suite still green. The duplicates are deleted, and a new `local/no-duplicate-fold-marker` ESLint rule fails the build if a folded suite ever appears twice in one host file again. (#3271) diff --git a/CONTEXT.md b/CONTEXT.md index 2dd6962a7..f923f29d7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -511,6 +511,8 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `RULESET.TESTS.no-source-grep=local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)` `RULESET.TESTS.no-source-grep.exemption=// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.` `RULESET.TESTS.no-source-grep.tmp-file-traps=reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()` +`RULESET.TESTS.no-duplicate-fold-marker=local/no-duplicate-fold-marker ESLint AST rule (eslint-rules/no-duplicate-fold-marker.cjs, #3271) reports the 2nd and every later __foldDescribe("folded: ...") call carrying a marker already seen in the SAME file, naming the first occurrence's line; error in tests/**/*.cjs. The key is the WHITESPACE-delimited token after folded:, NOT a [a-z0-9-]* slice — a slice truncates at "." and collides feat-443-effort-fast-mode.integration with feat-443-effort-fast-mode (two distinct suites coexisting in tests/model-resolver.test.cjs), and NOT the whole title, so a re-fold under a different batch label ("B1 #1970" vs "B5 #1975") is still caught. Deliberately silent on: a __foldDescribe title with no folded: prefix (the alias is reused for one ordinary describe in tests/review-default-reviewers-workflow.test.cjs), a plain describe(), a non-literal title, and the same marker in two DIFFERENT files (the defect class is intra-file).` +`RULESET.TESTS.no-duplicate-fold-marker.why=consolidation epic #1969 folds are self-contained blocks, so a second verbatim copy parses, registers and PASSES twice — nothing reports it; #3271 found 25 such copies (~5,800 lines) in tests/install.test.cjs (18), tests/install-minimal-hooks.test.cjs (5) and tests/install-write-confinement.test.cjs (2), all from one stale-base re-application in 6d072435d (#1975 re-applying #1970's hunks, 2026-07-03). Ref DEFECT.GENERATIVE-FIX: the two copies drift apart silently when a contributor fixes one and leaves the other asserting the old behavior, with the suite still green.` `RULESET.TESTS.escape-regex=new RegExp("prefix${var}") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter` `RULESET.TESTS.no-dead-regex-in-includes=src.includes("foo.*bar") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete` diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index 9956a6ff9..94e9cbecd 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "count": 426, + "count": 428, "classes": { "ARCH": 1, "CI": 2, @@ -18,7 +18,7 @@ "PROC": 14, "PROHIB": 10, "RELEASE-NOTES": 31, - "RULESET": 55, + "RULESET": 57, "SESSION": 9, "WAVE": 5, "WORKSTREAM": 5, @@ -1940,6 +1940,16 @@ "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete" }, + { + "id": "RULESET.TESTS.no-duplicate-fold-marker", + "klass": "RULESET", + "value": "local/no-duplicate-fold-marker ESLint AST rule (eslint-rules/no-duplicate-fold-marker.cjs, #3271) reports the 2nd and every later __foldDescribe(\"folded: ...\") call carrying a marker already seen in the SAME file, naming the first occurrence's line; error in tests/**/*.cjs. The key is the WHITESPACE-delimited token after folded:, NOT a [a-z0-9-]* slice — a slice truncates at \".\" and collides feat-443-effort-fast-mode.integration with feat-443-effort-fast-mode (two distinct suites coexisting in tests/model-resolver.test.cjs), and NOT the whole title, so a re-fold under a different batch label (\"B1 #1970\" vs \"B5 #1975\") is still caught. Deliberately silent on: a __foldDescribe title with no folded: prefix (the alias is reused for one ordinary describe in tests/review-default-reviewers-workflow.test.cjs), a plain describe(), a non-literal title, and the same marker in two DIFFERENT files (the defect class is intra-file)." + }, + { + "id": "RULESET.TESTS.no-duplicate-fold-marker.why", + "klass": "RULESET", + "value": "consolidation epic #1969 folds are self-contained blocks, so a second verbatim copy parses, registers and PASSES twice — nothing reports it; #3271 found 25 such copies (~5,800 lines) in tests/install.test.cjs (18), tests/install-minimal-hooks.test.cjs (5) and tests/install-write-confinement.test.cjs (2), all from one stale-base re-application in 6d072435d (#1975 re-applying #1970's hunks, 2026-07-03). Ref DEFECT.GENERATIVE-FIX: the two copies drift apart silently when a contributor fixes one and leaves the other asserting the old behavior, with the suite still green." + }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", diff --git a/docs/TESTING-SUITES.md b/docs/TESTING-SUITES.md index fa4e199cb..d4fce6d09 100644 --- a/docs/TESTING-SUITES.md +++ b/docs/TESTING-SUITES.md @@ -55,6 +55,45 @@ identity ratchet (`npm run lint:regression-names`, part of `npm run lint:ci`): `docs/INVENTORY.md`) must be regenerated **after** rebasing, never carried through a rebase. +### A folded suite may appear only once per host + +When a standalone file is folded into its owning module's test file, the moved +suite is wrapped in a self-contained block carrying a marker: + +```javascript +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs — … +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-376-claude-js-hook-gsd-rewriter (…)", () => { … }); +} +``` + +Because the block is self-contained, a **second verbatim copy in the same host +parses, registers, and passes — twice.** Nothing in a green suite reports it. +[#3271](https://github.com/open-gsd/gsd-core/issues/3271) found 25 such copies +(~5,800 lines) across three install suites, all from a single stale-base +re-application during the consolidation epic. The cost is not only wasted CI on +every lane: it is a `DEFECT.GENERATIVE-FIX` trap, because a contributor fixing +one of those regressions edits the copy they found and leaves the other +asserting the old behavior, with the suite still green. + +`local/no-duplicate-fold-marker` (`eslint-rules/no-duplicate-fold-marker.cjs`, +error under `tests/**/*.cjs`) reports the second and every later occurrence of a +`folded:` title in one file, naming the line the first occurrence sits +on. **When it fires, delete the copy it points at** — the two blocks are the +same suite, so the fix is removal, never an `eslint-disable`. + +It keys on the whitespace-delimited token after `folded:`, which matters in both +directions. A narrower key that stops at `.` would collide +`feat-443-effort-fast-mode.integration` with `feat-443-effort-fast-mode` — two +genuinely distinct suites that coexist in `tests/model-resolver.test.cjs`. Keying +on the *whole* title instead would let a re-fold under a different batch label +slip through, which is exactly the shape #3271 took. Titles without a `folded:` +prefix, non-literal titles, and the same marker appearing in two *different* host +files are all left alone. + The ratchet deliberately covers only `bug-*`. Files named `feat-NNNN-*` / `enh-NNNN-*` are *feature* test files — one (or one per suite) per feature is the sanctioned layout (see the #443 strategy below), not a one-off regression diff --git a/eslint-rules/no-duplicate-fold-marker.cjs b/eslint-rules/no-duplicate-fold-marker.cjs new file mode 100644 index 000000000..69a79ed7a --- /dev/null +++ b/eslint-rules/no-duplicate-fold-marker.cjs @@ -0,0 +1,184 @@ +'use strict'; + +/** + * no-duplicate-fold-marker + * + * Flag a `folded:` marker that appears more than once in the same file. + * + * Consolidation epic #1969 moved standalone regression suites into shared host + * files. Each moved suite is wrapped in a self-contained block: + * + * // ────────────────────────────────────────────────────────────────────── + * // Folded from tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs — … + * // ────────────────────────────────────────────────────────────────────── + * { + * const { describe: __foldDescribe } = require('node:test'); + * __foldDescribe("folded:bug-376-claude-js-hook-gsd-rewriter (… B1 #1970)", () => { + * … + * }); + * } + * + * Because each block is self-contained, a second verbatim copy in the same host + * parses, registers, and PASSES — twice. Nothing reports it. #3271 found 25 such + * copies (~5,800 lines) across three suites, all introduced by one stale-base + * re-application in `6d072435d` (#1975 re-applying #1970's hunks). + * + * Two concrete costs, both invisible to a green suite: + * 1. Every duplicated block executes twice on every lane of the matrix. + * 2. DEFECT.GENERATIVE-FIX — a contributor fixing one of these regressions + * edits the copy they found and leaves the other asserting the old + * behavior. The suite stays green while the two copies disagree. + * + * ── What is keyed ───────────────────────────────────────────────────────────── + * + * The marker is the WHITESPACE-DELIMITED token after `folded:` in the title + * literal passed to the fold alias — NOT the whole title, and NOT a + * `[a-z0-9-]*` slice of it. + * + * "folded:bug-376-claude-js-hook-gsd-rewriter (consolidation epic #1969 B1 #1970)" + * → marker `bug-376-claude-js-hook-gsd-rewriter` + * + * Both halves of that definition are load-bearing: + * + * (a) Stopping at whitespace and NOT at `.` keeps + * `feat-443-effort-fast-mode.integration` distinct from + * `feat-443-effort-fast-mode`. Those are two different folded suites that + * coexist in tests/model-resolver.test.cjs; a `[a-z0-9-]*` key collides + * them and reports a duplicate that does not exist (#3271's own + * reproduction command has this bug). + * + * (b) Keying on the marker rather than the full title means a re-fold under a + * different batch label ("… B1 #1970" vs "… B5 #1975") is still caught. + * The batch label is provenance, not identity. + * + * ── What is deliberately NOT flagged ────────────────────────────────────────── + * + * - A `__foldDescribe` title without a `folded:` prefix. The alias is reused + * for at least one ordinary describe block + * (tests/review-default-reviewers-workflow.test.cjs). Those are not folds + * and carry no uniqueness obligation. + * - A plain `describe("folded:…")`. The convention this rule enforces is the + * fold alias; a bare `describe` with a colliding title is a different + * (and currently non-existent) shape. + * - A non-literal title (`__foldDescribe(name, …)`, template literal with a + * substitution). Nothing can be proven about it statically, so it is + * skipped rather than guessed at. + * - The SAME marker in two DIFFERENT files. The defect class is intra-file + * duplication — one host running one suite twice. Cross-file reuse would be + * a different question and is not decided here. + * - A call through an ALIAS of the alias (`const d = __foldDescribe; d("folded:a …")`). + * The rule keys on the callee identifier being literally `__foldDescribe`; + * resolving a further alias through scope would buy nothing today — every + * one of the 365 fold sites in the tree calls the alias directly, and zero + * rebind it — while adding a scope walk to a rule that currently needs none. + * If a rebinding ever appears, it is the rebinding that is the anomaly. + * + * The rule reports the SECOND and every subsequent occurrence, never the first, + * and names the line the first occurrence sits on — so the failure message + * points at both ends of the duplication. + * + * DEFECT category: DEFECT.GENERATIVE-FIX + */ + +/** The `describe` alias that consolidation epic #1969 folds are wrapped in. */ +const FOLD_ALIAS = '__foldDescribe'; + +/** Title prefix that marks a folded suite. */ +const FOLD_PREFIX = 'folded:'; + +/** + * Extract the fold marker from a describe title. + * + * Returns the whitespace-delimited token following `folded:`, or null when the + * title is not a fold title (no prefix) or carries an empty marker. + * + * @param {string | null} title + * @returns {string | null} + */ +function foldMarkerOf(title) { + if (typeof title !== 'string') return null; + if (!title.startsWith(FOLD_PREFIX)) return null; + const marker = title.slice(FOLD_PREFIX.length).split(/\s/, 1)[0]; + return marker.length > 0 ? marker : null; +} + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow the same consolidation-epic folded suite appearing twice in one host file', + category: 'Best Practices', + }, + schema: [], + messages: { + duplicateFoldMarker: + 'Folded suite "{{marker}}" is already present in this file at line {{firstLine}} ' + + '(DEFECT.GENERATIVE-FIX). A second copy runs the same tests twice on every lane and ' + + 'lets the two copies drift apart silently — a contributor fixing the regression edits ' + + 'one copy and leaves the other asserting the old behavior, with the suite still green. ' + + 'Delete this copy; keep the one at line {{firstLine}}.', + }, + }, + + create(context) { + /** + * marker → line of its first occurrence in this file. + * Rebuilt per file: `create` runs once per linted file. + * @type {Map} + */ + const firstSeen = new Map(); + + /** + * Returns the static string value of a title argument, or null when the + * title is not a plain string literal (identifier, template literal with a + * substitution, computed expression, …). + * + * A substituted template cannot be resolved statically, so it is skipped + * rather than guessed at. + * + * @param {import('eslint').Rule.Node | undefined} node + * @returns {string | null} + */ + function staticTitleOf(node) { + if (!node) return null; + if (node.type === 'Literal') { + return typeof node.value === 'string' ? node.value : null; + } + if (node.type === 'TemplateLiteral') { + // Only a substitution-free template has a knowable value. + if (node.expressions.length !== 0) return null; + if (node.quasis.length !== 1) return null; + return node.quasis[0].value.cooked ?? null; + } + return null; + } + + return { + CallExpression(node) { + // Only the fold alias — a bare `describe` is a different convention. + if (node.callee.type !== 'Identifier') return; + if (node.callee.name !== FOLD_ALIAS) return; + if (node.arguments.length === 0) return; + + const marker = foldMarkerOf(staticTitleOf(node.arguments[0])); + if (marker === null) return; + + const firstLine = firstSeen.get(marker); + if (firstLine === undefined) { + firstSeen.set(marker, node.loc.start.line); + return; + } + + context.report({ + node: node.arguments[0], + messageId: 'duplicateFoldMarker', + data: { marker, firstLine: String(firstLine) }, + }); + }, + }; + }, +}; + +module.exports = rule; diff --git a/eslint.config.mjs b/eslint.config.mjs index e9a035a43..f26dc7e96 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,7 @@ import requireUserprofileWithHome from './eslint-rules/require-userprofile-with- import normalizePathInContent from './eslint-rules/normalize-path-in-content.cjs'; import requireFsOpFallback from './eslint-rules/require-fs-op-fallback.cjs'; import noUnboundedSpawn from './eslint-rules/no-unbounded-spawn.cjs'; +import noDuplicateFoldMarker from './eslint-rules/no-duplicate-fold-marker.cjs'; const localPlugin = { rules: { @@ -44,6 +45,7 @@ const localPlugin = { 'normalize-path-in-content': normalizePathInContent, 'require-fs-op-fallback': requireFsOpFallback, 'no-unbounded-spawn': noUnboundedSpawn, + 'no-duplicate-fold-marker': noDuplicateFoldMarker, }, }; @@ -462,6 +464,9 @@ export default tseslint.config( // exemption surface. The only sanctioned escapes are an explicit `timeout` on // a raw spawn or the `// allow-spawn-timeout-ceiling: ` marker. 'local/no-unbounded-spawn': 'error', + // Ban a consolidation-epic folded suite appearing twice in one host file (#3271). + // A second copy runs the same tests twice on every lane and drifts silently. + 'local/no-duplicate-fold-marker': 'error', // Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax 'no-restricted-syntax': [ 'error', diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index b64b5063f..20ba3b4cc 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "count": 426, + "count": 428, "classes": { "ARCH": 1, "CI": 2, @@ -18,7 +18,7 @@ "PROC": 14, "PROHIB": 10, "RELEASE-NOTES": 31, - "RULESET": 55, + "RULESET": 57, "SESSION": 9, "WAVE": 5, "WORKSTREAM": 5, @@ -29,583 +29,583 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 576 + "line": 578 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 560 + "line": 562 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 559 + "line": 561 }, { "id": "CONFIG.LOCATION.SEAM.in-process-scrub", "klass": "CONFIG", "value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST", - "line": 594 + "line": 596 }, { "id": "CONFIG.LOCATION.SEAM.kimi-two-homes", "klass": "CONFIG", "value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second", - "line": 593 + "line": 595 }, { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", - "line": 591 + "line": 593 }, { "id": "CONFIG.LOCATION.SEAM.two-families", "klass": "CONFIG", "value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2", - "line": 592 + "line": 594 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 590 + "line": 592 }, { "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": 802 + "line": 804 }, { "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": 803 + "line": 805 }, { "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": 801 + "line": 803 }, { "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": 800 + "line": 802 }, { "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": 1010 + "line": 1012 }, { "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": 1009 + "line": 1011 }, { "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": 1011 + "line": 1013 }, { "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": 1012 + "line": 1014 }, { "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": 1008 + "line": 1010 }, { "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": 782 + "line": 784 }, { "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": 781 + "line": 783 }, { "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": 783 + "line": 785 }, { "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": 780 + "line": 782 }, { "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": 965 + "line": 967 }, { "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": 964 + "line": 966 }, { "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": 966 + "line": 968 }, { "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": 963 + "line": 965 }, { "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": 807 + "line": 809 }, { "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": 806 + "line": 808 }, { "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": 808 + "line": 810 }, { "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": 805 + "line": 807 }, { "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": 842 + "line": 844 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples", "klass": "DEFECT", "value": "#3309 v2 default flip from mid-flight to end-of-phase", - "line": 841 + "line": 843 }, { "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": 843 + "line": 845 }, { "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": 840 + "line": 842 }, { "id": "DEFECT.FORMAT", "klass": "DEFECT", "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable", - "line": 757 + "line": 759 }, { "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": 855 + "line": 857 }, { "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": 854 + "line": 856 }, { "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": 856 + "line": 858 }, { "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": 853 + "line": 855 }, { "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": 851 + "line": 853 }, { "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": 850 + "line": 852 }, { "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": 849 + "line": 851 }, { "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": 1001 + "line": 1003 }, { "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": 1002 + "line": 1004 }, { "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": 1000 + "line": 1002 }, { "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": 999 + "line": 1001 }, { "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": 1003 + "line": 1005 }, { "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": 969 + "line": 971 }, { "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": 968 + "line": 970 }, { "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": 970 + "line": 972 }, { "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": 971 + "line": 973 }, { "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": 967 + "line": 969 }, { "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": 993 + "line": 995 }, { "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": 995 + "line": 997 }, { "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": 994 + "line": 996 }, { "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": 992 + "line": 994 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream", "klass": "DEFECT", "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe", - "line": 996 + "line": 998 }, { "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": 832 + "line": 834 }, { "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": 831 + "line": 833 }, { "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": 833 + "line": 835 }, { "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": 830 + "line": 832 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect", "klass": "DEFECT", "value": "hook re-fires on each invocation regardless of session-state read receipts", - "line": 837 + "line": 839 }, { "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": 836 + "line": 838 }, { "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": 838 + "line": 840 }, { "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": 998 + "line": 1000 }, { "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": 835 + "line": 837 }, { "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": 1017 + "line": 1019 }, { "id": "DEFECT.HOST-RESERVED-DIR-NAME", "klass": "DEFECT", "value": "a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees", - "line": 900 + "line": 902 }, { "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": 797 + "line": 799 }, { "id": "DEFECT.INVENTORY-DRIFT.examples", "klass": "DEFECT", "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)", - "line": 796 + "line": 798 }, { "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 eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows//steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path", - "line": 798 + "line": 800 }, { "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": 795 + "line": 797 }, { "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": 940 + "line": 942 }, { "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": 939 + "line": 941 }, { "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": 941 + "line": 943 }, { "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": 938 + "line": 940 }, { "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": 827 + "line": 829 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples", "klass": "DEFECT", "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants", - "line": 826 + "line": 828 }, { "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": 828 + "line": 830 }, { "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": 825 + "line": 827 }, { "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": 773 + "line": 775 }, { "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": 771 + "line": 773 }, { "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": 770 + "line": 772 }, { "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": 772 + "line": 774 }, { "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": 769 + "line": 771 }, { "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": 890 + "line": 892 }, { "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": 889 + "line": 891 }, { "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": 891 + "line": 893 }, { "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": 892 + "line": 894 }, { "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": 888 + "line": 890 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect", "klass": "DEFECT", "value": "any new bare tag in agents/*.md", - "line": 792 + "line": 794 }, { "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": 791 + "line": 793 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward", "klass": "DEFECT", "value": "hyphenate the tag (, ) — scanner regex matches bare names only", - "line": 793 + "line": 795 }, { "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": 790 + "line": 792 }, { "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": 761 + "line": 763 }, { "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": 760 + "line": 762 }, { "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": 762 + "line": 764 }, { "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": 759 + "line": 761 }, { "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT", @@ -617,517 +617,517 @@ "id": "DEFECT.SCOPE.window", "klass": "DEFECT", "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287", - "line": 756 + "line": 758 }, { "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": 942 + "line": 944 }, { "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": 953 + "line": 955 }, { "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": 952 + "line": 954 }, { "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": 954 + "line": 956 }, { "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": 951 + "line": 953 }, { "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": 955 + "line": 957 }, { "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": 846 + "line": 848 }, { "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": 847 + "line": 849 }, { "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": 845 + "line": 847 }, { "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": 777 + "line": 779 }, { "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": 776 + "line": 778 }, { "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": 778 + "line": 780 }, { "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": 775 + "line": 777 }, { "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": 961 + "line": 963 }, { "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": 959 + "line": 961 }, { "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": 958 + "line": 960 }, { "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": 960 + "line": 962 }, { "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": 957 + "line": 959 }, { "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": 766 + "line": 768 }, { "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": 765 + "line": 767 }, { "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": 767 + "line": 769 }, { "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": 764 + "line": 766 }, { "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": 1007 + "line": 1009 }, { "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": 1005 + "line": 1007 }, { "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": 1006 + "line": 1008 }, { "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": 1004 + "line": 1006 }, { "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": 787 + "line": 789 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples", "klass": "DEFECT", "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)", - "line": 786 + "line": 788 }, { "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": 788 + "line": 790 }, { "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": 785 + "line": 787 }, { "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": 859 + "line": 861 }, { "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": 858 + "line": 860 }, { "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": 860 + "line": 862 }, { "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": 857 + "line": 859 }, { "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": 822 + "line": 824 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples", "klass": "DEFECT", "value": "a33cbe72 worktree fix bound git subprocesses with timeout", - "line": 821 + "line": 823 }, { "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": 823 + "line": 825 }, { "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": 820 + "line": 822 }, { "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": 946 + "line": 948 }, { "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": 945 + "line": 947 }, { "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": 947 + "line": 949 }, { "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": 949 + "line": 951 }, { "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": 944 + "line": 946 }, { "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": 948 + "line": 950 }, { "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": 817 + "line": 819 }, { "id": "DEFECT.WINDOWS-FS-OPS.examples", "klass": "DEFECT", "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim", - "line": 816 + "line": 818 }, { "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": 818 + "line": 820 }, { "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": 815 + "line": 817 }, { "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": 876 + "line": 878 }, { "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": 875 + "line": 877 }, { "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": 877 + "line": 879 }, { "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": 878 + "line": 880 }, { "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": 874 + "line": 876 }, { "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": 884 + "line": 886 }, { "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": 883 + "line": 885 }, { "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": 885 + "line": 887 }, { "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": 886 + "line": 888 }, { "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": 882 + "line": 884 }, { "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": 870 + "line": 872 }, { "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": 869 + "line": 871 }, { "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": 871 + "line": 873 }, { "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": 872 + "line": 874 }, { "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": 868 + "line": 870 }, { "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": 864 + "line": 866 }, { "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": 863 + "line": 865 }, { "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": 865 + "line": 867 }, { "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": 866 + "line": 868 }, { "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": 862 + "line": 864 }, { "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": 896 + "line": 898 }, { "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": 895 + "line": 897 }, { "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": 897 + "line": 899 }, { "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": 898 + "line": 900 }, { "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": 894 + "line": 896 }, { "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": 812 + "line": 814 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples", "klass": "DEFECT", "value": "this session, branch fix/3309-... and pr-3316", - "line": 811 + "line": 813 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:", - "line": 813 + "line": 815 }, { "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": 810 + "line": 812 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 985 + "line": 987 }, { "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": 987 + "line": 989 }, { "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": 983 + "line": 985 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 988 + "line": 990 }, { "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": 990 + "line": 992 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 989 + "line": 991 }, { "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": 986 + "line": 988 }, { "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": 984 + "line": 986 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", @@ -1169,505 +1169,505 @@ "id": "LEARNING.prompt-budget.boundary-gap", "klass": "LEARNING", "value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures", - "line": 507 + "line": 509 }, { "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", "klass": "LIVE-CONFIG", "value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes", - "line": 600 + "line": 602 }, { "id": "LIVE-CONFIG.GUARD.SEAM.module", "klass": "LIVE-CONFIG", "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite", - "line": 595 + "line": 597 }, { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates /hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", - "line": 597 + "line": 599 }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", "klass": "LIVE-CONFIG", "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", - "line": 596 + "line": 598 }, { "id": "LIVE-CONFIG.GUARD.SEAM.severity", "klass": "LIVE-CONFIG", "value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1", - "line": 599 + "line": 601 }, { "id": "LIVE-CONFIG.GUARD.SEAM.truncation", "klass": "LIVE-CONFIG", "value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing", - "line": 598 + "line": 600 }, { "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": 651 + "line": 653 }, { "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": 652 + "line": 654 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 649 + "line": 651 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 650 + "line": 652 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 585 + "line": 587 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 586 + "line": 588 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 587 + "line": 589 }, { "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": 564 + "line": 566 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 563 + "line": 565 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 655 + "line": 657 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 661 + "line": 663 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 662 + "line": 664 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 657 + "line": 659 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 664 + "line": 666 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 660 + "line": 662 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 663 + "line": 665 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 656 + "line": 658 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 654 + "line": 656 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 658 + "line": 660 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 659 + "line": 661 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 670 + "line": 672 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 668 + "line": 670 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 669 + "line": 671 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 667 + "line": 669 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 666 + "line": 668 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 675 + "line": 677 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 676 + "line": 678 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 673 + "line": 675 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 678 + "line": 680 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 677 + "line": 679 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 674 + "line": 676 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 672 + "line": 674 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 683 + "line": 685 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 682 + "line": 684 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 685 + "line": 687 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 684 + "line": 686 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 681 + "line": 683 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 680 + "line": 682 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 689 + "line": 691 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 691 + "line": 693 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 688 + "line": 690 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 690 + "line": 692 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 687 + "line": 689 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 696 + "line": 698 }, { "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": 695 + "line": 697 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 697 + "line": 699 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 694 + "line": 696 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 693 + "line": 695 }, { "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": 701 + "line": 703 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 702 + "line": 704 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 700 + "line": 702 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 699 + "line": 701 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 705 + "line": 707 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 708 + "line": 710 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 709 + "line": 711 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 707 + "line": 709 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 706 + "line": 708 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 704 + "line": 706 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 714 + "line": 716 }, { "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": 712 + "line": 714 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 713 + "line": 715 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 711 + "line": 713 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 720 + "line": 722 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 717 + "line": 719 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 718 + "line": 720 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 719 + "line": 721 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 721 + "line": 723 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 716 + "line": 718 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 725 + "line": 727 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 724 + "line": 726 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 723 + "line": 725 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 730 + "line": 732 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 732 + "line": 734 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 729 + "line": 731 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 731 + "line": 733 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 728 + "line": 730 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 727 + "line": 729 }, { "id": "PROBE.ci.surface", @@ -1739,85 +1739,85 @@ "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 736 + "line": 738 }, { "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": 735 + "line": 737 }, { "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": 734 + "line": 736 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 740 + "line": 742 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 741 + "line": 743 }, { "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": 742 + "line": 744 }, { "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": 738 + "line": 740 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 739 + "line": 741 }, { "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": 1015 + "line": 1017 }, { "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": 1013 + "line": 1015 }, { "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": 1014 + "line": 1016 }, { "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": 1020 + "line": 1022 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 1021 + "line": 1023 }, { "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": 1019 + "line": 1021 }, { "id": "PROHIB.canon-referral", @@ -1883,217 +1883,217 @@ "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": 631 + "line": 633 }, { "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": 632 + "line": 634 }, { "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": 633 + "line": 635 }, { "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": 607 + "line": 609 }, { "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": 635 + "line": 637 }, { "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": 637 + "line": 639 }, { "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": 636 + "line": 638 }, { "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": 608 + "line": 610 }, { "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": 610 + "line": 612 }, { "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": 609 + "line": 611 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 642 + "line": 644 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 643 + "line": 645 }, { "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": 606 + "line": 608 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 622 + "line": 624 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 621 + "line": 623 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 623 + "line": 625 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 624 + "line": 626 }, { "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": 614 + "line": 616 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 618 + "line": 620 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 616 + "line": 618 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 617 + "line": 619 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 613 + "line": 615 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 619 + "line": 621 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 615 + "line": 617 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 612 + "line": 614 }, { "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": 639 + "line": 641 }, { "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": 640 + "line": 642 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 626 + "line": 628 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 629 + "line": 631 }, { "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": 628 + "line": 630 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 627 + "line": 629 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 531 + "line": 533 }, { "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 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": 520 + "line": 522 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 527 + "line": 529 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 528 + "line": 530 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 516 + "line": 518 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", @@ -2123,205 +2123,217 @@ "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 553 + "line": 555 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 554 + "line": 556 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 552 + "line": 554 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 555 + "line": 557 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 556 + "line": 558 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 557 + "line": 559 }, { "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": 880 + "line": 882 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 546 + "line": 548 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 547 + "line": 549 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 545 + "line": 547 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 544 + "line": 546 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 538 + "line": 540 }, { "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 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": 521 + "line": 523 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 551 + "line": 553 }, { "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": 973 + "line": 975 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by // path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib", - "line": 532 + "line": 534 }, { "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": 975 + "line": 977 }, { "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": 977 + "line": 979 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 534 + "line": 536 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 529 + "line": 531 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 558 + "line": 560 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 503 + "line": 505 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 506 + "line": 508 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 505 + "line": 507 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 510 + "line": 512 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 501 + "line": 503 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 513 + "line": 515 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 502 + "line": 504 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 498 + "line": 500 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); of the three test-rigor rules, local/no-source-grep and local/no-magic-sleep-in-tests are already promoted to error in tests/**/*.test.cjs scope (post-cleanup), local/no-elapsed-assertion remains at warn pending open epic #1885 (its dedicated ratchet issue #453 already merged without completing this promotion; follow-up #1888 was closed not-planned and folded into #1885)", - "line": 514 + "line": 516 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 504 + "line": 506 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 500 + "line": 502 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 512 + "line": 514 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 499 + "line": 501 + }, + { + "id": "RULESET.TESTS.no-duplicate-fold-marker", + "klass": "RULESET", + "value": "local/no-duplicate-fold-marker ESLint AST rule (eslint-rules/no-duplicate-fold-marker.cjs, #3271) reports the 2nd and every later __foldDescribe(\"folded: ...\") call carrying a marker already seen in the SAME file, naming the first occurrence's line; error in tests/**/*.cjs. The key is the WHITESPACE-delimited token after folded:, NOT a [a-z0-9-]* slice — a slice truncates at \".\" and collides feat-443-effort-fast-mode.integration with feat-443-effort-fast-mode (two distinct suites coexisting in tests/model-resolver.test.cjs), and NOT the whole title, so a re-fold under a different batch label (\"B1 #1970\" vs \"B5 #1975\") is still caught. Deliberately silent on: a __foldDescribe title with no folded: prefix (the alias is reused for one ordinary describe in tests/review-default-reviewers-workflow.test.cjs), a plain describe(), a non-literal title, and the same marker in two DIFFERENT files (the defect class is intra-file).", + "line": 497 + }, + { + "id": "RULESET.TESTS.no-duplicate-fold-marker.why", + "klass": "RULESET", + "value": "consolidation epic #1969 folds are self-contained blocks, so a second verbatim copy parses, registers and PASSES twice — nothing reports it; #3271 found 25 such copies (~5,800 lines) in tests/install.test.cjs (18), tests/install-minimal-hooks.test.cjs (5) and tests/install-write-confinement.test.cjs (2), all from one stale-base re-application in 6d072435d (#1975 re-applying #1970's hunks, 2026-07-03). Ref DEFECT.GENERATIVE-FIX: the two copies drift apart silently when a contributor fixes one and leaves the other asserting the old behavior, with the suite still green.", + "line": 498 }, { "id": "RULESET.TESTS.no-source-grep", @@ -2345,241 +2357,241 @@ "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, currently warn (promotion to error tracked under open epic #1885, not #453 which already merged without completing it); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 509 + "line": 511 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 511 + "line": 513 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 536 + "line": 538 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 525 + "line": 527 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 524 + "line": 526 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 523 + "line": 525 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 522 + "line": 524 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 518 + "line": 520 }, { "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 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": 519 + "line": 521 }, { "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": 928 + "line": 930 }, { "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": 929 + "line": 931 }, { "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": 930 + "line": 932 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 931 + "line": 933 }, { "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": 932 + "line": 934 }, { "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": 933 + "line": 935 }, { "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": 934 + "line": 936 }, { "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": 935 + "line": 937 }, { "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": 936 + "line": 938 }, { "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": 749 + "line": 751 }, { "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": 746 + "line": 748 }, { "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": 747 + "line": 749 }, { "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": 750 + "line": 752 }, { "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": 748 + "line": 750 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 572 + "line": 574 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 573 + "line": 575 }, { "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": 588 + "line": 590 }, { "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": 589 + "line": 591 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 574 + "line": 576 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 582 + "line": 584 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 566 + "line": 568 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 570 + "line": 572 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 569 + "line": 571 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 567 + "line": 569 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 568 + "line": 570 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 580 + "line": 582 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 581 + "line": 583 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 584 + "line": 586 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs", - "line": 583 + "line": 585 }, { "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": 579 + "line": 581 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 578 + "line": 580 } ], "duplicates": [] diff --git a/tests/emitted-attribution.test.cjs b/tests/emitted-attribution.test.cjs index 5c66cd9b6..f352d1ae9 100644 --- a/tests/emitted-attribution.test.cjs +++ b/tests/emitted-attribution.test.cjs @@ -30,7 +30,7 @@ * 18 affected emitted paths. */ -const test = require('node:test'); +const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); @@ -66,6 +66,10 @@ const { reconcileFamilies, safeDirArgs, measuredPackageVersion, + WORKTREE_TIMEOUT_MS, + BUILD_LIB_TIMEOUT_MS, + BUILD_TIMEOUT_MS, + CHUNK_TIMEOUT_CEILING_MS, } = require('./helpers/emitted-runtime.cjs'); const { EXPECTED_MANIFEST_COUNT, loadManifests } = require('./helpers/emitted-provenance.cjs'); @@ -2345,7 +2349,7 @@ test('an unreadable baseline surfaces an error', () => { test( 'buildBaselineAtRef resolves a baseline via the in-job build even when the generator ' + 'script is absent at the ref (#2767 regression)', - { timeout: 300_000 }, + { timeout: 480_000 }, (t) => { // Mirrors "differential attribution over the real tree": install output is // platform-specific on Windows, and this drives the same heavy worktree + @@ -3005,7 +3009,7 @@ test('property: reported added/dropped are exactly the set differences', () => { // the working-tree fixtures, which would be whatever this PR's author regenerated; // comparing against those would be vacuous. -test('differential attribution over the real tree', { timeout: 900_000 }, async (t) => { +test('differential attribution over the real tree', { timeout: 480_000 }, async (t) => { if (process.platform === 'win32') { // Mirrors the golden harness: install output is platform-specific on Windows // (backslash paths), so parity is asserted on macOS + Linux. An explicit t.skip, @@ -3427,3 +3431,70 @@ test('measuredPackageVersion: resolves this checkout\'s version with no repoRoot cleanup(unreadableRoot); } }); + +// ── #3271: the timeout ladder must escalate inward-out ────────────────────────── +// +// Three nested bounds govern this file's two heavy tests: the per-STEP bound inside +// buildBaselineAtRef, the per-TEST timeout node:test enforces, and the whole-CHUNK +// timeout in scripts/run-tests.cjs. They only produce a useful failure if they fire +// in that order. When the step bound was raised to the chunk ceiling, the chunk won +// the race and the failure arrived as an opaque "no failed step" kill — the clean +// per-step message was built and then made unreachable in the same change. +describe('#3271: emitted-runtime-bounds', () => { + const PER_TEST_TIMEOUT_MS = 480_000; + + test('the step bound fires before the per-test timeout', () => { + assert.ok( + BUILD_TIMEOUT_MS < PER_TEST_TIMEOUT_MS, + `step bound ${BUILD_TIMEOUT_MS}ms must be under the per-test timeout ${PER_TEST_TIMEOUT_MS}ms, ` + + 'or node:test kills the test before buildBaselineAtRef can say which step stalled', + ); + }); + + test('the per-test timeout fires before the whole-chunk timeout', () => { + assert.ok( + PER_TEST_TIMEOUT_MS < CHUNK_TIMEOUT_CEILING_MS, + `per-test timeout ${PER_TEST_TIMEOUT_MS}ms must be under the chunk ceiling ` + + `${CHUNK_TIMEOUT_CEILING_MS}ms (scripts/run-tests.cjs:973), or the chunk is killed first ` + + 'and the failure is reported with no failing step at all', + ); + }); + + test('a realistic full build still fits inside the per-test timeout', () => { + // Steps 1 and 2 measured at 15.1s and 19.8s on the remote runner. Their own + // bounds (60s + 180s) are worst-case ceilings, not expected cost; asserting on + // the SUM of all three ceilings would demand a per-test timeout larger than the + // chunk allows and lock in an impossible ladder. + const realisticPreamble = 60_000; + assert.ok( + BUILD_TIMEOUT_MS + realisticPreamble < PER_TEST_TIMEOUT_MS, + 'the generator bound plus a realistic worktree+build preamble must fit inside the per-test timeout', + ); + }); + + test('the declared bounds are the ones this file actually uses', () => { + // Guards the drift this ladder depends on: if a call site's literal timeout is + // edited without updating PER_TEST_TIMEOUT_MS, the two tests above keep passing + // while the real ladder is inverted. Asserted behaviorally against the helper's + // exported values rather than by scanning source text. + assert.equal(WORKTREE_TIMEOUT_MS, 60_000); + assert.equal(BUILD_LIB_TIMEOUT_MS, 180_000); + assert.equal(BUILD_TIMEOUT_MS, 360_000); + assert.equal(CHUNK_TIMEOUT_CEILING_MS, 600_000); + }); + + test('a failing step names itself and its elapsed time', () => { + // The message is the only channel that survives into the remote runner's + // failures.json — its captured `output` field comes back empty. A bare + // "spawnSync ETIMEDOUT" cost four separate experiments to re-derive what the + // throw already had. + let thrown; + assert.throws( + () => buildBaselineAtRef('refs/heads/definitely-not-a-real-ref-3271'), + (err) => { thrown = err; return true; }, + ); + assert.match(thrown.message, /git-worktree-add failed after [\d.]+s/); + assert.match(thrown.message, /Step timings: git-worktree-add=FAILED@[\d.]+s/); + assert.match(thrown.message, /bounds: worktree 60000ms, build:lib 180000ms, generator 360000ms/); + }); +}); diff --git a/tests/eslint-rules.test.cjs b/tests/eslint-rules.test.cjs index 0e9f90fad..c2d551b20 100644 --- a/tests/eslint-rules.test.cjs +++ b/tests/eslint-rules.test.cjs @@ -13,7 +13,8 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); -const { RuleTester } = require('eslint'); +const { RuleTester, ESLint } = require('eslint'); +const path = require('node:path'); const fc = require('fast-check'); const noSourceGrep = require('../eslint-rules/no-source-grep.cjs'); @@ -22,6 +23,7 @@ const noElapsedAssertion = require('../eslint-rules/no-elapsed-assertion.cjs'); const noRawRmsyncInTests = require('../eslint-rules/no-raw-rmsync-in-tests.cjs'); const noTautologicalAssert = require('../eslint-rules/no-tautological-assert.cjs'); const noAdhocMarkdownParsing = require('../eslint-rules/no-adhoc-markdown-parsing.cjs'); +const noDuplicateFoldMarker = require('../eslint-rules/no-duplicate-fold-marker.cjs'); const ruleTester = new RuleTester({ languageOptions: { @@ -1589,3 +1591,374 @@ describe('no-adhoc-markdown-parsing rule', () => { ); }); }); + +// ─── no-duplicate-fold-marker ──────────────────────────────────────────────── + +describe('no-duplicate-fold-marker rule', () => { + const REPO_ROOT = path.join(__dirname, '..'); + + /** Build a source string whose line numbers are the array indices + 1. */ + const src = (...lines) => lines.join('\n'); + + const FOLD_A_B1 = '__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});'; + const FOLD_A_B5 = '__foldDescribe("folded:a (consolidation epic #1969 B5 #1975)", () => {});'; + const FOLD_B_B1 = '__foldDescribe("folded:b (consolidation epic #1969 B1 #1970)", () => {});'; + + test('rule module exports a create function', () => { + assert.strictEqual(typeof noDuplicateFoldMarker.create, 'function'); + }); + + // ── Row 1: the #3271 regression, asserted against the real tree ──────────── + // + // The unit cases below prove the rule can fire. THIS proves the tree it + // guards is actually clean — it is the assertion that was red before the 25 + // duplicated regions were deleted (18 in install.test.cjs, 5 in + // install-minimal-hooks.test.cjs, 2 in install-write-confinement.test.cjs). + // + // Driven through the real ESLint API over the production glob rather than a + // hand-rolled scan of file text: a readFileSync + .match() scan of a .cjs + // path is exactly the shape `local/no-source-grep` bans in tests/**. + test('regression #3271: the real tests/ tree has no duplicate fold markers', async () => { + const eslint = new ESLint({ + cwd: REPO_ROOT, + overrideConfigFile: true, + overrideConfig: { + files: ['tests/**/*.cjs'], + plugins: { local: { rules: { 'no-duplicate-fold-marker': noDuplicateFoldMarker } } }, + languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs' }, + rules: { 'local/no-duplicate-fold-marker': 'error' }, + }, + }); + + const results = await eslint.lintFiles(['tests/**/*.cjs']); + + // Filter to THIS rule: an ad-hoc config also surfaces "rule not found" for + // inline eslint-disable directives naming rules it does not register. + const violations = results.flatMap((r) => + r.messages + .filter((m) => m.ruleId === 'local/no-duplicate-fold-marker') + .map((m) => `${path.relative(REPO_ROOT, r.filePath)}:${m.line} ${m.message}`), + ); + + // Non-vacuous: if the glob silently matched nothing, the empty result below + // would be meaningless. + assert.ok(results.length > 100, `expected the tests/ glob to match many files, got ${results.length}`); + assert.deepStrictEqual(violations, [], `duplicate folded suites found:\n${violations.join('\n')}`); + }); + + test('the rule is registered at error for tests/**/*.cjs in the real config', async () => { + const eslint = new ESLint({ cwd: REPO_ROOT }); + const config = await eslint.calculateConfigForFile( + path.join(REPO_ROOT, 'tests', 'install.test.cjs'), + ); + assert.deepStrictEqual(config.rules['local/no-duplicate-fold-marker'], [2]); + }); + + // ── Occurrence-count boundary: 1 (clean) / 2 (one report) / 3 (two) ──────── + + test('valid: a single folded marker in a file', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [{ code: src(FOLD_A_B1), filename: 'tests/host.test.cjs' }], + invalid: [], + }); + }); + + test('invalid: the same folded marker twice in one file', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [], + invalid: [ + { + code: src(FOLD_A_B1, FOLD_A_B1), + filename: 'tests/host.test.cjs', + errors: [ + { messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 }, + ], + }, + ], + }); + }); + + test('invalid: three occurrences report the 2nd and 3rd', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [], + invalid: [ + { + code: src(FOLD_A_B1, FOLD_A_B1, FOLD_A_B1), + filename: 'tests/host.test.cjs', + errors: [ + { messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 }, + { messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 3 }, + ], + }, + ], + }); + }); + + test('valid: two distinct folded markers', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [{ code: src(FOLD_A_B1, FOLD_B_B1), filename: 'tests/host.test.cjs' }], + invalid: [], + }); + }); + + // ── Negative space (10-diagnosis.md) ────────────────────────────────────── + + // #3271's own reproduction regex (`folded:[a-z0-9-]*`) stops at `.` and + // collides these two genuinely distinct suites, which coexist in + // tests/model-resolver.test.cjs. A guard written to that key would red the + // build on `next` forever. + test('valid: dot-suffixed marker is distinct from its prefix (model-resolver #3271 false positive)', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { + code: src( + '__foldDescribe("folded:feat-443-effort-fast-mode.integration (consolidation epic #1969 B8 #1977)", () => {});', + '__foldDescribe("folded:feat-443-effort-fast-mode (consolidation epic #1969 B8 #1977)", () => {});', + ), + filename: 'tests/model-resolver.test.cjs', + }, + ], + invalid: [], + }); + }); + + // tests/review-default-reviewers-workflow.test.cjs reuses the fold alias for + // an ordinary describe block. Those carry no uniqueness obligation. + test('valid: __foldDescribe titles without a folded: prefix are ignored', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { + code: src( + "__foldDescribe('#1936: OpenCode reviewer empty-output hardening', () => {});", + "__foldDescribe('#1936: OpenCode reviewer empty-output hardening', () => {});", + ), + filename: 'tests/host.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: a file with no fold markers', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [{ code: src('describe("ordinary", () => {});'), filename: 'tests/host.test.cjs' }], + invalid: [], + }); + }); + + test('valid: plain describe with a folded: title is not the fold convention', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { + code: src( + 'describe("folded:a (consolidation epic #1969 B1 #1970)", () => {});', + 'describe("folded:a (consolidation epic #1969 B1 #1970)", () => {});', + ), + filename: 'tests/host.test.cjs', + }, + ], + invalid: [], + }); + }); + + // Documented non-goal, pinned so the behavior is deliberate rather than + // accidental: the rule keys on the callee identifier being literally + // __foldDescribe. Every one of the 365 fold sites calls it directly. + test('valid: a call through a further alias of the fold alias is not tracked', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { + code: src( + 'const d = __foldDescribe;', + 'd("folded:a (consolidation epic #1969 B1 #1970)", () => {});', + 'd("folded:a (consolidation epic #1969 B1 #1970)", () => {});', + ), + filename: 'tests/host.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: a member-expression call named __foldDescribe is not the fold alias', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { + code: src( + 'helpers.__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});', + 'helpers.__foldDescribe("folded:a (consolidation epic #1969 B1 #1970)", () => {});', + ), + filename: 'tests/host.test.cjs', + }, + ], + invalid: [], + }); + }); + + // The same marker in two different HOST files is not intra-file duplication. + // RuleTester lints each entry as its own file, so this also proves the + // per-file state is rebuilt rather than shared across files. + test('valid: the same marker in two different files is not an intra-file duplicate', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { code: src(FOLD_A_B1), filename: 'tests/host-one.test.cjs' }, + { code: src(FOLD_A_B1), filename: 'tests/host-two.test.cjs' }, + ], + invalid: [], + }); + }); + + // ── Ordering / identity ─────────────────────────────────────────────────── + + test('invalid: interleaved duplicates each report against their own first occurrence', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [], + invalid: [ + { + code: src(FOLD_A_B1, FOLD_B_B1, FOLD_A_B1, FOLD_B_B1), + filename: 'tests/host.test.cjs', + errors: [ + { messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 3 }, + { messageId: 'duplicateFoldMarker', data: { marker: 'b', firstLine: '2' }, line: 4 }, + ], + }, + ], + }); + }); + + // The batch label is provenance, not identity — a re-fold under a different + // batch must not evade the guard. This is the exact shape of #3271: #1975 + // re-applied #1970's blocks. + test('invalid: a duplicate marker is reported even when the batch label differs', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [], + invalid: [ + { + code: src(FOLD_A_B1, FOLD_A_B5), + filename: 'tests/host.test.cjs', + errors: [ + { messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 }, + ], + }, + ], + }); + }); + + // ── Title shapes that cannot be resolved statically ─────────────────────── + + test('invalid: substitution-free template-literal fold titles are resolved', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [], + invalid: [ + { + code: src( + '__foldDescribe(`folded:a (consolidation epic #1969 B1 #1970)`, () => {});', + '__foldDescribe(`folded:a (consolidation epic #1969 B1 #1970)`, () => {});', + ), + filename: 'tests/host.test.cjs', + errors: [ + { messageId: 'duplicateFoldMarker', data: { marker: 'a', firstLine: '1' }, line: 2 }, + ], + }, + ], + }); + }); + + test('valid: non-literal fold titles are skipped without throwing', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { + code: src( + 'const name = "folded:a";', + 'const x = "a";', + '__foldDescribe(name, () => {});', + '__foldDescribe(name, () => {});', + '__foldDescribe(`folded:${x} (epic)`, () => {});', + '__foldDescribe(`folded:${x} (epic)`, () => {});', + '__foldDescribe(42, () => {});', + '__foldDescribe(42, () => {});', + ), + filename: 'tests/host.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: __foldDescribe with no arguments does not throw', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { code: src('__foldDescribe();', '__foldDescribe();'), filename: 'tests/host.test.cjs' }, + ], + invalid: [], + }); + }); + + test('valid: an empty marker after the folded: prefix is not tracked', () => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [ + { + code: src( + '__foldDescribe("folded: (consolidation epic #1969 B1 #1970)", () => {});', + '__foldDescribe("folded: (consolidation epic #1969 B1 #1970)", () => {});', + ), + filename: 'tests/host.test.cjs', + }, + ], + invalid: [], + }); + }); + + // Property: marker identity is the whole whitespace-delimited token. + // + // This is the generative form of the #3271 correctness question. An + // implementation that keyed on the issue's `[a-z0-9-]*` slice would truncate + // at `.` and pass arm 1 while failing arm 2 on any pair like + // (`a.integration`, `a`) — which is exactly the tests/model-resolver.test.cjs + // false positive. The alphabet deliberately includes `.` and `_` so those + // pairs are generated, not hoped for. + // + // `fc` is already imported at the top of this file and used by the + // no-adhoc-markdown-parsing suite; this follows the same + // fc.property-driving-ruleTester shape. + test('property: a marker is identified by its whole token, so distinct markers never collide', () => { + const markerArb = fc + .array(fc.constantFrom('a', 'z', 'q', '0', '9', '-', '.', '_'), { minLength: 1, maxLength: 12 }) + .map((chars) => chars.join('')); + + const fold = (marker) => + `__foldDescribe("folded:${marker} (consolidation epic #1969 B1 #1970)", () => {});`; + + // Arm 1: the SAME marker twice is always reported exactly once, against + // the first occurrence. + fc.assert( + fc.property(markerArb, (marker) => { + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [], + invalid: [ + { + code: src(fold(marker), fold(marker)), + filename: 'tests/host.test.cjs', + errors: [ + { messageId: 'duplicateFoldMarker', data: { marker, firstLine: '1' }, line: 2 }, + ], + }, + ], + }); + }), + { numRuns: 150, seed: 3271 }, + ); + + // Arm 2: two DISTINCT markers never collide, however they differ. + fc.assert( + fc.property(markerArb, markerArb, (a, b) => { + fc.pre(a !== b); + ruleTester.run('no-duplicate-fold-marker', noDuplicateFoldMarker, { + valid: [{ code: src(fold(a), fold(b)), filename: 'tests/host.test.cjs' }], + invalid: [], + }); + }), + { numRuns: 150, seed: 3271 }, + ); + }); +}); diff --git a/tests/fragment-single-edit-propagation.install.test.cjs b/tests/fragment-single-edit-propagation.install.test.cjs index 5d02d6e93..44e73274f 100644 --- a/tests/fragment-single-edit-propagation.install.test.cjs +++ b/tests/fragment-single-edit-propagation.install.test.cjs @@ -49,7 +49,7 @@ * Cleanup is via `t.after()` (never `try/finally` in the test body). */ -const { test } = require('node:test'); +const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); @@ -58,9 +58,15 @@ const { spawnSync } = require('node:child_process'); const { runNode } = require('./helpers/process-seam.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); -const { cleanup, readFileNormalized } = require('./helpers.cjs'); +const { cleanup, createTempDir, readFileNormalized } = require('./helpers.cjs'); const { RUNTIME_META, installerEnv } = require('./helpers/install-shared.cjs'); -const { buildOverlayRepo, REPO_ROOT } = require('./helpers/overlay-repo.cjs'); +const { + buildOverlayRepo, + REPO_ROOT, + placeVanishableLeaf, + linkOrCopyFile, + isMissingPath, +} = require('./helpers/overlay-repo.cjs'); // Read each generator's own typed reason enum (never invent/regex a reason // string) — rows 7 and 12 assert the REAL code below. gen-registry.cjs, // gen-adr-index.cjs, gen-capability-matrix.cjs, gen-inventory-manifest.cjs @@ -1289,3 +1295,101 @@ test('regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface', { cleanup(install.root); } }); + +describe('#3271: an overlay source that vanishes mid-walk', () => { + test('a leaf whose source is GONE is skipped, not fatal', () => { + const dir = createTempDir('gsd-3271-vanish-'); + try { + const missing = path.join(dir, 'never-existed.js'); + let attempts = 0; + const placed = placeVanishableLeaf(missing, () => { + attempts += 1; + const err = new Error(`ENOENT: no such file or directory, link '${missing}'`); + err.code = 'ENOENT'; + throw err; + }); + assert.equal(placed, false, 'a source that left the tree is not part of the snapshot'); + assert.equal(attempts, 1, 'no retry when the path is genuinely gone'); + } finally { + cleanup(dir); + } + }); + + test('a leaf mid-atomic-replace is retried once and placed', () => { + // The real shape: scripts/build-hooks.js unlinks and renames, so the name is + // briefly absent and then live again. The first attempt sees ENOENT; by the + // time we re-examine, the successor is in place. + const dir = createTempDir('gsd-3271-replace-'); + try { + const src = path.join(dir, 'gsd-config-reload.js'); + fs.writeFileSync(src, 'module.exports = 1;\n'); + let attempts = 0; + const placed = placeVanishableLeaf(src, () => { + attempts += 1; + if (attempts === 1) { + const err = new Error('ENOENT: no such file or directory, link'); + err.code = 'ENOENT'; + throw err; + } + }); + assert.equal(placed, true); + assert.equal(attempts, 2, 'exactly one retry — no spin, no sleep'); + } finally { + cleanup(dir); + } + }); + + test('a non-ENOENT failure still propagates', () => { + const dir = createTempDir('gsd-3271-eacces-'); + try { + assert.throws( + () => placeVanishableLeaf(dir, () => { + const err = new Error('EACCES: permission denied'); + err.code = 'EACCES'; + throw err; + }), + /EACCES/, + 'only a vanished source is tolerable; every other error is a real defect', + ); + } finally { + cleanup(dir); + } + }); + + test('linkOrCopyFile survives a real ENOENT from linkSync when the source is live', () => { + // Monkeypatch fs.linkSync to fail ENOENT exactly once, then restore in a + // finally. Mode-bit tricks are not used on purpose: root bypasses 0o000, so + // such a test passes with zero coverage under root Docker/CI. + const dir = createTempDir('gsd-3271-link-'); + const realLinkSync = fs.linkSync; + try { + const src = path.join(dir, 'src.js'); + const dest = path.join(dir, 'dest.js'); + fs.writeFileSync(src, 'contents\n'); + let calls = 0; + fs.linkSync = (...args) => { + calls += 1; + if (calls === 1) { + const err = new Error('ENOENT: no such file or directory, link'); + err.code = 'ENOENT'; + throw err; + } + return realLinkSync(...args); + }; + assert.equal(linkOrCopyFile(src, dest), true); + assert.equal(calls, 2); + assert.equal(fs.readFileSync(dest, 'utf8'), 'contents\n'); + } finally { + fs.linkSync = realLinkSync; + cleanup(dir); + } + }); + + test('isMissingPath accepts only ENOENT', () => { + assert.equal(isMissingPath(Object.assign(new Error('x'), { code: 'ENOENT' })), true); + assert.equal(isMissingPath(Object.assign(new Error('x'), { code: 'EACCES' })), false); + assert.equal(isMissingPath(Object.assign(new Error('x'), { code: 'EXDEV' })), false); + assert.equal(isMissingPath(new Error('plain')), false); + assert.equal(isMissingPath(null), false); + }); +}); diff --git a/tests/helpers/emitted-runtime.cjs b/tests/helpers/emitted-runtime.cjs index 2cd65ee3a..05fadaedd 100644 --- a/tests/helpers/emitted-runtime.cjs +++ b/tests/helpers/emitted-runtime.cjs @@ -668,6 +668,35 @@ function baselineManifestsAtRef(base = 'origin/next') { * @param {string} [o.cwd] repo to run `git worktree` from AND whose generator measures it * @returns {object} the parsed baseline artifact ({version, sha, manifests, sizes}) */ +const WORKTREE_TIMEOUT_MS = 60_000; +const BUILD_LIB_TIMEOUT_MS = 180_000; +// 360s for the generator step. NOT the 600000ms `local/no-unbounded-spawn` +// ceiling: `scripts/run-tests.cjs:973` bounds the WHOLE chunk at 600000ms, so a +// step bound equal to it loses the race — the chunk is killed first and the +// failure arrives as an opaque "no failed step" kill instead of the per-step +// message below. The bounds must escalate inward-out, and +// `emitted-runtime-bounds` in tests/emitted-attribution.test.cjs locks that. +// +// Measured for this step: ~22s idle in a container, ~142s with 8 CPU burners on +// 8 cores, 91.6s and 115.8s in the run that passed, and 300.1s in the run that +// timed out (censored — its real need is unknown). 360s is ~3x the passing +// observation and 20% above the censored one, while leaving 240s of chunk +// headroom for every other file sharing the chunk. +// +// The old 300s sat INSIDE that variance band. Under gsd-test this slow path runs +// on every verification, because the on-disk baseline cache is restored by +// actions/cache keyed on github.event.pull_request.base.sha — a key that exists +// only inside GitHub Actions. The real remedy is making that cache reachable from +// the remote runner so the in-job build returns to being the rare fallback +// ADR-2719 §5 describes; that is a gsd-test-runner change, not one this repo can +// make. +const BUILD_TIMEOUT_MS = 360_000; +// Mirrors `scripts/run-tests.cjs:973`'s default. Duplicated deliberately and +// narrowly: the bounds here must be checkable against it, and the alternative is +// reading that script's source, which `local/no-source-grep` bans. The lock test +// names this as the drift risk. +const CHUNK_TIMEOUT_CEILING_MS = 600_000; + function buildBaselineAtRef(ref, { cwd = REPO_ROOT } = {}) { const worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-emitted-baseline-wt-')); // mkdtempSync already created the directory; `git worktree add` requires the @@ -675,34 +704,59 @@ function buildBaselineAtRef(ref, { cwd = REPO_ROOT } = {}) { fs.rmdirSync(worktreeDir); const outFile = path.join(os.tmpdir(), `gsd-emitted-baseline-out-${crypto.randomBytes(8).toString('hex')}.json`); - const WORKTREE_TIMEOUT_MS = 60_000; - const BUILD_LIB_TIMEOUT_MS = 180_000; - const BUILD_TIMEOUT_MS = 300_000; + // Per-step timings, carried into the thrown error. A bare "spawnSync ETIMEDOUT" + // names neither the step nor its elapsed time, which is exactly the information + // needed to tell a slow machine from a hung step — and the failure message is + // the only channel that survives into the remote runner's failures.json. + const timings = []; + const timed = (step, fn) => { + const started = Date.now(); + try { + const value = fn(); + timings.push(`${step}=${((Date.now() - started) / 1000).toFixed(1)}s`); + return value; + } catch (err) { + const elapsed = ((Date.now() - started) / 1000).toFixed(1); + timings.push(`${step}=FAILED@${elapsed}s`); + const partial = [ + err && err.stdout ? `stdout tail: ${String(err.stdout).trim().slice(-400)}` : '', + err && err.stderr ? `stderr tail: ${String(err.stderr).trim().slice(-400)}` : '', + ].filter(Boolean).join('\n '); + err.message = + `${step} failed after ${elapsed}s (bounds: worktree ${WORKTREE_TIMEOUT_MS}ms, ` + + `build:lib ${BUILD_LIB_TIMEOUT_MS}ms, generator ${BUILD_TIMEOUT_MS}ms). ` + + `Step timings: ${timings.join(' ')}. ${err.message}` + + (partial ? `\n ${partial}` : ''); + throw err; + } + }; try { - execFileSync('git', [...safeDirArgs(cwd), 'worktree', 'add', '--detach', worktreeDir, ref], { - cwd, encoding: 'utf8', timeout: WORKTREE_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'], - }); + timed('git-worktree-add', () => + execFileSync('git', [...safeDirArgs(cwd), 'worktree', 'add', '--detach', worktreeDir, ref], { + cwd, encoding: 'utf8', timeout: WORKTREE_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'], + })); const sharedNodeModules = path.join(cwd, 'node_modules'); if (fs.existsSync(sharedNodeModules)) { fs.symlinkSync(sharedNodeModules, path.join(worktreeDir, 'node_modules'), 'dir'); } - runNpm(['run', 'build:lib'], { - cwd: worktreeDir, timeout: BUILD_LIB_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'], - }); + timed('npm-run-build-lib', () => + runNpm(['run', 'build:lib'], { + cwd: worktreeDir, timeout: BUILD_LIB_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'], + })); // Run `cwd`'s OWN generator (not the worktree's — see the function doc for why), // pointed at the worktree as the tree to measure. - execFileSync( - process.execPath, - [path.join(cwd, 'scripts', 'gen-emitted-baseline.cjs'), '--dir', worktreeDir, '--out', outFile], - { cwd, encoding: 'utf8', timeout: BUILD_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'] }, - ); + timed('gen-emitted-baseline', () => + execFileSync( + process.execPath, + [path.join(cwd, 'scripts', 'gen-emitted-baseline.cjs'), '--dir', worktreeDir, '--out', outFile], + { cwd, encoding: 'utf8', timeout: BUILD_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'] }, + )); - const raw = fs.readFileSync(outFile, 'utf8'); - return JSON.parse(raw); + return timed('read-artifact', () => JSON.parse(fs.readFileSync(outFile, 'utf8'))); } finally { try { execFileSync('git', [...safeDirArgs(cwd), 'worktree', 'remove', '--force', worktreeDir], { @@ -926,4 +980,8 @@ module.exports = { currentManifests, currentSizes, readAckFile, + WORKTREE_TIMEOUT_MS, + BUILD_LIB_TIMEOUT_MS, + BUILD_TIMEOUT_MS, + CHUNK_TIMEOUT_CEILING_MS, }; diff --git a/tests/helpers/overlay-repo.cjs b/tests/helpers/overlay-repo.cjs index c2c95906f..436be73eb 100644 --- a/tests/helpers/overlay-repo.cjs +++ b/tests/helpers/overlay-repo.cjs @@ -58,19 +58,70 @@ const REPO_ROOT = path.join(__dirname, '..', '..'); const OVERLAY_SKIP_TOP = new Set(['node_modules', '.git']); +/** + * True when `err` reports that a path was not there. + * + * @param {unknown} err + * @returns {boolean} + */ +function isMissingPath(err) { + return Boolean(err) && typeof err === 'object' && err.code === 'ENOENT'; +} + +/** + * Run `attempt` against a source path that another process may be replacing + * underneath the walk, and report whether the leaf was actually placed. + * + * `buildOverlayRepo` enumerates names with `readdirSync` and then acts on them, + * which is a TOCTOU window. It is not theoretical: `hooks/dist` is regenerated + * by an ATOMIC REPLACE (`scripts/build-hooks.js` unlinks and renames), so any + * concurrently running test that rebuilds hooks makes a just-listed name vanish + * mid-walk. That took down three runs on three different branches with a bare + * `ENOENT ... link '/work/hooks/dist/...'`. + * + * On ENOENT the source is re-examined ONCE rather than slept on: an atomic + * rename is a single syscall, so by the time the failure surfaces the successor + * is either already in place (retry succeeds) or the path is genuinely gone from + * the tree (nothing to mirror, so the leaf is skipped). No sleep, no spin — a + * timing-based wait here would be the flake this is fixing, not a fix for it. + * + * Returns false only when the path left the source tree entirely; the overlay + * mirrors the tree, and a file that is no longer in it is not part of the + * snapshot. Every other error propagates untouched. + * + * @param {string} srcPath + * @param {() => void} attempt + * @returns {boolean} whether the leaf was placed + */ +function placeVanishableLeaf(srcPath, attempt) { + try { + attempt(); + return true; + } catch (err) { + if (!isMissingPath(err)) throw err; + if (!fs.existsSync(srcPath)) return false; + attempt(); + return true; + } +} + /** Hard-link a file, falling back to a real copy only if the two paths sit on * different filesystems/devices (EXDEV) or linking is denied (EPERM) — both - * cross-platform-legitimate, unlike a symlink's Dirent type-detection gap. */ + * cross-platform-legitimate, unlike a symlink's Dirent type-detection gap. + * Returns whether the leaf was placed; false means the source vanished + * mid-walk (see `placeVanishableLeaf`). */ function linkOrCopyFile(src, dest) { - try { - fs.linkSync(src, dest); - } catch (err) { - if (err.code === 'EXDEV' || err.code === 'EPERM') { - fs.copyFileSync(src, dest); - } else { - throw err; + return placeVanishableLeaf(src, () => { + try { + fs.linkSync(src, dest); + } catch (err) { + if (err.code === 'EXDEV' || err.code === 'EPERM') { + fs.copyFileSync(src, dest); + } else { + throw err; + } } - } + }); } /** @@ -95,6 +146,7 @@ function buildOverlayRepo(fileOverrides, opts = {}) { parts: relPath.split('/'), content, })); + const skipped = []; function place(srcDir, destDir, pending, isTop) { fs.mkdirSync(destDir, { recursive: true }); @@ -120,21 +172,48 @@ function buildOverlayRepo(fileOverrides, opts = {}) { // fs.statSync follows symlinks (unlike Dirent.isDirectory()), so a // symlinked source directory is still recursed as a REAL directory in // the overlay — the property copyWithPathReplacement itself needs. - if (fs.statSync(srcPath).isDirectory()) { + // + // The stat sits in the same TOCTOU window as the copy/link below: the + // name came from readdirSync, and an atomic replace elsewhere in the tree + // can retire it before we get here. + let srcStat; + try { + srcStat = fs.statSync(srcPath); + } catch (err) { + if (isMissingPath(err)) continue; + throw err; + } + if (srcStat.isDirectory()) { place(srcPath, destPath, overridden || [], false); } else if (mode === 'copy') { // Real independent inode — a write through this path in the overlay // can never alias back to REPO_ROOT's own tracked file (see // opts.mode doc above). - fs.copyFileSync(srcPath, destPath); + const placed = placeVanishableLeaf(srcPath, () => fs.copyFileSync(srcPath, destPath)); + if (!placed) skipped.push(srcPath); } else { - linkOrCopyFile(srcPath, destPath); + const placed = linkOrCopyFile(srcPath, destPath); + if (!placed) skipped.push(srcPath); } } } place(REPO_ROOT, tmpRepo, entries, true); + + if (skipped.length > 0) { + // Not thrown: a source that left the tree mid-walk is genuinely not part of + // the snapshot, and failing here would reintroduce the crash this tolerance + // exists to remove. But it must not be SILENT either — a dropped leaf can + // surface later as a confusing "file missing" in an unrelated assertion, or + // as nothing at all for a test that never touches it. + console.warn( + `buildOverlayRepo: ${skipped.length} source file(s) vanished mid-walk and were ` + + `omitted from the overlay (likely a concurrent atomic replace, e.g. hooks/dist):\n ` + + skipped.join('\n '), + ); + } + return tmpRepo; } -module.exports = { buildOverlayRepo, linkOrCopyFile, REPO_ROOT, OVERLAY_SKIP_TOP }; +module.exports = { buildOverlayRepo, linkOrCopyFile, placeVanishableLeaf, isMissingPath, REPO_ROOT, OVERLAY_SKIP_TOP }; diff --git a/tests/helpers/timeouts.cjs b/tests/helpers/timeouts.cjs index 5414c7600..c5d2b3246 100644 --- a/tests/helpers/timeouts.cjs +++ b/tests/helpers/timeouts.cjs @@ -30,6 +30,30 @@ const { DEFAULT_GIT_TIMEOUT_MS } = require('./git-fixture.cjs'); */ const PROBE_TIMEOUT_MS = 15000; +/** + * A git-hook invocation that FANS OUT to nested shell subprocesses — the hook + * itself under `bash`, plus every helper it shells to. The prepush guard is the + * worked example: it runs a mock `git` that is also a bash script, so a single + * `runHook` is roughly four Git Bash spawns. + * + * This is a heavier class than `PROBE_TIMEOUT_MS`, and the difference is + * Windows-shaped. Each spawn there is Defender-scanned, and the first hook test + * in a file pays cold start on top. CI (PR #3285, `full test (windows-latest, + * 22, shard 2/3)`) recorded `outcome=timed_out exitCode=null` at exactly the + * 15000ms probe bound while every other lane — including windows-latest node 24, + * all three shards — passed the same commit. That is a bound sized for the wrong + * class, not a slow machine. + * + * 60000ms is 4x the bound that failed and half `INSTALL_TIMEOUT_MS`, which is + * the right order: a hook fan-out is much lighter than a full installer run but + * far heavier than reading back a version string. + * + * Sites that invoke a hook doing NO subprocess fan-out should stay on + * `PROBE_TIMEOUT_MS` — this norm describes the fan-out shape, not `runHook` in + * general. + */ +const HOOK_FANOUT_TIMEOUT_MS = 60000; + /** * Git plumbing (rev-parse, branch, log, ...) against a small mkdtemp * fixture repo. Re-exports `tests/helpers/git-fixture.cjs`'s @@ -57,6 +81,7 @@ const INSTALL_TIMEOUT_MS = 120000; module.exports = { PROBE_TIMEOUT_MS, + HOOK_FANOUT_TIMEOUT_MS, GIT_TIMEOUT_MS, BUILD_TIMEOUT_MS, INSTALL_TIMEOUT_MS, diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index 2d6771167..2c31132fe 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -2731,1288 +2731,6 @@ describe('enh-770: managed-hooks-registry includes gsd-config-reload.js', () => }); } -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-1754-js-hook-guard.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-1754-js-hook-guard (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression tests for bug #1754 - * - * The installer must NOT register .js hook entries in settings.json when the - * corresponding .js file does not exist at the target path. The original bug: - * on fresh installs where hooks/dist/ was missing from the npm package (as in - * v1.32.0), the hook copy step produced no files, yet the registration step - * ran unconditionally for .js hooks — leaving users with "PreToolUse:Bash - * hook error" on every tool invocation. - * - * The .sh hooks already had fs.existsSync() guards (added in #1817). This - * test verifies the same defensive pattern exists for all .js hooks. - */ - -'use strict'; - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. -const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); - -const JS_HOOKS = [ - { name: 'gsd-check-update.js', registrationAnchor: 'hasGsdUpdateHook' }, - { name: 'gsd-context-monitor.js', registrationAnchor: 'hasContextMonitorHook' }, - { name: 'gsd-prompt-guard.js', registrationAnchor: 'hasPromptGuardHook' }, - { name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' }, - { name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' }, - { name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' }, - { name: 'gsd-write-guard.js', registrationAnchor: 'hasWriteGuardHook' }, -]; - -describe('bug #1754: .js hook registration guards', () => { - let src; - - before(() => { - // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. - // Concatenate both sources so structural assertions find patterns in either file. - const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); - let hooksSurfaceSrc = ''; - try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } - src = installSrc + '\n' + hooksSurfaceSrc; - }); - - for (const { name, registrationAnchor } of JS_HOOKS) { - describe(`${name} registration`, () => { - test(`install.js checks file existence before registering ${name}`, () => { - // Find the registration block by locating the "has...Hook" variable - const anchorIdx = src.indexOf(registrationAnchor); - assert.ok( - anchorIdx !== -1, - `${registrationAnchor} variable not found in install.js` - ); - - // Extract a window around the registration block to find the guard - const blockStart = anchorIdx; - const blockEnd = Math.min(src.length, anchorIdx + 1200); - const block = src.slice(blockStart, blockEnd); - - // The block must contain an fs.existsSync check for the hook file - assert.ok( - block.includes('fs.existsSync') || block.includes('existsSync'), - `install.js must call fs.existsSync on the target path before registering ${name} ` + - `in settings.json. Without this guard, hooks are registered even when the .js file ` + - `was never copied (the root cause of #1754).` - ); - }); - - test(`install.js emits a warning when ${name} is missing`, () => { - // The hook file name (without extension) should appear in a warning message - const hookBaseName = name.replace('.js', ''); - const warnPattern = `Skipped`; - const anchorIdx = src.indexOf(registrationAnchor); - const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200)); - - assert.ok( - block.includes(warnPattern) && block.includes(hookBaseName), - `install.js must emit a skip warning when ${name} is not found at the target path` - ); - }); - }); - } - - test('all .js hooks use the same guard pattern as .sh hooks', () => { - // Count existsSync calls in the hook registration section. - // There should be guards for all JS hooks plus the existing SH hooks. - // This test ensures new hooks added in the future follow the same pattern. - // ADR-857 phase 5f-1b: registration moved to runtime-hooks-surface.cts so scan the - // full concatenated source (install.js + runtime-hooks-surface.cts) rather than slicing. - const registrationSection = src; - - // Count unique hook file existence checks (pattern: path.join(targetDir, 'hooks', 'gsd-*.js')) - const jsGuards = (registrationSection.match(/gsd-[\w-]+\.js.*not found at target/g) || []); - const shGuards = (registrationSection.match(/gsd-[\w-]+\.sh.*not found at target/g) || []); - - assert.ok( - jsGuards.length >= JS_HOOKS.length, - `Expected at least ${JS_HOOKS.length} .js hook guards, found ${jsGuards.length}. ` + - `Every .js hook registration must check file existence before registering.` - ); - - assert.ok( - shGuards.length >= 3, - `Expected at least 3 .sh hook guards (validate-commit, session-state, phase-boundary), ` + - `found ${shGuards.length}.` - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-1817-sh-hook-guard.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-1817-sh-hook-guard (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression tests for bug #1817 - * - * The installer must NOT register .sh hook entries in settings.json when the - * corresponding .sh file does not exist at the target path. The original bug: - * v1.32.0's npm package omitted the .sh files from hooks/dist/, so the copy - * step produced no files, yet the registration step ran unconditionally — - * leaving users with hook errors on every tool invocation. - * - * Defensive guard: before registering each .sh hook in settings.json, - * install.js must verify the target file exists. If it doesn't, skip - * registration and emit a warning. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. -const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); - -const SH_HOOKS = [ - { name: 'gsd-validate-commit.sh', settingsVar: 'validateCommitCommand' }, - { name: 'gsd-session-state.sh', settingsVar: 'sessionStateCommand' }, - { name: 'gsd-phase-boundary.sh', settingsVar: 'phaseBoundaryCommand' }, -]; - -describe('bug #1817: .sh hook registration guards', () => { - let src; - - // Read once — all tests in this suite share the same source snapshot. - // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. - // Concatenate both sources so structural assertions find patterns in either file. - try { - const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); - let hooksSurfaceSrc = ''; - try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } - src = installSrc + '\n' + hooksSurfaceSrc; - } catch { - src = ''; - } - - for (const { name, settingsVar } of SH_HOOKS) { - describe(`${name} registration`, () => { - test(`install.js checks file existence before registering ${name}`, () => { - // Find the block where this .sh hook is registered. - // Each registration block is preceded by the command variable declaration - // and followed by the next hook or end of registration section. - const varIdx = src.indexOf(settingsVar); - assert.ok(varIdx !== -1, `${settingsVar} variable not found in install.js`); - - // Extract ~900 chars around the variable to find the registration block - const blockStart = Math.max(0, varIdx - 50); - const blockEnd = Math.min(src.length, varIdx + 900); - const block = src.slice(blockStart, blockEnd); - - assert.ok( - block.includes('fs.existsSync') || block.includes('existsSync'), - `install.js must call fs.existsSync on the target path before registering ${name} in settings.json. ` + - `Without this guard, hooks are registered even when the .sh file was never copied ` + - `(the root cause of #1817).` - ); - }); - }); - } -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1076-extended-hook-events-drive.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1076-extended-hook-events-drive (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * ADR-857 phase 5f-3: extended hook event guards are driven by the - * extendedHookEvents descriptor field, not hardcoded runtime-name checks. - * - * Before this change: - * - SubagentStop/Stop/PreCompact were wired only when (isQwen || runtime==='claude') - * - FileChanged was wired only when (runtime === 'claude') - * - BeforeAgent/AfterAgent/BeforeModel were wired only when (isGemini) - * - * After this change: - * - All three guard blocks are driven purely by extendedEvents.includes(eventName) - * - Any runtime (or arbitrary string) that passes the right extendedHookEvents - * array gets exactly those events registered, regardless of its runtime name. - * - * This suite proves descriptor-drive by calling applySettingsJsonHooks directly - * with a controlled extendedHookEvents array and asserting on settings.hooks. - * No source-grep; purely behavioral. - */ - -const { test, describe, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { ensureHooksDist } = require('./helpers/hooks-dist.cjs'); - -const REPO_ROOT = path.resolve(__dirname, '..'); - -before(() => { - ensureHooksDist(); -}); - -const { applySettingsJsonHooks } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** Return all hook commands registered under an event key. */ -function hooksForEvent(settings, eventName) { - if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; - return settings.hooks[eventName].flatMap(entry => - (entry && Array.isArray(entry.hooks) ? entry.hooks : []) - .map(h => h && h.command) - .filter(Boolean) - ); -} - -/** True if any hook is registered under eventName. */ -function hasHooksFor(settings, eventName) { - return hooksForEvent(settings, eventName).length > 0; -} - -/** - * Create a temporary directory with stub hook files so fs.existsSync guards pass. - * Returns the targetDir path. - */ -function createStubTargetDir() { - const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-1076-')); - const hooksDir = path.join(tmpDir, 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - // Stubs for the hooks applySettingsJsonHooks existsSync-checks - const stubs = [ - 'gsd-check-update.js', - 'gsd-context-monitor.js', - 'gsd-prompt-guard.js', - 'gsd-read-guard.js', - 'gsd-read-injection-scanner.js', - 'gsd-config-reload.js', - 'gsd-workflow-guard.js', - 'gsd-worktree-path-guard.js', - 'gsd-validate-commit.sh', - 'gsd-session-state.sh', - 'gsd-phase-boundary.sh', - 'gsd-graphify-update.sh', - ]; - const hooksDistDir = path.join(REPO_ROOT, 'hooks', 'dist'); - for (const stub of stubs) { - const dest = path.join(hooksDir, stub); - const distSrc = path.join(hooksDistDir, stub); - if (fs.existsSync(distSrc)) { - fs.copyFileSync(distSrc, dest); - } else { - // Minimal stub so existsSync passes - const ext = path.extname(stub); - fs.writeFileSync(dest, ext === '.sh' ? '#!/bin/bash\n# stub\n' : '#!/usr/bin/env node\n// stub\n'); - } - try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } - } - return tmpDir; -} - -function cleanupDir(dir) { - cleanup(dir); -} - -/** - * Build the minimal opts bag for applySettingsJsonHooks. - * postToolEvent: 'PostToolUse' (default dialect). - * All commands: non-null strings so the "command truthy" guard passes. - */ -function buildOpts(targetDir, { runtime, extendedHookEvents }) { - const hookOpts = { platform: process.platform, runtime }; - const node = process.execPath; - return { - runtime, - isGlobal: true, - targetDir, - postToolEvent: 'PostToolUse', - hookEvents: undefined, // not the hookEvents dialect — we're testing extendedHookEvents - extendedHookEvents, - updateCheckCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-check-update.js')}"`, - contextMonitorCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-context-monitor.js')}"`, - promptGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-prompt-guard.js')}"`, - readGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-guard.js')}"`, - readInjectionScannerCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js')}"`, - configReloadCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-config-reload.js')}"`, - hookOpts, - localCmd: () => null, - localShellCmd: () => null, - }; -} - -// ─── Suite 1: claude shape (SubagentStop+Stop+PreCompact+FileChanged) ───────── - -describe('enh-1076 phase 5f-3: claude extendedHookEvents → SubagentStop/Stop/PreCompact/FileChanged', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - const opts = buildOpts(targetDir, { - runtime: 'claude', - extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], - }); - applySettingsJsonHooks(settings, opts); - }); - - test('SubagentStop is wired (descriptor-driven)', () => { - assert.ok( - hasHooksFor(settings, 'SubagentStop'), - `Expected SubagentStop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('Stop is wired (descriptor-driven)', () => { - assert.ok( - hasHooksFor(settings, 'Stop'), - `Expected Stop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('PreCompact is wired (descriptor-driven)', () => { - assert.ok( - hasHooksFor(settings, 'PreCompact'), - `Expected PreCompact hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('FileChanged is wired (descriptor-driven)', () => { - assert.ok( - hasHooksFor(settings, 'FileChanged'), - `Expected FileChanged hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 2: qwen shape (SubagentStop+Stop+PreCompact, no FileChanged) ─────── - -describe('enh-1076 phase 5f-3: qwen extendedHookEvents → SubagentStop/Stop/PreCompact only', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - const opts = buildOpts(targetDir, { - runtime: 'qwen', - extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'], - }); - applySettingsJsonHooks(settings, opts); - }); - - test('SubagentStop is wired', () => { - assert.ok(hasHooksFor(settings, 'SubagentStop')); - }); - - test('Stop is wired', () => { - assert.ok(hasHooksFor(settings, 'Stop')); - }); - - test('PreCompact is wired', () => { - assert.ok(hasHooksFor(settings, 'PreCompact')); - }); - - test('FileChanged is NOT wired (not in extendedHookEvents)', () => { - assert.strictEqual( - hasHooksFor(settings, 'FileChanged'), - false, - `FileChanged must NOT be wired for qwen shape; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 3: gemini shape (BeforeAgent+AfterAgent+BeforeModel) ─────────────── - -describe('enh-1076 phase 5f-3: extendedHookEvents → BeforeAgent/AfterAgent/BeforeModel (Gemini-3 backend dialect)', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - const opts = buildOpts(targetDir, { - runtime: 'antigravity', - extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'], - }); - applySettingsJsonHooks(settings, opts); - }); - - test('BeforeAgent is wired', () => { - assert.ok( - hasHooksFor(settings, 'BeforeAgent'), - `Expected BeforeAgent hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('AfterAgent is wired', () => { - assert.ok(hasHooksFor(settings, 'AfterAgent')); - }); - - test('BeforeModel is wired', () => { - assert.ok(hasHooksFor(settings, 'BeforeModel')); - }); - - test('SubagentStop is NOT wired (not in extendedHookEvents)', () => { - assert.strictEqual( - hasHooksFor(settings, 'SubagentStop'), - false, - 'SubagentStop must NOT be wired for gemini shape' - ); - }); - - test('FileChanged is NOT wired (not in extendedHookEvents)', () => { - assert.strictEqual( - hasHooksFor(settings, 'FileChanged'), - false, - 'FileChanged must NOT be wired for gemini shape' - ); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 4: empty extendedHookEvents → none of the extended events ────────── - -describe('enh-1076 phase 5f-3: empty extendedHookEvents → no extended events wired', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - // Use runtime='someruntime' to prove it's the descriptor, not the name, that matters - const opts = buildOpts(targetDir, { - runtime: 'someruntime', - extendedHookEvents: [], - }); - applySettingsJsonHooks(settings, opts); - }); - - const EXTENDED_EVENTS = [ - 'SubagentStop', 'Stop', 'PreCompact', 'FileChanged', - 'BeforeAgent', 'AfterAgent', 'BeforeModel', - ]; - - for (const event of EXTENDED_EVENTS) { - test(`${event} is NOT wired when extendedHookEvents is empty`, () => { - assert.strictEqual( - hasHooksFor(settings, event), - false, - `${event} must not be wired when extendedHookEvents=[] (runtime=someruntime); hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - } - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 5: descriptor-drive is runtime-name-agnostic ─────────────────────── -// Pass an arbitrary runtime name ('hypothetical') with SubagentStop in its -// extendedHookEvents. This could NEVER have worked under the old hardcoded check. -// Under the new descriptor-driven guard it MUST work. - -describe('enh-1076 phase 5f-3: arbitrary runtime with SubagentStop in descriptor gets it wired', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - const opts = buildOpts(targetDir, { - runtime: 'hypothetical', // NOT 'claude' or 'qwen' — would have been skipped before - extendedHookEvents: ['SubagentStop'], - }); - applySettingsJsonHooks(settings, opts); - }); - - test('SubagentStop IS wired for a hypothetical runtime when descriptor includes it', () => { - assert.ok( - hasHooksFor(settings, 'SubagentStop'), - `SubagentStop must be wired via descriptor even for unknown runtime names; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('Stop is NOT wired (not in extendedHookEvents)', () => { - assert.strictEqual(hasHooksFor(settings, 'Stop'), false); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 6: hooksSurface drive (ADR-857 phase 5g drive 3) ────────────────── -// -// applySettingsJsonHooks is gated by opts.hooksSurface !== 'none'. -// - hooksSurface:'none' → entire body is skipped; no hooks written -// - hooksSurface:'settings-json'→ hooks are written (even for a runtime whose -// name was previously hardcoded to skip, e.g. 'opencode') -// -// This proves the skip is driven by the descriptor field, not the runtime name. - -describe('enh-1076 phase 5g drive 3: hooksSurface:none skips all hooks regardless of runtime', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - // 'claude' would normally write hooks, but hooksSurface:'none' must skip entirely. - const opts = { - ...buildOpts(targetDir, { runtime: 'claude', extendedHookEvents: ['SubagentStop'] }), - hooksSurface: 'none', - }; - applySettingsJsonHooks(settings, opts); - }); - - test('SessionStart is NOT written when hooksSurface is "none"', () => { - assert.strictEqual( - hasHooksFor(settings, 'SessionStart'), - false, - `SessionStart must not be written when hooksSurface="none"; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('PostToolUse is NOT written when hooksSurface is "none"', () => { - assert.strictEqual(hasHooksFor(settings, 'PostToolUse'), false); - }); - - test('PreToolUse is NOT written when hooksSurface is "none"', () => { - assert.strictEqual(hasHooksFor(settings, 'PreToolUse'), false); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -describe('enh-1076 phase 5g drive 3: hooksSurface:settings-json writes hooks even for previously-skipped runtime name', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - // 'opencode' previously was hardcoded to skip hooks; with descriptor drive it - // should write hooks whenever hooksSurface !== 'none'. - const opts = { - ...buildOpts(targetDir, { runtime: 'opencode', extendedHookEvents: [] }), - hooksSurface: 'settings-json', - }; - applySettingsJsonHooks(settings, opts); - }); - - test('SessionStart IS written with at least one command when hooksSurface is "settings-json" (even for opencode name)', () => { - // ensureHooksDist() in before() guarantees hooks/dist is built, so the - // existsSync guards inside applySettingsJsonHooks pass and commands are registered. - assert.ok( - settings.hooks && typeof settings.hooks === 'object', - `settings.hooks must be initialized when hooksSurface="settings-json"`, - ); - assert.ok( - hasHooksFor(settings, 'SessionStart'), - `settings.hooks.SessionStart must contain at least one registered command when hooksSurface="settings-json"; ` + - `keys: ${JSON.stringify(Object.keys(settings.hooks))}`, - ); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1077-install-hook-events-dialect-drive.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1077-install-hook-events-dialect-drive (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * ADR-857 phase 5f-2: hook-events dialect is driven from the registry descriptor. - * - * Before this change, postToolEvent and preToolEvent were hardcoded strings - * derived from runtime-name checks: - * - * (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse' - * (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse' - * - * After phase 5f-2, both are driven by the registry descriptor's - * `hookEvents` field: hookEvents === 'gemini' → AfterTool/BeforeTool; - * any other value (or missing) → PostToolUse/PreToolUse. - * - * Equivalence (i.e. identical observable behaviour for all runtimes): - * hookEvents === 'gemini' iff runtime ∈ {gemini, antigravity} - * - * This suite asserts the equivalence and the registry-parity invariant: - * any runtime whose descriptor carries hookEvents='gemini' gets the - * AfterTool/BeforeTool dialect; all others get PostToolUse/PreToolUse. - */ - -const { test, describe, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { ensureHooksDist } = require('./helpers/hooks-dist.cjs'); - -const { install } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// ─── hooks/dist build guard ─────────────────────────────────────────────────── -// -// hooks/dist/ is gitignored and only produced by `npm run build:hooks`. -// In CI the scoped/windows test jobs do NOT run build:hooks before running -// tests, so install() finds no hook files → event arrays come back empty → -// every "expected AfterTool/PostToolUse/BeforeTool/PreToolUse hooks" assertion -// fails. This mirrors the pattern in bug-376-claude-js-hook-gsd-rewriter.test.cjs. - -before(() => { - ensureHooksDist(); -}); - -// ─── Registry lookup ────────────────────────────────────────────────────────── - -const REGISTRY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); -const registry = (() => { - try { return require(REGISTRY_PATH); } catch { return undefined; } -})(); - -/** - * Return the hookEvents dialect for a runtime ID from the live registry. - * Returns undefined when the registry is absent or the runtime has no descriptor. - */ -function registryHookEvents(runtimeId) { - return registry?.runtimes?.[runtimeId]?.runtime?.hookEvents; -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** Collect all hook commands registered under a settings event key. */ -function hooksForEvent(settings, eventName) { - if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; - return settings.hooks[eventName].flatMap(entry => - (entry && Array.isArray(entry.hooks) ? entry.hooks : []) - .map(h => h && h.command) - .filter(Boolean) - ); -} - -/** True if at least one hook is registered under eventName. */ -function hasHooksFor(settings, eventName) { - return hooksForEvent(settings, eventName).length > 0; -} - -// ─── Suite 1: Gemini-dialect runtimes use AfterTool/BeforeTool ─────────────── -// -// Registry runtimes with hookEvents='gemini': gemini, antigravity - -describe('enh-1077 phase 5f-2: gemini hookEvents dialect → AfterTool/BeforeTool', () => { - // #1928: the gemini runtime was removed (Google sunset Gemini CLI - // 2026-06-18). antigravity — the Gemini-backend successor — is the only - // remaining runtime whose descriptor carries hookEvents='gemini'. - - describe('antigravity install uses AfterTool/BeforeTool (gemini dialect)', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1077-antigrav-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const agDir = path.join(tmpDir, '.gemini', 'antigravity'); - fs.mkdirSync(agDir, { recursive: true }); - const result = install(false, 'antigravity'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('registry confirms antigravity hookEvents is "gemini"', () => { - const he = registryHookEvents('antigravity'); - if (he !== undefined) { - assert.strictEqual(he, 'gemini', - 'Registry descriptor for antigravity must declare hookEvents="gemini"'); - } - }); - - test('antigravity install returns a settings object', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'antigravity install must return a non-null settings object'); - }); - - test('antigravity install registers at least one hook under AfterTool', () => { - assert.ok(hasHooksFor(settings, 'AfterTool'), - `Expected AfterTool hooks on antigravity; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('antigravity install does NOT register context-monitor under PostToolUse', () => { - const cmds = hooksForEvent(settings, 'PostToolUse'); - const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); - assert.strictEqual(hasMonitor, false, - `antigravity must NOT use PostToolUse for context-monitor; got: ${JSON.stringify(cmds)}`); - }); - - test('antigravity install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { - const cmds = hooksForEvent(settings, 'BeforeTool'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.ok(hasPromptGuard, - `Expected prompt-guard hook under BeforeTool on antigravity; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('antigravity install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { - const cmds = hooksForEvent(settings, 'PreToolUse'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.strictEqual(hasPromptGuard, false, - `antigravity must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); - }); - }); -}); - -// ─── Suite 2: Claude-dialect runtimes use PostToolUse/PreToolUse ────────────── -// -// Registry runtimes with hookEvents='claude': claude, augment - -describe('enh-1077 phase 5f-2: claude hookEvents dialect → PostToolUse/PreToolUse', () => { - // ── claude ── - - describe('claude install uses PostToolUse for post-tool hooks', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1077-claude-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const claudeDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const result = install(false, 'claude'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('registry confirms claude hookEvents is "claude"', () => { - const he = registryHookEvents('claude'); - if (he !== undefined) { - assert.strictEqual(he, 'claude', - 'Registry descriptor for claude must declare hookEvents="claude"'); - } - }); - - test('claude install returns a settings object', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'claude install must return a non-null settings object'); - }); - - test('claude install registers at least one hook under PostToolUse', () => { - assert.ok(hasHooksFor(settings, 'PostToolUse'), - `Expected PostToolUse hooks on claude; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('claude install does NOT register context-monitor under AfterTool (wrong dialect)', () => { - const cmds = hooksForEvent(settings, 'AfterTool'); - const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); - assert.strictEqual(hasMonitor, false, - `claude must NOT use AfterTool for context-monitor; got AfterTool commands: ${JSON.stringify(cmds)}`); - }); - - test('claude install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { - const cmds = hooksForEvent(settings, 'PreToolUse'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.ok(hasPromptGuard, - `Expected prompt-guard hook under PreToolUse on claude; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('claude install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { - const cmds = hooksForEvent(settings, 'BeforeTool'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.strictEqual(hasPromptGuard, false, - `claude must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); - }); - }); - - // ── augment ── - - describe('augment install uses PostToolUse/PreToolUse (claude dialect)', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1077-augment-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const augDir = path.join(tmpDir, '.augment'); - fs.mkdirSync(augDir, { recursive: true }); - const result = install(false, 'augment'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('registry confirms augment hookEvents is "claude"', () => { - const he = registryHookEvents('augment'); - if (he !== undefined) { - assert.strictEqual(he, 'claude', - 'Registry descriptor for augment must declare hookEvents="claude"'); - } - }); - - test('augment install returns a settings object', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'augment install must return a non-null settings object'); - }); - - test('augment install registers at least one hook under PostToolUse', () => { - assert.ok(hasHooksFor(settings, 'PostToolUse'), - `Expected PostToolUse hooks on augment; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('augment install does NOT register context-monitor under AfterTool', () => { - const cmds = hooksForEvent(settings, 'AfterTool'); - const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); - assert.strictEqual(hasMonitor, false, - `augment must NOT use AfterTool for context-monitor; got: ${JSON.stringify(cmds)}`); - }); - - test('augment install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { - const cmds = hooksForEvent(settings, 'PreToolUse'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.ok(hasPromptGuard, - `Expected prompt-guard hook under PreToolUse on augment; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('augment install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { - const cmds = hooksForEvent(settings, 'BeforeTool'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.strictEqual(hasPromptGuard, false, - `augment must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); - }); - }); -}); - -// ─── Suite 3: Registry-parity invariant ────────────────────────────────────── -// -// For every runtime in the registry that exposes a settings.json surface -// (i.e. hookEvents is defined), assert that the installed hook dialect matches -// the registry value. This is the generative-fix parity assertion -// (DEFECT.GENERATIVE-FIX): adding a new runtime with hookEvents to the -// registry automatically requires a passing install test for that runtime. - -describe('enh-1077 phase 5f-2: registry-parity — hookEvents descriptor drives install dialect', () => { - test('all registry runtimes with hookEvents use the matching install dialect', () => { - if (!registry || !registry.runtimes) { - // Registry absent — skip parity check (equivalence still verified above) - return; - } - - // Runtimes that have settings.json surfaces and a hookEvents descriptor - const SETTINGS_JSON_RUNTIMES = ['claude', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy']; - - const failures = []; - - for (const runtimeId of SETTINGS_JSON_RUNTIMES) { - const he = registryHookEvents(runtimeId); - if (he === undefined) continue; // no hookEvents in descriptor — skip - - const expectedPostEvent = he === 'gemini' ? 'AfterTool' : 'PostToolUse'; - const unexpectedPostEvent = he === 'gemini' ? 'PostToolUse' : 'AfterTool'; - const expectedPreEvent = he === 'gemini' ? 'BeforeTool' : 'PreToolUse'; - const unexpectedPreEvent = he === 'gemini' ? 'PreToolUse' : 'BeforeTool'; - - const previousCwd = process.cwd(); - const tmpDir = createTempDir(`gsd-1077-parity-${runtimeId}-`); - try { - process.chdir(tmpDir); - const result = install(false, runtimeId); - const settings = result && result.settings; - if (!settings) continue; // non-settings-json surface, skip - - // Post-tool event assertions - const hasExpected = hasHooksFor(settings, expectedPostEvent); - const hasUnexpected = hooksForEvent(settings, unexpectedPostEvent) - .some(c => c && c.includes('gsd-context-monitor')); - - if (!hasExpected) { - failures.push(`${runtimeId}: expected context-monitor hook under ${expectedPostEvent} (hookEvents=${he}), but none found`); - } - if (hasUnexpected) { - failures.push(`${runtimeId}: must NOT register context-monitor under ${unexpectedPostEvent}, but it was found`); - } - - // Pre-tool event assertions: prompt-guard must land under the dialect-correct key. - const preToolCmdsExpected = hooksForEvent(settings, expectedPreEvent); - const hasPromptGuardExpected = preToolCmdsExpected.some(c => c && c.includes('gsd-prompt-guard')); - const preToolCmdsUnexpected = hooksForEvent(settings, unexpectedPreEvent); - const hasPromptGuardUnexpected = preToolCmdsUnexpected.some(c => c && c.includes('gsd-prompt-guard')); - - if (!hasPromptGuardExpected) { - failures.push(`${runtimeId}: expected prompt-guard hook under ${expectedPreEvent} (hookEvents=${he}), but none found; ${expectedPreEvent} cmds: ${JSON.stringify(preToolCmdsExpected)}`); - } - if (hasPromptGuardUnexpected) { - failures.push(`${runtimeId}: must NOT register prompt-guard under ${unexpectedPreEvent} (hookEvents=${he}), but it was found`); - } - } finally { - process.chdir(previousCwd); - cleanup(tmpDir); - } - } - - assert.deepEqual(failures, [], - 'Registry-parity failures (hookEvents descriptor must drive install dialect):\n' + - failures.join('\n')); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-788-qwen-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-788-qwen-hook-events (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Enhancement #788: Expand Qwen Code hook-event coverage. - * - * Qwen Code supports 15 hook events; gsd previously registered only - * SessionStart and PostToolUse. This suite asserts that a Qwen install - * registers the 3 new high-value events: - * - SubagentStop — subagent lifecycle finalisation (context tracking) - * - Stop — model stop / final-response hook (context tracking) - * - PreCompact — pre-compaction awareness (context tracking) - * - * All three are wired to gsd-context-monitor.js — the same hook used for - * PostToolUse — so context headroom warnings surface at these moments too. - * - * Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless - * tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for - * the UserPromptSubmit payload. Deferred to a follow-on issue. - * - * Also asserts the inverse: Claude Code installs do NOT gain these events - * (strict isQwen scope guard). - * - * Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/ - */ - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { install, uninstall, validateHookFields } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** Extract all hook commands registered under `eventName` from settings. */ -function hooksForEvent(settings, eventName) { - if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; - return settings.hooks[eventName].flatMap(entry => - (entry && Array.isArray(entry.hooks) ? entry.hooks : []) - .map(h => h && h.command) - .filter(Boolean) - ); -} - -// Stub JS hook files that the installer checks with fs.existsSync() so hook -// registration guards pass even when hooks/dist/ isn't built. -const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); -const STUB_HOOKS = [ - 'gsd-context-monitor.js', - 'gsd-prompt-guard.js', - 'gsd-check-update.js', - 'gsd-config-reload.js', // Added in #770 -]; - -function stubHooksIntoTarget(targetDir) { - const hooksDest = path.join(targetDir, 'hooks'); - fs.mkdirSync(hooksDest, { recursive: true }); - for (const hookFile of STUB_HOOKS) { - const src = path.join(HOOKS_SRC, hookFile); - const dest = path.join(hooksDest, hookFile); - if (fs.existsSync(src)) { - fs.copyFileSync(src, dest); - } else { - // Minimal stub so existsSync passes - fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); - } - try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } - } -} - -/** - * Persist in-memory settings to disk, simulating what finishInstall() does - * (finishInstall is not exported). Required for tests that call install() - * twice and need the second call to read the first call's hook registrations. - */ -function persistSettings(settingsPath, settings) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); -} - -// ─── Suite 1: Qwen — new events are registered ─────────────────────────────── - -describe('enh-788: Qwen install registers 3 new hook events', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-788-qwen-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const targetDir = path.join(tmpDir, '.qwen'); - fs.mkdirSync(targetDir, { recursive: true }); - // Pre-populate hook files so installer registration guards (fs.existsSync) - // pass and hooks are actually registered in settings.json. - stubHooksIntoTarget(targetDir); - - const result = install(false, 'qwen'); - settings = result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('install returns a settings object (not null)', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'Qwen install must return a non-null settings object'); - }); - - test('SubagentStop event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'SubagentStop'); - assert.ok(cmds.length > 0, - `Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('Stop event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'Stop'); - assert.ok(cmds.length > 0, - `Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('PreCompact event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'PreCompact'); - assert.ok(cmds.length > 0, - `Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => { - // gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op - // for UserPromptSubmit payloads. Registration is deferred until a - // dedicated hook can process the user-prompt payload shape. - const cmds = hooksForEvent(settings, 'UserPromptSubmit'); - assert.strictEqual(cmds.length, 0, - `UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`); - }); - - test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => { - for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { - const cmds = hooksForEvent(settings, event); - assert.ok( - cmds.some(c => c.includes('gsd-context-monitor')), - `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` - ); - } - }); - - test('FileChanged is NOT registered for Qwen (Claude-only event)', () => { - // gsd-config-reload / FileChanged is a Claude Code-only registration. - // Qwen does not support the FileChanged hook event at all. - const cmds = hooksForEvent(settings, 'FileChanged'); - assert.strictEqual(cmds.length, 0, - `FileChanged should NOT be registered for Qwen; got: ${JSON.stringify(cmds)}`); - }); -}); - -// ─── Suite 2: Claude install DOES get the context events (since #770) ─────── -// Note: Prior to #770, these were Qwen-only events. #770 extended them to -// Claude Code. This suite is updated to match the new expected behavior. - -describe('enh-788 (updated by #770): Claude install registers context lifecycle events', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-788-claude-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - stubHooksIntoTarget(path.join(tmpDir, '.claude')); - - const result = install(false, 'claude', { installerMigrations: [] }); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('Claude install registers SubagentStop (since #770)', () => { - const cmds = hooksForEvent(settings, 'SubagentStop'); - assert.ok(cmds.length > 0, - `Claude should have SubagentStop since #770; got: ${JSON.stringify(cmds)}`); - }); - - test('Claude install registers Stop (since #770)', () => { - const cmds = hooksForEvent(settings, 'Stop'); - assert.ok(cmds.length > 0, - `Claude should have Stop since #770; got: ${JSON.stringify(cmds)}`); - }); - - test('Claude install registers PreCompact (since #770)', () => { - const cmds = hooksForEvent(settings, 'PreCompact'); - assert.ok(cmds.length > 0, - `Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`); - }); -}); - -// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── - -describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-788-idem-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const targetDir = path.join(tmpDir, '.qwen'); - fs.mkdirSync(targetDir, { recursive: true }); - stubHooksIntoTarget(targetDir); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('re-running after persisted first install does not duplicate hook entries', () => { - // First install: get settings and persist to disk (simulating finishInstall) - const result1 = install(false, 'qwen'); - persistSettings(result1.settingsPath, result1.settings); - - // Second install: reads the persisted settings.json — dedup guards apply - process.chdir(tmpDir); - const result2 = install(false, 'qwen'); - const s2 = result2.settings; - - for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { - const cmds = hooksForEvent(s2, event); - assert.strictEqual(cmds.length, 1, - `Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); - } - }); -}); - -// ─── Suite 4: Uninstall removes the new event registrations ────────────────── - -describe('enh-788: Qwen uninstall removes new hook event entries', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-788-uninstall-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const targetDir = path.join(tmpDir, '.qwen'); - fs.mkdirSync(targetDir, { recursive: true }); - stubHooksIntoTarget(targetDir); - - // Install and persist to disk so uninstall has a settings.json to clean - const result = install(false, 'qwen'); - persistSettings(result.settingsPath, result.settings); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('settings.json hook entries are removed on uninstall', () => { - uninstall(false, 'qwen'); - const settingsPath = path.join(tmpDir, '.qwen', 'settings.json'); - if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { - const cmds = hooksForEvent(settings, event); - assert.strictEqual(cmds.length, 0, - `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); - } - }); -}); - }); -} - - // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-1834-sh-hooks-installed.test.cjs — consolidation epic #1969 (B6 #1975) // ──────────────────────────────────────────────────────────────────────── diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index d44f69855..bcf61f381 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -1751,574 +1751,6 @@ describe('N3: Windows-separator confinement logic (path.win32 semantics)', () => }); } -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2998-pristine-dir-populated.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2998-pristine-dir-populated (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2998: gsd-pristine/ snapshot is documented but never populated by - * the installer. saveLocalPatches declared a pristineDir variable and - * promised "saves pristine copies (from manifest) to gsd-pristine/ to - * enable three-way merge during reapply-patches" -- but no code ever - * wrote to that directory. Effect: the /gsd-reapply-patches Step 5 - * verifier (#2972) silently degrades to its over-broad fallback heuristic - * ("every significant backup line"), exactly the silent-success-on-lost- - * content failure mode #2969 was designed to prevent. - * - * Fix: new populatePristineDir({...}) helper runs the install transform - * pipeline (copyWithPathReplacement) into a tmp staging dir, then copies - * out the modified-file paths into gsd-pristine/. saveLocalPatches now - * accepts a pristineCtx and calls the helper when local patches are - * detected. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const crypto = require('node:crypto'); - -const ROOT = path.join(__dirname, '..'); -const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); -const { cleanup } = require('./helpers.cjs'); - -function sha256(content) { - return crypto.createHash('sha256').update(content).digest('hex'); -} - -describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => { - test('exported as a function', () => { - assert.equal(typeof INSTALL.populatePristineDir, 'function', - 'expected populatePristineDir in install.js exports (#2998)'); - }); - - test('returns 0 when no files are modified (no-op)', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); - try { - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir: path.join(tmp, 'gsd-pristine'), - modified: [], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 0); - } finally { - cleanup(tmp); - } - }); - - test('writes one pristine file per modified path that exists in source', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); - const pristineDir = path.join(tmp, 'gsd-pristine'); - try { - // Pick a real installed-side relPath from the package source. The - // install transforms map source `gsd-core/` to installed - // `gsd-core/` for skills-aware runtimes (like claude), - // so the relPath is the same on both sides. - const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md'); - const sourcePath = path.join(ROOT, candidate); - assert.equal(fs.existsSync(sourcePath), true, - `precondition: source file exists at ${candidate}`); - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir, - modified: [candidate], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 1, 'expected exactly one pristine file written'); - const out = path.join(pristineDir, candidate); - assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`); - // The pristine content should be the transformed version (not raw source): - // copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix. - // For claude+global, the prefix is $HOME/.claude/ which equals the original, - // so the transform is effectively identity here. We assert the content is a - // non-empty markdown file rather than asserting on transform specifics. - const content = fs.readFileSync(out, 'utf-8'); - assert.ok(content.length > 0, 'pristine file should be non-empty'); - } finally { - cleanup(tmp); - } - }); - - test('skips paths not present in source (does not corrupt pristine with stale data)', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); - const pristineDir = path.join(tmp, 'gsd-pristine'); - try { - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir, - modified: ['gsd-core/this-path-does-not-exist.md'], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 0, 'expected zero pristine files for non-existent source paths'); - const out = path.join(pristineDir, 'gsd-core/this-path-does-not-exist.md'); - assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths'); - } finally { - cleanup(tmp); - } - }); - - test('pristine files have stable content (transformations are deterministic)', () => { - // Determinism is what makes the verifier's hash check meaningful: - // backup-meta.json records pristine_hashes computed at this same step, - // so re-running with the same inputs must yield byte-identical files. - const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-')); - const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-')); - try { - const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md'); - const ctx = { - packageSrc: ROOT, - modified: [candidate], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }; - INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx)); - INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx)); - const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate)); - const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate)); - assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content'); - } finally { - cleanup(tmp1); - cleanup(tmp2); - } - }); -}); - -// ─── #3004 CR follow-up: multi-root pristine expansion ───────────────────── - -describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => { - test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-')); - const pristineDir = path.join(tmp, 'gsd-pristine'); - try { - const candidate = path.join('agents', 'gsd-planner.md'); - const sourcePath = path.join(ROOT, candidate); - assert.equal(fs.existsSync(sourcePath), true, - `precondition: source file exists at ${candidate}`); - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir, - modified: [candidate], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine'); - assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true); - } finally { - cleanup(tmp); - } - }); - - test('a mix of gsd-core/ and agents/ paths in modified list are all staged', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-')); - const pristineDir = path.join(tmp, 'gsd-pristine'); - try { - const a = path.join('gsd-core', 'workflows', 'reapply-patches.md'); - const b = path.join('agents', 'gsd-planner.md'); - assert.equal(fs.existsSync(path.join(ROOT, a)), true); - assert.equal(fs.existsSync(path.join(ROOT, b)), true); - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir, - modified: [a, b], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 2, 'expected both top-level dirs to be staged'); - assert.equal(fs.existsSync(path.join(pristineDir, a)), true); - assert.equal(fs.existsSync(path.join(pristineDir, b)), true); - } finally { - cleanup(tmp); - } - }); -}); - -describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => { - test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => { - // Structural assertion: the function exists with the new signature shape. - // Behavioral end-to-end is covered by the populatePristineDir tests above - // (that helper is what saveLocalPatches calls internally). - assert.equal(typeof INSTALL.populatePristineDir, 'function'); - // The signature for saveLocalPatches isn't exported, but the helper IS, - // and it's the unit of behavior the bug is about. Asserting on the helper - // is the structural-IR equivalent of the no-source-grep convention. - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3407-pristine-stale-content.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3407-pristine-stale-content (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #3407: Installer leaves stale content in gsd-pristine/ - * - * Root cause: populatePristineDir() in saveLocalPatches() snapshots from - * pristineCtx.packageSrc — the NEWLY-downloaded release tree — and writes - * those bytes into gsd-pristine/. For files changed between the old and new - * release, this writes the NEW bytes into the pristine baseline instead of - * the OLD bytes. The three-way-diff verifier then classifies upstream-changed - * lines as user-added → Step 5a gate fails with false FAIL_USER_LINES_MISSING. - * - * The #3657 fix (OK_PRISTINE_DRIFT_DETECTED) was a symptom workaround: the - * verifier detects hash mismatch (backup-meta.json records old-release hash - * but gsd-pristine/ has new-release bytes) and skips to over-broad mode - * instead of false-failing. The root-cause stale write was never fixed. - * - * Fix: when a correctly-populated gsd-pristine/ already exists from the - * previous install (i.e., the file's sha256 matches the originalHash recorded - * in the manifest), preserve it — do NOT wipe and re-populate from the new - * release source. This ensures gsd-pristine/ holds old-release bytes even - * after an upgrade where the file content changed upstream. - * - * Regression contract (byte-comparison): - * After saveLocalPatches() is called with a user-modified file whose - * gsd-pristine/ entry was correctly set by the previous install, the - * gsd-pristine/ file MUST still contain the old-release bytes, not the - * new-release bytes supplied in pristineCtx.packageSrc. - * - * Closes: #3407 - */ - -const { test, describe, beforeEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const crypto = require('node:crypto'); - -const ROOT = path.join(__dirname, '..'); -const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); -const { cleanup } = require('./helpers.cjs'); - -const MANIFEST_NAME = 'gsd-file-manifest.json'; -const PATCHES_DIR_NAME = 'gsd-local-patches'; - -function sha256(content) { - return crypto.createHash('sha256').update(content instanceof Buffer ? content : Buffer.from(content)).digest('hex'); -} - -// ─── Bug #3407: gsd-pristine/ must preserve OLD-release bytes across upgrade ── - -describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgrade', () => { - let tmpDir; - let configDir; - let fakeSrcDir; - - beforeEach((t) => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3407-')); - configDir = path.join(tmpDir, 'config'); - fakeSrcDir = path.join(tmpDir, 'new-release-src'); - fs.mkdirSync(configDir, { recursive: true }); - fs.mkdirSync(fakeSrcDir, { recursive: true }); - t.after(() => { - cleanup(tmpDir); - }); - }); - - /** - * Core regression test. - * - * Timeline: - * Install v1: file content = OLD_RELEASE_CONTENT, gsd-pristine/ROOT_FILE - * = OLD_RELEASE_CONTENT (correctly set by previous install), - * manifest hash = sha256(OLD_RELEASE_CONTENT) - * User edits: configDir/ROOT_FILE = USER_MODIFIED_CONTENT - * Upgrade v2: pristineCtx.packageSrc has NEW_RELEASE_CONTENT for ROOT_FILE - * saveLocalPatches is called before the wipe. - * - * Expected AFTER fix: gsd-pristine/ROOT_FILE still == OLD_RELEASE_CONTENT - * Actual BEFORE fix: gsd-pristine/ROOT_FILE == NEW_RELEASE_CONTENT (stale) - */ - test('gsd-pristine/ retains old-release bytes when upgrading a user-modified file', () => { - const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1 pristine.\n'; - const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — upstream changed this line.\n'; - const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1 pristine.\n## User addition\nUser customization here.\n'; - - const oldHash = sha256(OLD_RELEASE_CONTENT); - - // Simulate a root-level installed file. Root-level files in the manifest - // are denoted without a subdirectory (slash-free relPath). - const relPath = 'test-root-file.md'; - - // Set up configDir: user-modified installed file + manifest recording old hash - fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); - fs.writeFileSync( - path.join(configDir, MANIFEST_NAME), - JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) - ); - - // Set up fakeSrcDir (new release): the file has NEW content - fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); - - // Set up gsd-pristine/ with OLD content (as correctly populated by previous install) - const pristineDir = path.join(configDir, 'gsd-pristine'); - fs.mkdirSync(pristineDir, { recursive: true }); - fs.writeFileSync(path.join(pristineDir, relPath), OLD_RELEASE_CONTENT); - - // Call saveLocalPatches with the new release as packageSrc (the buggy scenario) - INSTALL.saveLocalPatches(configDir, { - packageSrc: fakeSrcDir, - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - - // Assert: gsd-pristine/ must still contain OLD-release bytes - const pristineFile = path.join(pristineDir, relPath); - assert.ok( - fs.existsSync(pristineFile), - `gsd-pristine/${relPath} must exist after saveLocalPatches` - ); - - const actualPristineContent = fs.readFileSync(pristineFile, 'utf8'); - assert.equal( - sha256(actualPristineContent), - oldHash, - [ - `gsd-pristine/${relPath} must contain OLD-release bytes (sha256=${oldHash.slice(0, 12)}…)`, - `but got sha256=${sha256(actualPristineContent).slice(0, 12)}…`, - `(If equal to sha256(NEW_RELEASE_CONTENT)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… then #3407 is NOT fixed)`, - ].join(' ') - ); - - // Secondary: confirm backup-meta records the old hash (not new) - const backupMeta = JSON.parse( - fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8') - ); - assert.ok( - Object.prototype.hasOwnProperty.call(backupMeta.pristine_hashes, relPath), - 'backup-meta.json must record pristine_hash for modified file' - ); - assert.equal( - backupMeta.pristine_hashes[relPath], - oldHash, - 'backup-meta.json pristine_hash must equal old-release hash (not new-release hash)' - ); - }); - - /** - * Regression test for Codex finding: when gsd-pristine/ entry is absent - * (e.g., post-buggy-run deletion or first upgrade without prior pristine) - * but the file is UNCHANGED between old and new release, the hash-validated - * regeneration path must restore the pristine entry using new-release source. - * - * When sha256(newReleaseBytesForFile) === originalHash, the file is identical - * between releases — new-release generated bytes ARE the old-release pristine - * and may be safely promoted. - * - * Previously (before the regeneration path was added): missing entries were - * left absent unconditionally, causing permanent over-broad fallback even - * when the file was unchanged upstream. - */ - test('gsd-pristine/ is regenerated for missing entries when file is unchanged between releases', () => { - const SHARED_RELEASE_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n'; - const USER_MODIFIED_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n## User addition\nCustom.\n'; - - const oldHash = sha256(SHARED_RELEASE_CONTENT); - const relPath = 'test-unchanged-file.md'; - - // configDir has user-modified file + manifest with old-release hash - fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); - fs.writeFileSync( - path.join(configDir, MANIFEST_NAME), - JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) - ); - - // fakeSrcDir (new release) has the SAME content — file was not changed upstream - fs.writeFileSync(path.join(fakeSrcDir, relPath), SHARED_RELEASE_CONTENT); - - // NOTE: gsd-pristine/ does NOT exist (simulating post-buggy-run or first-time scenario) - - INSTALL.saveLocalPatches(configDir, { - packageSrc: fakeSrcDir, - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - - // The regeneration path should have detected that sha256(new-release candidate) - // === originalHash, and promoted the candidate into gsd-pristine/. - const pristineFile = path.join(configDir, 'gsd-pristine', relPath); - assert.ok( - fs.existsSync(pristineFile), - [ - `gsd-pristine/${relPath} must exist after hash-validated regeneration.`, - `When new-release bytes hash to originalHash, the file was unchanged between`, - `releases and the candidate should be promoted to restore the pristine baseline.`, - ].join(' ') - ); - - const actualContent = fs.readFileSync(pristineFile, 'utf8'); - assert.equal( - sha256(actualContent), - oldHash, - [ - `gsd-pristine/${relPath} must contain bytes matching originalHash after regeneration`, - `(sha256=${oldHash.slice(0, 12)}…)`, - ].join(' ') - ); - }); - - /** - * Stale-pristine recovery test (pre-fix bug artifact). - * - * Timeline: - * Buggy run: gsd-pristine/ was written with NEW_RELEASE_CONTENT - * (the exact #3407 artifact — stale bytes from a buggy populatePristineDir). - * Fix run: saveLocalPatches detects the hash mismatch - * (sha256(NEW_RELEASE_CONTENT) !== originalHash recorded in manifest), - * removes the stale entry, then attempts regeneration. - * - * When the file CHANGED between releases (NEW !== OLD): - * - The stale entry is removed. - * - Regeneration discards the new-release candidate (hash mismatch). - * - gsd-pristine/ must be ABSENT (over-broad fallback — correct). - * - * When the file is UNCHANGED between releases (NEW === OLD): - * - The stale entry (which happens to have correct bytes despite the bug) is - * detected as correct (hash matches originalHash) and PRESERVED. - * - gsd-pristine/ must remain present with the correct bytes. - * - * This test covers the "file changed across release boundary" case. - * The "unchanged" case is already covered by the regeneration test above. - */ - test('stale gsd-pristine/ entry (new-release bytes) is removed when file changed between releases', () => { - const OLD_RELEASE_CONTENT = '# Old Release\nv1 content here.\n'; - const NEW_RELEASE_CONTENT = '# New Release\nv2 content — upstream changed this.\n'; - const USER_MODIFIED_CONTENT = '# Old Release\nv1 content here.\n## User section\nCustom work.\n'; - - const oldHash = sha256(OLD_RELEASE_CONTENT); - const relPath = 'test-stale-recovery.md'; - - // configDir: user-modified file + manifest recording OLD hash - fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); - fs.writeFileSync( - path.join(configDir, MANIFEST_NAME), - JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) - ); - - // fakeSrcDir (new release): contains the NEW content - fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); - - // Pre-populate gsd-pristine/ with NEW_RELEASE_CONTENT — the exact pre-fix bug artifact. - // This simulates a prior buggy run that wrote new-release bytes into the pristine baseline. - const STALE_BYTES = NEW_RELEASE_CONTENT; // named constant for clarity - const pristineDir = path.join(configDir, 'gsd-pristine'); - fs.mkdirSync(pristineDir, { recursive: true }); - fs.writeFileSync(path.join(pristineDir, relPath), STALE_BYTES); - - // Verify the pre-condition: stale bytes do NOT match the original hash. - // If this assert fails, the test fixture is wrong (not a fix regression). - assert.notEqual( - sha256(STALE_BYTES), - oldHash, - 'test fixture check: stale bytes must differ from originalHash' - ); - - INSTALL.saveLocalPatches(configDir, { - packageSrc: fakeSrcDir, - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - - // The fix must detect the hash mismatch (stale entry) and remove it. - // The regeneration path discards the new-release candidate (its hash !== oldHash). - // Result: gsd-pristine/ must be ABSENT — over-broad fallback is the safe outcome. - const pristineFile = path.join(pristineDir, relPath); - assert.strictEqual( - fs.existsSync(pristineFile), - false, - [ - `expected gsd-pristine/${relPath} to be absent after stale-pristine recovery.`, - `The stale entry (new-release bytes, sha256=${sha256(STALE_BYTES).slice(0, 12)}…)`, - `must be removed; regeneration must discard the candidate because`, - `sha256(new-release)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… !== originalHash=${oldHash.slice(0, 12)}….`, - `Presence of the file means the stale bytes were NOT cleaned up (pre-fix behavior).`, - ].join(' ') - ); - }); - - /** - * Second scenario: gsd-pristine/ does NOT pre-exist (first upgrade with no - * prior pristine population). In this case there is no way to obtain the - * old-release pristine bytes — populatePristineDir must NOT write the new- - * release bytes either. The correct outcome is: gsd-pristine/ stays empty - * for this file, and the verifier falls back to over-broad mode (safe). - */ - test('gsd-pristine/ stays empty when no prior pristine exists (first upgrade, no stale write)', () => { - const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1.\n'; - const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — changed.\n'; - const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1.\n## User addition\nCustom.\n'; - - const oldHash = sha256(OLD_RELEASE_CONTENT); - const relPath = 'test-first-upgrade.md'; - - // configDir has user-modified file + manifest - fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); - fs.writeFileSync( - path.join(configDir, MANIFEST_NAME), - JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) - ); - - // fakeSrcDir (new release) has new content - fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); - - // NOTE: gsd-pristine/ does NOT exist yet (first upgrade) - - INSTALL.saveLocalPatches(configDir, { - packageSrc: fakeSrcDir, - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - - const pristineFile = path.join(configDir, 'gsd-pristine', relPath); - assert.strictEqual( - fs.existsSync(pristineFile), - false, - [ - `expected gsd-pristine/${relPath} to be absent when file changed across release boundary.`, - `Writing new-release bytes as pristine for a file whose hash is unknown leads to`, - `false FAIL_USER_LINES_MISSING in the reapply-patches verifier (#3407).`, - `Over-broad fallback mode is the correct outcome here.`, - ].join(' ') - ); - }); -}); - -// The former "Antipattern hunt" describe block (structural typeof checks only) was -// removed — it provided no real behavioral coverage and was a vacuous-truth pattern -// per /test-rigor skill. Behavioral tests for populatePristineDir are covered above. - }); -} - - // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2995-post-install-script-paths.test.cjs — consolidation epic #1969 (B6 #1975) // ──────────────────────────────────────────────────────────────────────── diff --git a/tests/install.test.cjs b/tests/install.test.cjs index ac203bc8f..f6f2b891d 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -5978,3963 +5978,6 @@ test('install.js tier-defaults object has exactly the same keys as manifest effo }); } -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2256-model-overrides-transport.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2256-model-overrides-transport (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression tests for issue #2256 — per-agent model_overrides transport - * for Codex and OpenCode runtimes. - * - * The bug: model_overrides set in per-project `.planning/config.json` were - * never read by the Codex / OpenCode install paths, which only probed - * `~/.gsd/defaults.json`. As a result, the configured per-agent model was - * dropped and child agents inherited the runtime's default model. - * - * These tests lock in the fix: per-project overrides must be honored, and - * per-project keys must win over global when both are present. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const isWindows = process.platform === 'win32'; - -const { - readGsdEffectiveModelOverrides, - generateCodexAgentToml, - convertClaudeToOpencodeFrontmatter, - getCodexSkillAdapterHeader, -} = require('../bin/install.js'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); -const makeTmp = (prefix) => createTempDir(`gsd-2256-${prefix}-`); - -function writeJson(p, obj) { - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(obj, null, 2)); -} - -describe('bug #2256 — readGsdEffectiveModelOverrides', () => { - let projectDir; - let homeDir; - let origHome; - let origUserProfile; - - beforeEach(() => { - projectDir = makeTmp('proj'); - homeDir = makeTmp('home'); - origHome = process.env.HOME; - // On Windows, os.homedir() reads USERPROFILE (not HOME). Tests that - // need to redirect ~ must override both — otherwise the SUT reads - // the real user's home and the fixture is invisible. - origUserProfile = process.env.USERPROFILE; - process.env.HOME = homeDir; - if (isWindows) process.env.USERPROFILE = homeDir; - }); - - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - if (isWindows) { - if (origUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = origUserProfile; - } - cleanup(projectDir); - cleanup(homeDir); - }); - - test('returns null when neither source defines model_overrides', () => { - const result = readGsdEffectiveModelOverrides(projectDir); - assert.strictEqual(result, null); - }); - - test('reads overrides from ~/.gsd/defaults.json (global only)', () => { - writeJson(path.join(homeDir, '.gsd', 'defaults.json'), { - model_overrides: { 'gsd-codebase-mapper': 'gpt-5-mini' }, - }); - const result = readGsdEffectiveModelOverrides(projectDir); - assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'gpt-5-mini' }); - }); - - test('reads overrides from per-project .planning/config.json', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' }, - }); - const result = readGsdEffectiveModelOverrides(projectDir); - assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'claude-haiku-4-5' }); - }); - - test('per-project overrides win over global on conflict', () => { - writeJson(path.join(homeDir, '.gsd', 'defaults.json'), { - model_overrides: { 'gsd-codebase-mapper': 'global-model', 'gsd-planner': 'opus' }, - }); - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_overrides: { 'gsd-codebase-mapper': 'project-model' }, - }); - const result = readGsdEffectiveModelOverrides(projectDir); - // Per-project wins on conflict; non-conflicting global keys are preserved. - assert.deepStrictEqual(result, { - 'gsd-codebase-mapper': 'project-model', - 'gsd-planner': 'opus', - }); - }); - - test('walks up from nested targetDir to find .planning/', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_overrides: { 'gsd-planner': 'project-opus' }, - }); - const nested = path.join(projectDir, '.codex'); - fs.mkdirSync(nested, { recursive: true }); - const result = readGsdEffectiveModelOverrides(nested); - assert.deepStrictEqual(result, { 'gsd-planner': 'project-opus' }); - }); -}); - -describe('bug #2256 — Codex adapter embeds per-project override', () => { - const agentContent = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; - - test('generateCodexAgentToml embeds model when override provided', () => { - const toml = generateCodexAgentToml( - 'gsd-codebase-mapper', - agentContent, - { 'gsd-codebase-mapper': 'gpt-5-mini' }, - ); - assert.match(toml, /^model = "gpt-5-mini"$/m); - }); - - test('generateCodexAgentToml omits model when no override', () => { - const toml = generateCodexAgentToml('gsd-codebase-mapper', agentContent, null); - assert.doesNotMatch(toml, /^model\s*=/m); - }); -}); - -describe('bug #2256 — OpenCode adapter embeds per-project override', () => { - test('convertClaudeToOpencodeFrontmatter embeds model on agent frontmatter', () => { - const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; - const out = convertClaudeToOpencodeFrontmatter(input, { - isAgent: true, - modelOverride: 'claude-haiku-4-5', - }); - assert.match(out, /^model: claude-haiku-4-5$/m); - assert.match(out, /^mode: subagent$/m); - }); - - test('convertClaudeToOpencodeFrontmatter omits model when override absent', () => { - const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; - const out = convertClaudeToOpencodeFrontmatter(input, { isAgent: true, modelOverride: null }); - assert.doesNotMatch(out, /^model:/m); - }); -}); - -describe('bug #2256 — Codex skill adapter header documents transport', () => { - test('Task(model=...) line no longer says "omit" without explanation', () => { - const header = getCodexSkillAdapterHeader('gsd-plan-phase'); - // Header must mention that per-agent model_overrides are embedded in agent - // TOML so spawn_agent picks them up automatically — the old text said - // "Codex uses per-role config, not inline model selection" which left - // users thinking their model_overrides were silently ignored. - assert.match(header, /model_overrides/); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3181-node-cellar-path.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3181-node-cellar-path (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #3181: `resolveNodeRunner()` bakes versioned Homebrew Cellar paths - * (e.g. `/usr/local/Cellar/node/25.8.1/bin/node`) into hook commands in - * `~/.claude/settings.json`. After `brew upgrade node` the Cellar binary - * fails with `dyld: Library not loaded` because shared libraries have - * changed SOVERSION. - * - * Fix: prefer the stable Homebrew symlinks (`/usr/local/bin/node` for Intel - * Macs, `/opt/homebrew/bin/node` for Apple Silicon) when a Cellar path is - * detected. Non-Homebrew paths (NVM, system node, Windows, etc.) are - * returned unchanged. - * - * Also: `rewriteLegacyManagedNodeHookCommands()` must normalize Cellar paths - * baked into existing hook commands so reinstall doesn't re-bake them. - * - * All assertions go against exported function return values — no source-grep. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { normalizeNodePath, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands } = INSTALL; - -// ─── normalizeNodePath ──────────────────────────────────────────────────────── - -describe('Bug #3181: normalizeNodePath — exported as a function', () => { - test('normalizeNodePath is exported', () => { - assert.equal(typeof normalizeNodePath, 'function'); - }); -}); - -describe('Bug #3181: normalizeNodePath — Intel Homebrew Cellar paths → /usr/local/bin/node', () => { - test('simple versioned Intel Cellar path', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'); - assert.equal(result, '/usr/local/bin/node'); - }); - - test('Intel Cellar path with long semver', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/20.11.0/bin/node'); - assert.equal(result, '/usr/local/bin/node'); - }); - - test('Intel Cellar path with prerelease version segment', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/22.0.0-rc.1/bin/node'); - assert.equal(result, '/usr/local/bin/node'); - }); - - test('Intel versioned formula Cellar path (node@20) maps to stable symlink', () => { - const result = normalizeNodePath('/usr/local/Cellar/node@20/20.11.0/bin/node'); - assert.equal(result, '/usr/local/bin/node'); - }); -}); - -describe('Bug #3181: normalizeNodePath — Apple Silicon Homebrew Cellar paths → /opt/homebrew/bin/node', () => { - test('simple versioned Apple Silicon Cellar path', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'); - assert.equal(result, '/opt/homebrew/bin/node'); - }); - - test('Apple Silicon Cellar path with another version', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node/18.20.4/bin/node'); - assert.equal(result, '/opt/homebrew/bin/node'); - }); - - test('Apple Silicon versioned formula Cellar path (node@18) maps to stable symlink', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node@18/18.20.4/bin/node'); - assert.equal(result, '/opt/homebrew/bin/node'); - }); -}); - -// #2185: Linuxbrew + any custom HOMEBREW_PREFIX — the Cellar prefix is derived -// from the path itself, so one branch covers every Homebrew layout. -describe('Bug #2185: normalizeNodePath — Linuxbrew + custom-prefix Cellar paths → /bin/node', () => { - test('Linuxbrew Cellar path maps to the stable linuxbrew symlink', () => { - const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node/26.0.0/bin/node'); - assert.equal(result, '/home/linuxbrew/.linuxbrew/bin/node'); - }); - - test('Linuxbrew Cellar path after a version bump (26.5.0) maps to stable symlink', () => { - const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node/26.5.0/bin/node'); - assert.equal(result, '/home/linuxbrew/.linuxbrew/bin/node'); - }); - - test('Linuxbrew versioned formula Cellar path (node@22) maps to stable symlink', () => { - const result = normalizeNodePath('/home/linuxbrew/.linuxbrew/Cellar/node@22/22.11.0/bin/node'); - assert.equal(result, '/home/linuxbrew/.linuxbrew/bin/node'); - }); - - test('custom HOMEBREW_PREFIX Cellar path maps to its stable symlink', () => { - const result = normalizeNodePath('/custom/brew/Cellar/node/25.8.1/bin/node'); - assert.equal(result, '/custom/brew/bin/node'); - }); -}); - -describe('Bug #3181: normalizeNodePath — non-Homebrew paths are returned unchanged', () => { - test('NVM path is unchanged', () => { - const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; - assert.equal(normalizeNodePath(nvm), nvm); - }); - - test('already-stable Intel Homebrew symlink is unchanged', () => { - assert.equal(normalizeNodePath('/usr/local/bin/node'), '/usr/local/bin/node'); - }); - - test('already-stable Apple Silicon Homebrew symlink is unchanged', () => { - assert.equal(normalizeNodePath('/opt/homebrew/bin/node'), '/opt/homebrew/bin/node'); - }); - - test('system node (/usr/bin/node) is unchanged', () => { - assert.equal(normalizeNodePath('/usr/bin/node'), '/usr/bin/node'); - }); - - test('Windows path is unchanged', () => { - const win = 'C:\\Program Files\\nodejs\\node.exe'; - assert.equal(normalizeNodePath(win), win); - }); - - test('empty string is returned as-is', () => { - assert.equal(normalizeNodePath(''), ''); - }); - - test('null is returned as-is', () => { - assert.equal(normalizeNodePath(null), null); - }); -}); - -// ─── resolveNodeRunner ──────────────────────────────────────────────────────── - -describe('Bug #3181: resolveNodeRunner — maps Cellar execPath to stable symlink', () => { - test('Intel Cellar execPath → stable symlink quoted token', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { - value: '/usr/local/Cellar/node/25.8.1/bin/node', - configurable: true, - }); - const runner = resolveNodeRunner(); - assert.equal(runner, '"/usr/local/bin/node"', - `expected stable Intel symlink, got: ${runner}`); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); - - test('Apple Silicon Cellar execPath → stable symlink quoted token', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { - value: '/opt/homebrew/Cellar/node/25.8.1/bin/node', - configurable: true, - }); - const runner = resolveNodeRunner(); - assert.equal(runner, '"/opt/homebrew/bin/node"', - `expected stable Apple Silicon symlink, got: ${runner}`); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); - - test('non-Homebrew execPath is returned as a quoted absolute path unchanged', () => { - const orig = process.execPath; - const nvmPath = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; - try { - Object.defineProperty(process, 'execPath', { value: nvmPath, configurable: true }); - const runner = resolveNodeRunner(); - assert.equal(runner, JSON.stringify(nvmPath)); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); - - test('returns null when execPath is empty (existing null-guard is preserved)', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { value: '', configurable: true }); - assert.equal(resolveNodeRunner(), null); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); -}); - -// ─── rewriteLegacyManagedNodeHookCommands — Cellar runner rewrite ───────────── - -describe('Bug #3181: rewriteLegacyManagedNodeHookCommands — rewrites baked Cellar runner to stable symlink', () => { - test('Intel Cellar runner in a managed hook is rewritten to the stable symlink', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true, 'expected rewrite to occur'); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - ); - }); - - test('Apple Silicon Cellar runner in a managed hook is rewritten to the stable symlink', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: '"/opt/homebrew/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - }], - }], - }, - }; - const runner = '"/opt/homebrew/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true, 'expected rewrite to occur'); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/opt/homebrew/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - ); - }); - - test('a hook already using the stable runner is NOT rewritten (no churn)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false, 'already-stable entry must not be touched'); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('a user hook using a Cellar runner but an unmanaged filename is NOT rewritten', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/my-custom-hook.js"', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false, 'unmanaged hooks with Cellar runner must not be touched'); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - // Existing bare-node rewrite still works alongside the new Cellar rewrite - test('bare `node` managed hook is still rewritten (existing #2979 behaviour preserved)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-977-fnm-multishell-path.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-977-fnm-multishell-path (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #977: `resolveNodeRunner()` bakes an ephemeral fnm multishell shim path - * (e.g. `C:/Users/u/AppData/Local/fnm_multishells/_/node.exe`) into - * managed `.js` hook commands. fnm cleans up these per-shell-session directories - * when the shell exits, so the captured path later points at nothing — every - * managed hook fails to spawn until reinstall. - * - * Fix: when `normalizeNodePath` detects a path matching the fnm multishell - * directory pattern (`fnm_multishells//node(\.exe)?$`), it probes a stable - * alias path derived from `FNM_DIR` or `APPDATA` env vars (with injected - * `existsSync` for testability) and returns the first that exists. Falls back to - * the raw execPath if no stable alias is found. - * - * All assertions go against exported function return values — no source-grep. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { normalizeNodePath, resolveNodeRunner } = INSTALL; - -// ─── Synthetic paths used across tests ─────────────────────────────────────── - -const EPHEMERAL_FNM_WIN = 'C:/Users/u/AppData/Local/fnm_multishells/15600_1781041703752/node.exe'; -const EPHEMERAL_FNM_WIN_BACKSLASH = 'C:\\Users\\u\\AppData\\Local\\fnm_multishells\\15600_1781041703752\\node.exe'; -const FNM_DIR_WIN = 'C:/Users/u/AppData/Roaming/fnm'; -const APPDATA_WIN = 'C:/Users/u/AppData/Roaming'; -const STABLE_FNM_DIR_NODE = `${FNM_DIR_WIN}/aliases/default/node.exe`; -const STABLE_APPDATA_NODE = `${APPDATA_WIN}/fnm/aliases/default/node.exe`; - -// ─── normalizeNodePath — fnm multishell ephemeral path → stable alias ──────── - -describe('Bug #977: normalizeNodePath — fnm multishell path with FNM_DIR → stable alias', () => { - test('forward-slash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { - env: { FNM_DIR: FNM_DIR_WIN }, - existsSync: p => p === STABLE_FNM_DIR_NODE, - }); - assert.equal( - result, - STABLE_FNM_DIR_NODE, - `expected stable FNM_DIR alias, got: ${result}`, - ); - }); - - test('backslash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN_BACKSLASH, { - env: { FNM_DIR: FNM_DIR_WIN }, - existsSync: p => p === STABLE_FNM_DIR_NODE, - }); - assert.equal( - result, - STABLE_FNM_DIR_NODE, - `expected stable FNM_DIR alias, got: ${result}`, - ); - }); - - test('FNM_DIR alias does not exist → falls through to APPDATA alias → returns APPDATA alias', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { - env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, - existsSync: p => p === STABLE_APPDATA_NODE, // FNM_DIR alias absent, APPDATA alias present - }); - assert.equal( - result, - STABLE_APPDATA_NODE, - `expected stable APPDATA alias, got: ${result}`, - ); - }); - - test('no alias exists → returns raw execPath unchanged (graceful fallback)', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { - env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, - existsSync: () => false, // nothing exists - }); - assert.equal( - result, - EPHEMERAL_FNM_WIN, - `expected raw execPath fallback, got: ${result}`, - ); - }); - - test('no FNM_DIR or APPDATA in env → returns raw execPath unchanged', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { - env: {}, - existsSync: () => false, - }); - assert.equal( - result, - EPHEMERAL_FNM_WIN, - `expected raw execPath fallback, got: ${result}`, - ); - }); -}); - -// ─── normalizeNodePath — non-fnm paths are NOT affected by the new branch ──── - -describe('Bug #977: normalizeNodePath — non-fnm paths are unaffected (no regression to existing behavior)', () => { - test('NVM path is unchanged', () => { - const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; - assert.equal(normalizeNodePath(nvm), nvm); - }); - - test('Intel Homebrew Cellar path still maps to stable symlink', () => { - assert.equal( - normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'), - '/usr/local/bin/node', - ); - }); - - test('Apple Silicon Homebrew Cellar path still maps to stable symlink', () => { - assert.equal( - normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'), - '/opt/homebrew/bin/node', - ); - }); - - test('regular Windows nodejs path is unchanged', () => { - const win = 'C:\\Program Files\\nodejs\\node.exe'; - assert.equal(normalizeNodePath(win), win); - }); - - test('empty string is returned as-is', () => { - assert.equal(normalizeNodePath(''), ''); - }); - - test('null is returned as-is', () => { - assert.equal(normalizeNodePath(null), null); - }); -}); - -// ─── normalizeNodePath — already-stable fnm alias path is not re-processed ─── - -describe('Bug #977: normalizeNodePath — already-stable fnm alias path passes through unchanged', () => { - test('stable FNM_DIR alias path is returned as-is', () => { - assert.equal( - normalizeNodePath(STABLE_FNM_DIR_NODE), - STABLE_FNM_DIR_NODE, - ); - }); -}); - -// ─── normalizeNodePath — false-positive guard: non-numeric id must NOT remap ── - -describe('Bug #977: normalizeNodePath — non-ephemeral fnm_multishells path is not remapped', () => { - test('non-numeric id segment (e.g. custom-dir) returns raw execPath unchanged even when alias exists', () => { - const nonEphemeral = 'C:/Users/u/AppData/Local/fnm_multishells/custom-dir/node.exe'; - const stableAlias = 'C:/Users/u/AppData/Roaming/fnm/aliases/default/node.exe'; - const result = normalizeNodePath(nonEphemeral, { - env: { FNM_DIR: 'C:/Users/u/AppData/Roaming/fnm' }, - // existsSync returns true for the alias to prove the regex — not the existsSync — is the guard - existsSync: p => p === stableAlias, - }); - assert.equal( - result, - nonEphemeral, - `expected raw execPath (non-ephemeral id must not be remapped), got: ${result}`, - ); - }); -}); - -// ─── resolveNodeRunner — opts pass-through ──────────────────────────────────── - -describe('Bug #977: resolveNodeRunner — passes opts through to normalizeNodePath', () => { - test('fnm multishell execPath is resolved to stable alias via injected opts', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { - value: EPHEMERAL_FNM_WIN, - configurable: true, - }); - const runner = resolveNodeRunner({ - env: { FNM_DIR: FNM_DIR_WIN }, - existsSync: p => p === STABLE_FNM_DIR_NODE, - }); - assert.equal( - runner, - JSON.stringify(STABLE_FNM_DIR_NODE), - `expected stable FNM_DIR alias quoted, got: ${runner}`, - ); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2979-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2979-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2979: Managed JS hooks fail in GUI/minimal-PATH runtimes because - * the installer emits bare `node`. - * - * Reporter evidence: in a stripped PATH like /usr/bin:/bin:/usr/sbin:/sbin - * (the default for Finder-launched/Antigravity-spawned processes on macOS), - * `node` is not resolvable. Hook commands like - * `node "/.gemini/hooks/gsd-check-update.js"` - * fail with `/bin/sh: node: command not found` (exit 127). - * - * Fix: emit the absolute node path (`process.execPath`, the binary - * running the installer itself) as the runner. Forward-slash-normalized - * and double-quoted so it works on POSIX and Windows. - * - * This test exercises the public buildHookCommand surface plus the - * resolveNodeRunner helper, asserting on structured records: - * - the runner field is an absolute path (not bare 'node') - * - it ends with /node or \\node (or .exe on Windows simulation) - * - .sh hooks still use bare 'bash' (PATH-resolved; portable across - * distros that don't ship /bin/bash, like NixOS) - * - * No source-grep on install.js content — assertions go against the - * value returned by the exported function and the parsed structure of - * the emitted hook command (split into runner + args). - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { buildHookCommand, resolveNodeRunner } = INSTALL; - -/** - * Parse a hook command string into { runner, hookPath } structured - * record. The shape is ` ""` where may itself - * be a quoted absolute path (containing spaces), so we split on the - * trailing quoted-path token rather than the first space. - */ -function parseHookCommand(cmd) { - // Trailing token: a double-quoted string ending the command. - const m = cmd.match(/^(.+?)\s+"([^"]+)"\s*$/); - if (!m) { - return { runner: null, hookPath: null, raw: cmd }; - } - return { runner: m[1], hookPath: m[2], raw: cmd }; -} - -describe('Bug #2979: resolveNodeRunner returns absolute, quoted, forward-slash node path', () => { - test('exported as a function', () => { - assert.equal(typeof resolveNodeRunner, 'function'); - }); - - test('returns a double-quoted absolute path', () => { - const runner = resolveNodeRunner(); - assert.ok(runner.startsWith('"'), `expected leading double-quote, got: ${runner}`); - assert.ok(runner.endsWith('"'), `expected trailing double-quote, got: ${runner}`); - const inner = runner.slice(1, -1); - assert.ok(path.isAbsolute(inner.replace(/\//g, path.sep)), `expected absolute path, got: ${inner}`); - }); - - test('uses forward slashes (Windows-safe, matches buildHookCommand convention)', () => { - const runner = resolveNodeRunner(); - assert.ok(!runner.includes('\\'), `expected forward slashes, got: ${runner}`); - }); - - test('points at a node binary (basename starts with "node")', () => { - const runner = resolveNodeRunner(); - const inner = runner.slice(1, -1); - const base = path.posix.basename(inner); - assert.ok(/^node(\.exe)?$/i.test(base), `expected basename node or node.exe, got: ${base}`); - }); -}); - -describe('Bug #2979: buildHookCommand for .js hooks emits absolute node runner', () => { - test('global install: .js hook uses absolute node path, not bare "node"', () => { - const cmd = buildHookCommand('/tmp/.claude', 'gsd-check-update.js'); - const parsed = parseHookCommand(cmd); - assert.notEqual(parsed.runner, null, `failed to parse: ${cmd}`); - assert.notEqual(parsed.runner, 'node', `must not emit bare node (#2979): ${cmd}`); - // The runner should be a quoted absolute path. - assert.ok(parsed.runner.startsWith('"') && parsed.runner.endsWith('"'), - `runner must be quoted absolute path, got: ${parsed.runner}`); - }); - - test('global install: .js hook command parses with hookPath at expected location', () => { - const cmd = buildHookCommand('/tmp/.gemini', 'gsd-statusline.js'); - const parsed = parseHookCommand(cmd); - assert.equal(parsed.hookPath, '/tmp/.gemini/hooks/gsd-statusline.js'); - }); - - test('portableHooks global install: .js hook still uses absolute node (only the path is $HOME-relative)', () => { - const home = require('node:os').homedir().replace(/\\/g, '/'); - const configDir = home + '/.gemini'; - const cmd = buildHookCommand(configDir, 'gsd-check-update.js', { portableHooks: true }); - const parsed = parseHookCommand(cmd); - assert.notEqual(parsed.runner, 'node', `portableHooks must also use absolute node (#2979): ${cmd}`); - assert.equal(parsed.hookPath, '$HOME/.gemini/hooks/gsd-check-update.js'); - }); -}); - -describe('Bug #3362 / #3413: Windows hook commands are runtime-aware', () => { - // #1928: gemini runtime removed — the PowerShell call-operator seam is now - // inert for every runtime. Antigravity (the Gemini-backend successor) never - // needed the call operator either; lock the inert contract explicitly. - test('Antigravity global install: .js hook command stays shell-neutral on Windows (seam inert after gemini removal)', () => { - const cmd = buildHookCommand('C:/Users/me/.gemini/antigravity', 'gsd-check-update.js', { - platform: 'win32', - runtime: 'antigravity', - }); - assert.ok(!cmd.startsWith('& '), `Antigravity hook command must not use PowerShell call operator: ${cmd}`); - assert.ok(cmd.includes('"C:/Users/me/.gemini/antigravity/hooks/gsd-check-update.js"')); - }); - - test('Antigravity portable install: .js hook command also stays shell-neutral on Windows (seam inert after gemini removal)', () => { - const home = require('node:os').homedir().replace(/\\/g, '/'); - const cmd = buildHookCommand(`${home}/.gemini/antigravity`, 'gsd-check-update.js', { - portableHooks: true, - platform: 'win32', - runtime: 'antigravity', - }); - assert.ok(!cmd.startsWith('& '), `Antigravity hook command must not use PowerShell call operator: ${cmd}`); - assert.equal(parseHookCommand(cmd).hookPath, '$HOME/.gemini/antigravity/hooks/gsd-check-update.js'); - }); - - test('Claude global install: .js hook command stays shell-neutral on Windows Git Bash', () => { - const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { - platform: 'win32', - runtime: 'claude', - }); - assert.ok(!cmd.startsWith('& '), `Claude hook command must not use PowerShell call operator: ${cmd}`); - assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js'); - }); - - test('Windows .js hook with no runtime stays shell-neutral', () => { - const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { - platform: 'win32', - }); - assert.ok(!cmd.startsWith('& '), `Missing runtime must not imply PowerShell syntax: ${cmd}`); - assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js'); - }); - - test('Antigravity runtime on non-Windows platform does not get PowerShell syntax', () => { - const cmd = buildHookCommand('/home/me/.claude', 'gsd-check-update.js', { - platform: 'linux', - runtime: 'antigravity', - }); - assert.ok(!cmd.startsWith('& '), `Non-Windows Antigravity hook must stay shell-neutral: ${cmd}`); - assert.equal(parseHookCommand(cmd).hookPath, '/home/me/.claude/hooks/gsd-check-update.js'); - }); -}); - -describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => { - test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => { - const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh', { platform: 'linux' }); - const parsed = parseHookCommand(cmd); - assert.equal(parsed.runner, 'bash'); - }); - - test('Windows .sh hook uses resolved Git Bash path instead of bare bash (#3393)', () => { - const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-validate-commit.sh', { - platform: 'win32', - env: { ProgramFiles: 'C:\\Program Files' }, - existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe', - }); - assert.equal( - cmd, - '"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.codex/hooks/gsd-validate-commit.sh"', - ); - }); - - test('Windows .sh hook returns null when no supported Bash runner is found (#3393)', () => { - const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-phase-boundary.sh', { - platform: 'win32', - env: {}, - existsSync: () => false, - }); - assert.equal(cmd, null); - }); - - test('Windows Claude .sh hook omits explicit bash.exe wrapper (#166)', () => { - const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-session-state.sh', { - platform: 'win32', - runtime: 'claude', - env: { ProgramFiles: 'C:\\Program Files' }, - existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe', - }); - assert.equal( - cmd, - '"C:/Users/me/.claude/hooks/gsd-session-state.sh"', - 'Claude win32 .sh hooks should serialize as script-only commands' - ); - }); -}); - -// ─── #3002 CR follow-up: legacy-bare-node migration ───────────────────────── - -const { rewriteLegacyManagedNodeHookCommands } = INSTALL; - -describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites bare-node managed hooks on reinstall', () => { - test('exported as a function', () => { - assert.equal(typeof rewriteLegacyManagedNodeHookCommands, 'function'); - }); - - test('rewrites a managed hook entry that uses bare `node ` to the absolute runner', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [ - { type: 'command', command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"' }, - ], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - ); - }); - - test('does NOT touch entries that already use a quoted absolute runner', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '"/usr/local/bin/node" "/x/hooks/gsd-statusline.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - // #1928: gemini runtime removed — the PowerShell call-operator seam is now - // inert for every runtime (including antigravity, the Gemini-backend - // successor). An already-correct absolute-runner command needs no rewrite. - test('Antigravity on Windows leaves an already-correct quoted managed hook untouched (seam inert after gemini removal)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '"/usr/local/bin/node" "C:/Program Files/Antigravity/.gemini/antigravity/hooks/gsd-check-update.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'antigravity' }); - assert.equal(changed, false); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('Antigravity on Windows strips a stale PowerShell call operator from managed hooks on reinstall (seam inert after gemini removal)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Program Files/Antigravity/.gemini/antigravity/hooks/gsd-check-update.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'antigravity' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "C:/Program Files/Antigravity/.gemini/antigravity/hooks/gsd-check-update.js"', - ); - }); - - test('Antigravity on Windows rewrites PowerShell bare-node managed hooks to absolute runner and drops the stale & (seam inert after gemini removal)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '& node "C:/Users/me/.gemini/antigravity/hooks/gsd-check-update.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'antigravity' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "C:/Users/me/.gemini/antigravity/hooks/gsd-check-update.js"', - ); - }); - - test('Claude on Windows strips stale PowerShell prefix from managed hooks on reinstall (#3413)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'claude' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"', - ); - }); - - test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'node /home/me/my-custom-hook.js' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('does NOT touch .sh hooks (they correctly use bare bash)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'bash "/x/hooks/gsd-session-state.sh"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false); - }); - - test('is a no-op when absoluteRunner is null (resolveNodeRunner failed)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'node "/x/hooks/gsd-check-update.js"' }], - }], - }, - }; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, null); - assert.equal(changed, false); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - // #3002 CR: substring containment was a false-positive vector. - // User-authored hooks whose path happened to CONTAIN a managed filename - // as a substring would get unconditionally rewritten with the GSD runner. - // The fix matches by basename equality. - test('does NOT rewrite a user hook whose path contains a managed filename as a substring', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - // Path contains gsd-check-update.js as substring of a longer - // filename, but is NOT actually that file. - command: 'node /home/me/scripts/wraps-gsd-check-update.js-helper.js', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false, 'must not rewrite user hooks with managed-filename-as-substring paths'); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('rewrites a managed entry whose path is quoted with single quotes', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: "node '/x/hooks/gsd-statusline.js'" }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`, - ); - }); - - test('rewrites a managed entry with no path quoting (bareword)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'node /x/hooks/gsd-context-monitor.js' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" /x/hooks/gsd-context-monitor.js', - ); - }); - - test('handles Windows-style backslash path separators when extracting basename', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'node "C:\\\\Users\\\\me\\\\.claude\\\\hooks\\\\gsd-prompt-guard.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true); - }); - - test('Antigravity on Windows normalizes single-quoted managed hook paths to double-quoted forward-slash paths without adding & (#3392; seam inert after gemini removal)', () => { - const settings = { - hooks: { - PreToolUse: [{ - hooks: [{ - type: 'command', - command: "node 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'", - }], - }], - }, - }; - const runner = '"C:/nvm4w/nodejs/node.exe"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'antigravity' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.PreToolUse[0].hooks[0].command, - '"C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"', - ); - }); -}); - -describe('Bug #2979 (#3002 CR): resolveNodeRunner returns null when execPath unavailable', () => { - test('returns null instead of bare "node" when process.execPath is empty', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { value: '', configurable: true }); - const r = resolveNodeRunner(); - assert.equal(r, null, 'expected null, not bare "node"'); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); - - test('buildHookCommand returns null when execPath is unavailable (caller skips registration)', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { value: '', configurable: true }); - const cmd = buildHookCommand('/tmp/.claude', 'gsd-statusline.js'); - assert.equal(cmd, null); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); -}); - -// ─── #3002 CR follow-up #2: null-command guards in settings.json ────────── - -const { validateHookFields } = INSTALL; - -describe('Bug #2979 (#3002 CR follow-up): no command:null hook entries survive serialization', () => { - // CR feedback: assert structurally on the resulting settings object, not by - // grepping bin/install.js source. The push-site guards (each `if` clause's - // `&& ` token) skip null-command pushes at the source. As a - // backstop, install.js now runs validateHookFields(settings) right before - // writeSettings; this test exercises that backstop directly. - // - // Construct a settings object that contains exactly the kind of null-command - // entries that the registration code would have written if my push-site - // guards regressed. Run validateHookFields on it. Assert the null entries - // are gone and the well-formed entries survive. - - function nullCommandEntry(matcher) { - const entry = { hooks: [{ type: 'command', command: null }] }; - if (matcher) entry.matcher = matcher; - return entry; - } - function realCommandEntry(matcher, command) { - const entry = { hooks: [{ type: 'command', command }] }; - if (matcher) entry.matcher = matcher; - return entry; - } - - const MANAGED_JS_HOOKS = [ - { event: 'SessionStart', matcher: undefined, label: 'gsd-check-update.js' }, - { event: 'PostToolUse', matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', label: 'gsd-context-monitor.js' }, - { event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-prompt-guard.js' }, - { event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-read-guard.js' }, - { event: 'PostToolUse', matcher: 'Read', label: 'gsd-read-injection-scanner.js' }, - { event: 'PreToolUse', matcher: 'Bash|Edit|Write|MultiEdit', label: 'gsd-workflow-guard.js' }, - ]; - - for (const { event, matcher, label } of MANAGED_JS_HOOKS) { - test(`validateHookFields strips a null-command ${label} entry from settings.hooks.${event}`, () => { - const settings = { - hooks: { - [event]: [ - nullCommandEntry(matcher), - realCommandEntry(matcher, '"/usr/local/bin/node" "/x/hooks/other.js"'), - ], - }, - }; - const out = validateHookFields(settings); - const survivors = out.hooks[event] || []; - // The well-formed entry must remain. - assert.equal(survivors.length, 1, `expected the real-command entry to survive`); - // No survivor entry contains a hook with command === null. - for (const e of survivors) { - for (const h of e.hooks || []) { - assert.notEqual(h.command, null, 'no surviving hook should have command:null'); - } - } - }); - } - - test('validateHookFields drops the entry entirely when all its hooks have null commands', () => { - const settings = { - hooks: { - SessionStart: [nullCommandEntry()], - }, - }; - const out = validateHookFields(settings); - // Empty event arrays should be cleaned up (the entire SessionStart key - // gets removed when nothing valid remains). - assert.ok( - !out.hooks.SessionStart || out.hooks.SessionStart.length === 0, - 'expected SessionStart to be empty/removed after the only entry was dropped', - ); - }); - - test('validateHookFields preserves agent-type hooks while stripping command:null sibling hooks', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [ - { type: 'command', command: null }, - { type: 'agent', prompt: 'analyze the session' }, - { type: 'command', command: '"/usr/local/bin/node" "/x/hooks/y.js"' }, - ], - }], - }, - }; - const out = validateHookFields(settings); - const survivors = out.hooks.SessionStart[0].hooks; - assert.equal(survivors.length, 2, 'expected 2 of 3 hooks to survive (the null-command one is stripped)'); - assert.equal(survivors.find(h => h.command === null), undefined, 'no surviving hook should have command:null'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-442-config-dir-equals-in-path.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-442-config-dir-equals-in-path (consolidation epic #1969 B1 #1970)", () => { -'use strict'; -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -// parseConfigDirArg is not exported directly from bin/install.js (it closes -// over the module-level `args` array). We expose a pure seam here: -// parseConfigDirFromArgs(args) that mirrors the function's logic so we can -// test the equals-form parsing without spawning a child process. -// -// The implementation under test is inlined below (RED: before the fix it will -// reproduce the truncation bug). Once the fix lands, we swap in the real -// implementation via require. - -/** - * Pure seam that replicates the equals-form parse logic from bin/install.js. - * We import it via a thin wrapper so that the function can be tested without - * executing the entire install script. - * - * During RED the bug is: `split('=')[1]` drops everything after the second `=`. - */ -const { parseConfigDirFromArgs } = require('../bin/install.js'); - -describe('bug-442: --config-dir= equals-form path parsing', () => { - // ── Happy-path: single = in path ───────────────────────────────────────── - test('--config-dir= with one = in value returns full value', () => { - const result = parseConfigDirFromArgs(['--config-dir=/tmp/gsd=a']); - assert.equal(result, '/tmp/gsd=a'); - }); - - // ── Happy-path: multiple = in path ─────────────────────────────────────── - test('--config-dir= with multiple = in value returns full value', () => { - const result = parseConfigDirFromArgs(['--config-dir=/tmp/a=b=c']); - assert.equal(result, '/tmp/a=b=c'); - }); - - // ── Short form -c= ──────────────────────────────────────────────────────── - test('-c= with = in value returns full value', () => { - const result = parseConfigDirFromArgs(['-c=/tmp/gsd=a']); - assert.equal(result, '/tmp/gsd=a'); - }); - - test('-c= with multiple = in value returns full value', () => { - const result = parseConfigDirFromArgs(['-c=/tmp/a=b=c']); - assert.equal(result, '/tmp/a=b=c'); - }); - - // ── Contract: empty value ───────────────────────────────────────────────── - // --config-dir= (no value after the =) → returns empty string ''. - // The caller (parseConfigDirArg) treats '' as missing and errors; the seam - // itself should faithfully return '' rather than null/undefined so the - // caller can make the error decision. - test('--config-dir= with no value returns empty string', () => { - const result = parseConfigDirFromArgs(['--config-dir=']); - assert.equal(result, ''); - }); - - test('-c= with no value returns empty string', () => { - const result = parseConfigDirFromArgs(['-c=']); - assert.equal(result, ''); - }); - - // ── Space-separated form is unaffected (regression guard) ───────────────── - test('--config-dir space-separated still returns the path', () => { - const result = parseConfigDirFromArgs(['--config-dir', '/tmp/gsd=a']); - assert.equal(result, '/tmp/gsd=a'); - }); - - test('-c space-separated still returns the path', () => { - const result = parseConfigDirFromArgs(['-c', '/tmp/gsd=a']); - assert.equal(result, '/tmp/gsd=a'); - }); - - // ── No config-dir flag → null ───────────────────────────────────────────── - test('returns null when no --config-dir flag is present', () => { - const result = parseConfigDirFromArgs(['--global', '--claude']); - assert.equal(result, null); - }); - - // ── Negative matrix (CLI edge cases) ───────────────────────────────────── - // Flag-looking value after space form: next arg starts with - → null (no - // valid value; the real function would process.exit but the seam returns null - // so tests stay in-process). - test('space form with next arg being a flag returns null (flag-looking value)', () => { - const result = parseConfigDirFromArgs(['--config-dir', '--other-flag']); - assert.equal(result, null); - }); - - // Equals form where value is a path with no = (plain path, no regression) - test('--config-dir= without any = in path still works', () => { - const result = parseConfigDirFromArgs(['--config-dir=/tmp/plain']); - assert.equal(result, '/tmp/plain'); - }); - - // Flag appears after other args (positional ordering should not matter) - test('--config-dir= flag after other args is parsed correctly', () => { - const result = parseConfigDirFromArgs(['--global', '--config-dir=/tmp/a=b', '--claude']); - assert.equal(result, '/tmp/a=b'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/enh-1559-installer-export-audit.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:enh-1559-installer-export-audit (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); - -let installer; -let conversion; - -before(() => { - process.env['GSD_TEST_MODE'] = '1'; - installer = require('../bin/install.js'); - conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -}); - -describe('bin/install.js compatibility export audit (#1559)', () => { - test('retains audited compatibility relays for shared rewrite helpers', () => { - assert.strictEqual(installer.processAttribution, conversion.processAttribution); - assert.strictEqual( - installer.applyRuntimeContentRewritesForCommandsInPlace, - conversion.applyRuntimeContentRewritesForCommandsInPlace, - ); - }); - - test('does not leak unaudited conversion-module helpers through the installer', () => { - for (const name of [ - 'yamlQuote', - 'toSingleLine', - 'extractFrontmatterAndBody', - 'extractFrontmatterField', - 'convertClaudeToCursorMarkdown', - 'convertClaudeToCodexMarkdown', - 'transformContentToHyphen', - 'claudeToGeminiTools', - 'convertGeminiToolName', - 'rewriteStagedSkillBodies', - 'rewriteStagedCommandBodies', - '_computePathPrefix', - '_stampNonClaudeRuntimeDefaults', - 'NON_CLAUDE_RUNTIMES', - ]) { - assert.ok(name in conversion, `${name} remains available from the conversion module`); - assert.equal(installer[name], undefined, `${name} is not an installer compatibility export`); - } - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-1908-uninstall-manifest.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-1908-uninstall-manifest (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for bug #1908 - * - * `--uninstall` did not remove `gsd-file-manifest.json` from the target - * directory, leaving a stale metadata file after uninstall. - * - * Fix: `uninstall()` must call - * fs.rmSync(path.join(targetDir, MANIFEST_NAME), { force: true }) - * after cleaning up the rest of the GSD artefacts. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { uninstall } = require('../bin/install.js'); - -const MANIFEST_NAME = 'gsd-file-manifest.json'; - -// ─── helpers ────────────────────────────────────────────────────────────────── - -function createFakeInstall(prefix = 'gsd-uninstall-test-') { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - - // Simulate the minimum directory/file layout produced by the installer: - // gsd-core/ directory, agents/ directory, and the manifest file. - fs.mkdirSync(path.join(dir, 'gsd-core', 'workflows'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub'); - - fs.mkdirSync(path.join(dir, 'agents'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'agents', 'gsd-executor.md'), '# stub'); - - const manifest = { - version: '1.34.0', - timestamp: new Date().toISOString(), - files: { - 'gsd-core/workflows/execute-phase.md': 'abc123', - 'agents/gsd-executor.md': 'def456', - }, - }; - fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); - - return dir; -} - -function cleanup(dir) { - // eslint-disable-next-line local/no-raw-rmsync-in-tests -- local teardown helper predates helpers.cjs; renaming would collide with the imported cleanup - try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} -} - -// ─── tests ──────────────────────────────────────────────────────────────────── - -describe('uninstall — manifest cleanup (#1908)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createFakeInstall(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-file-manifest.json is removed after global uninstall', () => { - const manifestPath = path.join(tmpDir, MANIFEST_NAME); - - // Pre-condition: manifest exists before uninstall - assert.ok( - fs.existsSync(manifestPath), - 'Test setup failure: manifest file should exist before uninstall' - ); - - // Run uninstall against tmpDir (pass it via CLAUDE_CONFIG_DIR so getGlobalDir() - // resolves to our temp directory; pass isGlobal=true) - const savedEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = tmpDir; - try { - uninstall(true, 'claude'); - } finally { - if (savedEnv === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = savedEnv; - } - } - - assert.ok( - !fs.existsSync(manifestPath), - [ - `${MANIFEST_NAME} must be removed by uninstall() but still exists at`, - manifestPath, - ].join(' ') - ); - }); - - test('gsd-file-manifest.json is removed after local uninstall', () => { - const manifestPath = path.join(tmpDir, MANIFEST_NAME); - - assert.ok( - fs.existsSync(manifestPath), - 'Test setup failure: manifest file should exist before uninstall' - ); - - // For a local install, getGlobalDir is not called — targetDir = cwd + dirName. - // Simulate by creating .claude/ inside tmpDir and placing artefacts there. - const localDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(path.join(localDir, 'gsd-core', 'workflows'), { recursive: true }); - fs.writeFileSync(path.join(localDir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub'); - const localManifestPath = path.join(localDir, MANIFEST_NAME); - fs.writeFileSync(localManifestPath, JSON.stringify({ version: '1.34.0', files: {} }, null, 2)); - - const savedCwd = process.cwd(); - process.chdir(tmpDir); - try { - uninstall(false, 'claude'); - } finally { - process.chdir(savedCwd); - } - - assert.ok( - !fs.existsSync(localManifestPath), - [ - `${MANIFEST_NAME} must be removed by uninstall() (local) but still exists at`, - localManifestPath, - ].join(' ') - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2771-user-profile-manifest.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2771-user-profile-manifest (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest - * - * USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user. - * preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest() - * also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was - * on disk at install time. On the next install, saveLocalPatches() compares the on-disk - * (refreshed) hash to the manifest hash, finds them different, and emits the spurious - * "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning. - * - * Invariant: a file is either distribution (manifest-tracked, diff'd against manifest) - * or user artifact (preserved across installs, never diff'd). It cannot be both. The - * shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both - * preserveUserArtifacts callers and writeManifest. - * - * Closes: #2771 - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const MANIFEST_NAME = 'gsd-file-manifest.json'; -const PATCHES_DIR_NAME = 'gsd-local-patches'; - -// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. -const { - BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, - INSTALL_TIMEOUT_MS, -} = require('./helpers/timeouts.cjs'); - -before(() => { - const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); - throwIfFailed(r, `node ${BUILD_SCRIPT}`); -}); - -function runInstaller(configDir) { - const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; - delete env.GSD_TEST_MODE; - const r = runNode( - [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], - { env, timeoutMs: INSTALL_TIMEOUT_MS } - ); - throwIfFailed(r, `node ${INSTALL_SCRIPT} --claude --global --yes --no-sdk`); - return r.stdout; -} - -// ─── Test 1: writeManifest must NOT record USER-PROFILE.md ──────────────────── - -describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempDir('gsd-2771-manifest-'); }); - afterEach(() => { cleanup(tmpDir); }); - - test('writeManifest excludes gsd-core/USER-PROFILE.md even when present on disk', () => { - runInstaller(tmpDir); - - // Simulate /gsd-profile-user creating USER-PROFILE.md - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - fs.writeFileSync(profilePath, '# My Profile\n\nFirst version.\n'); - - // Re-install: writeManifest runs again with USER-PROFILE.md present on disk - runInstaller(tmpDir); - - const manifestPath = path.join(tmpDir, MANIFEST_NAME); - assert.ok(fs.existsSync(manifestPath), 'manifest must be written'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - - assert.ok( - !Object.prototype.hasOwnProperty.call(manifest.files, 'gsd-core/USER-PROFILE.md'), - 'manifest.files must NOT contain gsd-core/USER-PROFILE.md — it is a user artifact, not distribution' - ); - }); -}); - -// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ──────────── - -describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempDir('gsd-2771-preserve-'); }); - afterEach(() => { cleanup(tmpDir); }); - - test('USER-PROFILE.md content survives reinstall (preservation regression guard)', () => { - runInstaller(tmpDir); - - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - const content = '# Profile\n\nUser content from /gsd-profile-user.\n'; - fs.writeFileSync(profilePath, content); - - runInstaller(tmpDir); - - assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md must survive reinstall'); - assert.strictEqual(fs.readFileSync(profilePath, 'utf8'), content); - }); -}); - -// ─── Test 3: no spurious "local patches" hit for USER-PROFILE.md refresh ────── - -describe('#2771: refreshed USER-PROFILE.md does not trigger local-patches warning', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempDir('gsd-2771-patches-'); }); - afterEach(() => { cleanup(tmpDir); }); - - test('saveLocalPatches does not classify a refreshed USER-PROFILE.md as a local patch', () => { - // Initial install - runInstaller(tmpDir); - - // /gsd-profile-user creates USER-PROFILE.md (v1) - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - fs.writeFileSync(profilePath, '# Profile v1\n'); - - // Reinstall — manifest written with v1 contents (under buggy code) or excluded (under fix) - runInstaller(tmpDir); - - // /gsd-profile-user --refresh rewrites USER-PROFILE.md (v2 != v1) - fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n'); - - // Reinstall — saveLocalPatches scans manifest. Under bug, v2 hash != v1 manifest - // hash → patch detected. Under fix, file is not in manifest → no patch. - const output = runInstaller(tmpDir); - - const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME); - const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md'); - assert.ok( - !fs.existsSync(patchFile), - 'USER-PROFILE.md must NOT appear in gsd-local-patches/ — it is a user artifact, not a modified distribution file' - ); - - const offendingLine = output - .split('\n') - .find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line)); - assert.strictEqual( - offendingLine, - undefined, - 'installer output must not report USER-PROFILE.md as a locally modified GSD file on any single line. Output was:\n' + output - ); - }); -}); - -// ─── Test 5: legacy manifest with USER-PROFILE.md entry is normalized ───────── - -describe('#2771: legacy manifest entries for USER_OWNED_ARTIFACTS are normalized', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempDir('gsd-2771-legacy-'); }); - afterEach(() => { cleanup(tmpDir); }); - - test('pre-existing manifest entry for USER-PROFILE.md does not trigger patches warning', () => { - // Initial install - runInstaller(tmpDir); - - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - fs.writeFileSync(profilePath, '# Profile v1\n'); - - // Reinstall to populate manifest under the (now-fixed) writer - runInstaller(tmpDir); - - // Inject a stale manifest entry simulating a pre-#2771 install: a hash for - // USER-PROFILE.md that does NOT match current content. - const manifestPath = path.join(tmpDir, MANIFEST_NAME); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - manifest.files = manifest.files || {}; - manifest.files['gsd-core/USER-PROFILE.md'] = 'deadbeef'.repeat(8); // stale hash - fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); - - // /gsd-profile-user --refresh rewrites USER-PROFILE.md - fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n'); - - // Reinstall — saveLocalPatches must strip the legacy entry before scanning - const output = runInstaller(tmpDir); - - const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME); - const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md'); - assert.ok( - !fs.existsSync(patchFile), - 'legacy USER-PROFILE.md manifest entry must be normalized away — not backed up as a patch' - ); - - const offendingLine = output - .split('\n') - .find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line)); - assert.strictEqual( - offendingLine, - undefined, - 'legacy manifest entry must not surface a USER-PROFILE.md patches warning. Output was:\n' + output - ); - }); -}); - -// ─── Test 4: shared constant exists and is used by both call sites ──────────── - -describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => { - test('install.js exports USER_OWNED_ARTIFACTS containing USER-PROFILE.md', () => { - const origMode = process.env.GSD_TEST_MODE; - process.env.GSD_TEST_MODE = '1'; - let mod; - try { - delete require.cache[require.resolve(INSTALL_SCRIPT)]; - mod = require(INSTALL_SCRIPT); - } finally { - if (origMode === undefined) delete process.env.GSD_TEST_MODE; - else process.env.GSD_TEST_MODE = origMode; - } - - assert.ok( - Array.isArray(mod.USER_OWNED_ARTIFACTS) || mod.USER_OWNED_ARTIFACTS instanceof Set, - 'install.js must export USER_OWNED_ARTIFACTS as a single source of truth' - ); - const list = Array.isArray(mod.USER_OWNED_ARTIFACTS) - ? mod.USER_OWNED_ARTIFACTS - : Array.from(mod.USER_OWNED_ARTIFACTS); - assert.ok( - list.includes('USER-PROFILE.md'), - 'USER_OWNED_ARTIFACTS must include USER-PROFILE.md' - ); - }); -}); - -describe('manifest path safety', () => { - let tmpDir; - let outside; - - beforeEach(() => { - tmpDir = createTempDir('gsd-manifest-path-safety-'); - outside = path.join(tmpDir, '..', `outside-managed-file-${path.basename(tmpDir)}.txt`); - }); - afterEach(() => { - cleanup(outside); - cleanup(tmpDir); - }); - - test('saveLocalPatches ignores manifest entries that escape the install root', () => { - const origMode = process.env.GSD_TEST_MODE; - process.env.GSD_TEST_MODE = '1'; - let mod; - try { - delete require.cache[require.resolve(INSTALL_SCRIPT)]; - mod = require(INSTALL_SCRIPT); - } finally { - if (origMode === undefined) delete process.env.GSD_TEST_MODE; - else process.env.GSD_TEST_MODE = origMode; - } - - fs.writeFileSync(outside, 'outside user data\n', 'utf8'); - fs.writeFileSync( - path.join(tmpDir, MANIFEST_NAME), - JSON.stringify({ - version: 'legacy', - timestamp: '2026-05-11T00:00:00.000Z', - files: { - '../outside-managed-file.txt': 'deadbeef', - }, - }, null, 2), - 'utf8' - ); - - const modified = mod.saveLocalPatches(tmpDir); - - assert.deepEqual(modified, []); - assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n'); - assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', path.basename(outside))), false); - }); - - test('saveLocalPatches does not follow symlinked patch directories outside the install root', () => { - const origMode = process.env.GSD_TEST_MODE; - process.env.GSD_TEST_MODE = '1'; - let mod; - try { - delete require.cache[require.resolve(INSTALL_SCRIPT)]; - mod = require(INSTALL_SCRIPT); - } finally { - if (origMode === undefined) delete process.env.GSD_TEST_MODE; - else process.env.GSD_TEST_MODE = origMode; - } - - const hookPath = path.join(tmpDir, 'hooks', 'managed.js'); - fs.mkdirSync(path.dirname(hookPath), { recursive: true }); - fs.writeFileSync(hookPath, 'user edited hook\n', 'utf8'); - fs.writeFileSync( - path.join(tmpDir, MANIFEST_NAME), - JSON.stringify({ - version: 'legacy', - timestamp: '2026-05-11T00:00:00.000Z', - files: { - 'hooks/managed.js': crypto.createHash('sha256').update('managed hook\n').digest('hex'), - }, - }, null, 2), - 'utf8' - ); - - fs.mkdirSync(outside, { recursive: true }); - try { - fs.symlinkSync(outside, path.join(tmpDir, PATCHES_DIR_NAME), 'dir'); - } catch { - return; - } - - const modified = mod.saveLocalPatches(tmpDir); - - assert.deepEqual(modified, []); - assert.equal(fs.existsSync(path.join(outside, 'hooks', 'managed.js')), false); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3571-configuration-manifest-install-path.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3571-configuration-manifest-install-path (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for #3571: configuration.cjs used the source - * checkout sdk/shared path only, which breaks installed gsd-tools.cjs because - * runtime installs copy gsd-core/ but not sdk/. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const REPO_ROOT = path.join(__dirname, '..'); -const CONFIGURATION_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'configuration.cjs'); -const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared'); - -const { install } = require('../bin/install.js'); - -const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs'); -const makeTmpDir = () => createTempDir('gsd-3571-'); - -function silenceConsole(fn) { - const original = { - log: console.log, - warn: console.warn, - error: console.error, - }; - console.log = () => {}; - console.warn = () => {}; - console.error = () => {}; - try { - return fn(); - } finally { - console.log = original.log; - console.warn = original.warn; - console.error = original.error; - } -} - -describe('bug #3571: configuration generated manifests resolve in install layout', () => { - let tmpRoot; - let savedHome; - let savedUserProfile; - let savedExplicitConfigDir; - let restoreConfigLocationEnv; - - beforeEach(() => { - tmpRoot = makeTmpDir(); - savedHome = process.env.HOME; - // On Windows, os.homedir() reads USERPROFILE; install() resolves via it. - savedUserProfile = process.env.USERPROFILE; - savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - // #2665: this block calls the real installer IN-PROCESS with only HOME - // sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR - // (or CODEX_HOME, or any other runtime's config-location var) overrides that - // sandbox and a complete global install lands in the developer's live config - // dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env. - restoreConfigLocationEnv = scrubConfigLocationEnv(); - }); - - afterEach(() => { - process.env.HOME = savedHome; - if (savedUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = savedUserProfile; - if (savedExplicitConfigDir === undefined) { - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - } else { - process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; - } - restoreConfigLocationEnv(); - cleanup(tmpRoot); - }); - - test('co-located bin/shared manifests let configuration.cjs load without sdk/shared', () => { - const gsdBinDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin'); - const gsdLibDir = path.join(gsdBinDir, 'lib'); - const gsdSharedDir = path.join(gsdBinDir, 'shared'); - fs.mkdirSync(gsdLibDir, { recursive: true }); - fs.mkdirSync(gsdSharedDir, { recursive: true }); - - const installedCjs = path.join(gsdLibDir, 'configuration.cjs'); - fs.copyFileSync(CONFIGURATION_CJS, installedCjs); - fs.copyFileSync( - path.join(SHARED_DIR, 'config-defaults.manifest.json'), - path.join(gsdSharedDir, 'config-defaults.manifest.json') - ); - fs.copyFileSync( - path.join(SHARED_DIR, 'config-schema.manifest.json'), - path.join(gsdSharedDir, 'config-schema.manifest.json') - ); - - delete require.cache[installedCjs]; - let mod; - assert.doesNotThrow(() => { - mod = require(installedCjs); - }, 'installed configuration.cjs must not require ~/.codex/sdk/shared'); - - assert.ok(mod.VALID_CONFIG_KEYS.has('workflow.plan_review_convergence')); - }); - - test('post-install: install() copies configuration manifests to co-located bin/shared', () => { - process.env.HOME = tmpRoot; - process.env.USERPROFILE = tmpRoot; - - silenceConsole(() => { - install(true, 'codex'); - }); - - const sharedDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin', 'shared'); - for (const fileName of ['config-defaults.manifest.json', 'config-schema.manifest.json']) { - const installedManifest = path.join(sharedDir, fileName); - assert.ok(fs.existsSync(installedManifest), `${fileName} must be copied to ${sharedDir}`); - assert.doesNotThrow(() => { - JSON.parse(fs.readFileSync(installedManifest, 'utf8')); - }, `${fileName} must be valid JSON`); - } - - const installedCjs = path.join( - tmpRoot, - '.codex', - 'gsd-core', - 'bin', - 'lib', - 'configuration.cjs' - ); - - delete require.cache[installedCjs]; - assert.doesNotThrow(() => { - require(installedCjs); - }, 'post-install configuration.cjs must load from co-located manifests'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-3288-model-catalog-install-path.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-3288-model-catalog-install-path (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for #3288: model-catalog.cjs uses brittle relative path - * that breaks after install. - * - * Repro: - * After `node bin/install.js --global --claude`, the installed - * `~/.claude/gsd-core/bin/lib/model-catalog.cjs` tries: - * require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')) - * which resolves to `~/.claude/sdk/shared/model-catalog.json`. - * The installer copies `gsd-core/` but never copies `sdk/shared/`, - * so the require throws MODULE_NOT_FOUND. - * - * Fix contract: - * 1. model-catalog.cjs must use a resolve-chain that checks a co-located - * path first (bin/shared/model-catalog.json) before the legacy - * source-repo path. - * 2. bin/install.js must copy shared model-catalog.json into - * gsd-core/bin/shared/model-catalog.json (co-located inside the - * gsd-core/ payload). - * - * Both halves must be true for the install layout to work. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const REPO_ROOT = path.join(__dirname, '..'); -const MODEL_CATALOG_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'model-catalog.cjs'); -const MODEL_CATALOG_JSON = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'model-catalog.json'); - -const { install } = require('../bin/install.js'); - -// ─── helpers ───────────────────────────────────────────────────────────────── - -const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs'); -const makeTmpDir = createTempDir; - -const rmTmpDir = cleanup; - -/** - * Silence console output during install to avoid noise in test output. - */ -function silenceConsole(fn) { - const orig = { - log: console.log, - warn: console.warn, - error: console.error, - }; - console.log = () => {}; - console.warn = () => {}; - console.error = () => {}; - try { - return fn(); - } finally { - console.log = orig.log; - console.warn = orig.warn; - console.error = orig.error; - } -} - -// ─── test 1: fake-install layout reproduces MODULE_NOT_FOUND ──────────────── -// -// Build a fake post-install layout that mirrors what the OLD install did: -// /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real file) -// /.claude/sdk/shared/model-catalog.json ABSENT -// -// Then attempt to require model-catalog.cjs from that layout. -// Under the old path scheme (3 levels up → sdk/shared/) this should throw. -// After the fix, if we DON'T also copy the json, it should still throw — this -// confirms the co-located path IS required. - -describe('bug #3288: model-catalog.cjs install-layout resolution', () => { - let tmpRoot; - let savedHome; - let savedUserProfile; - let savedExplicitConfigDir; - let restoreConfigLocationEnv; - - beforeEach(() => { - tmpRoot = makeTmpDir('gsd-3288-'); - savedHome = process.env.HOME; - // On Windows, os.homedir() reads USERPROFILE (and HOMEDRIVE+HOMEPATH), NOT - // HOME. install() resolves the install destination via os.homedir(), so the - // tests must also redirect USERPROFILE → tmpRoot on win32 to keep the - // installer writing inside the fixture. - savedUserProfile = process.env.USERPROFILE; - // Stash and clear explicitConfigDir via env so install() picks up our tmp dir. - // Must delete (not just save) so any CI-set value doesn't leak into install() - // and target a different directory than tmpRoot (CR finding, PR #3293). - savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - // #2665: this block calls the real installer IN-PROCESS with only HOME - // sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR - // (or CODEX_HOME, or any other runtime's config-location var) overrides that - // sandbox and a complete global install lands in the developer's live config - // dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env. - restoreConfigLocationEnv = scrubConfigLocationEnv(); - }); - - afterEach(() => { - process.env.HOME = savedHome; - if (savedUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = savedUserProfile; - if (savedExplicitConfigDir === undefined) { - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - } else { - process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; - } - restoreConfigLocationEnv(); - rmTmpDir(tmpRoot); - }); - - // ── test A ────────────────────────────────────────────────────────────────── - test('OLD layout (3-level __dirname, no co-located json) fails to require', () => { - // Build the old install layout manually: - // /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of the real CJS) - // sdk/shared/model-catalog.json ABSENT - const gsdLibDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin', 'lib'); - fs.mkdirSync(gsdLibDir, { recursive: true }); - - // Write a minimal model-catalog.cjs that uses ONLY the 3-level path (the old/broken path). - const oldCjsContent = `'use strict'; -const path = require('node:path'); -// This is the BRITTLE path: 3 levels up from bin/lib → sdk/shared/ -const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')); -module.exports = { catalog }; -`; - const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs'); - fs.writeFileSync(catalogCjsPath, oldCjsContent); - - // Deliberately do NOT create sdk/shared/model-catalog.json (simulates missing file post-install). - - // Require must fail with MODULE_NOT_FOUND. - assert.throws( - () => { - // Delete from require cache to force a fresh load. - delete require.cache[catalogCjsPath]; - require(catalogCjsPath); - }, - (err) => { - assert.ok( - err.code === 'MODULE_NOT_FOUND' || err.message.includes('model-catalog.json'), - `Expected MODULE_NOT_FOUND or model-catalog.json error, got: ${err.message}`, - ); - return true; - }, - 'OLD 3-level path must fail when sdk/shared/model-catalog.json is not present (install layout)', - ); - }); - - // ── test B ────────────────────────────────────────────────────────────────── - test('NEW layout (co-located bin/shared/model-catalog.json) resolves correctly', () => { - // Build the new install layout: - // /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real CJS) - // /.claude/gsd-core/bin/shared/model-catalog.json (co-located copy) - const gsdBinDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin'); - const gsdLibDir = path.join(gsdBinDir, 'lib'); - const gsdSharedDir = path.join(gsdBinDir, 'shared'); - fs.mkdirSync(gsdLibDir, { recursive: true }); - fs.mkdirSync(gsdSharedDir, { recursive: true }); - - // Copy the real model-catalog.cjs into the fake install. - const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs'); - fs.copyFileSync(MODEL_CATALOG_CJS, catalogCjsPath); - - // Copy the real model-catalog.json to the co-located path. - fs.copyFileSync(MODEL_CATALOG_JSON, path.join(gsdSharedDir, 'model-catalog.json')); - - // Require must succeed and expose catalog with expected shape. - delete require.cache[catalogCjsPath]; - let mod; - assert.doesNotThrow(() => { - mod = require(catalogCjsPath); - }, 'NEW co-located layout must not throw MODULE_NOT_FOUND'); - - assert.ok(mod.catalog, 'module must export catalog'); - assert.ok(Array.isArray(mod.VALID_PROFILES), 'module must export VALID_PROFILES'); - assert.ok(mod.VALID_PROFILES.length > 0, 'VALID_PROFILES must not be empty'); - }); - - // ── test C ────────────────────────────────────────────────────────────────── - test('post-install: install() copies model-catalog.json to co-located path', () => { - // Run the real installer against a tmp target dir, then assert the co-located - // json is present and parseable. - const claudeDir = path.join(tmpRoot, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - process.env.HOME = tmpRoot; - process.env.USERPROFILE = tmpRoot; - - // Capture process.exit to prevent the test from being killed. - const origExit = process.exit; - let exitCalled = false; - process.exit = (code) => { - exitCalled = true; - throw new Error(`process.exit(${code}) during install — should not happen`); - }; - - try { - silenceConsole(() => { - install(true /* isGlobal */, 'claude'); - }); - } catch (e) { - if (exitCalled) { - assert.fail(`install() called process.exit — unexpected: ${e.message}`); - } - throw e; - } finally { - process.exit = origExit; - } - - // The co-located json must be present after install. - const colocatedJson = path.join( - claudeDir, - 'gsd-core', - 'bin', - 'shared', - 'model-catalog.json', - ); - assert.ok( - fs.existsSync(colocatedJson), - `model-catalog.json must be present at co-located path post-install: ${colocatedJson}`, - ); - - // The json must be valid and have expected shape. - let parsed; - assert.doesNotThrow(() => { - parsed = JSON.parse(fs.readFileSync(colocatedJson, 'utf8')); - }, 'co-located model-catalog.json must be valid JSON'); - - assert.ok(Array.isArray(parsed.profiles), 'catalog.profiles must be an array'); - assert.ok(parsed.profiles.length > 0, 'catalog.profiles must not be empty'); - - // And the installed model-catalog.cjs must be requireable from its install location. - const installedCjs = path.join( - claudeDir, - 'gsd-core', - 'bin', - 'lib', - 'model-catalog.cjs', - ); - assert.ok(fs.existsSync(installedCjs), `model-catalog.cjs must be installed at: ${installedCjs}`); - - delete require.cache[installedCjs]; - let installedMod; - assert.doesNotThrow(() => { - installedMod = require(installedCjs); - }, 'installed model-catalog.cjs must not throw MODULE_NOT_FOUND after install'); - - assert.ok(installedMod.catalog, 'installed module must export catalog'); - assert.ok(installedMod.VALID_PROFILES.length > 0, 'installed module must have valid profiles'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-130-finishinstall-opencode-testmode.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-130-finishinstall-opencode-testmode (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #130: finishInstall calls configureOpencodePermissions unconditionally, - * violating the GSD_TEST_MODE side-effect-free contract. - * - * configureOpencodePermissions does fs.mkdirSync + fs.writeFileSync, which - * must NOT run under GSD_TEST_MODE='1'. This test asserts that the opencode - * config file (opencode.json) is NOT created when GSD_TEST_MODE is set. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const os = require('node:os'); -const fs = require('node:fs'); - -const ROOT = path.join(__dirname, '..'); - -// Point HOME at a temp dir so configureOpencodePermissions can't write to -// the real ~/.config/opencode/ even if the guard is missing. -const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-')); -// Consolidation #1969: scope the HOME/USERPROFILE mutation to before/after so it -// does not leak into sibling folded suites (was process-isolated when standalone). -const { before: __foldBefore, after: __foldAfter } = require('node:test'); -const __savedHome = process.env.HOME; -const __savedUserProfile = process.env.USERPROFILE; -__foldBefore(() => { - process.env.HOME = FAKE_HOME; - process.env.USERPROFILE = FAKE_HOME; -}); -__foldAfter(() => { - if (__savedHome === undefined) delete process.env.HOME; - else process.env.HOME = __savedHome; - if (__savedUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = __savedUserProfile; -}); - -// The opencode config dir that configureOpencodePermissions would use for a -// global install when configDir=null: /.config/opencode/ -// The file it writes is opencode.json (or opencode.jsonc if pre-existing). -const OPENCODE_CONFIG_DIR = path.join(FAKE_HOME, '.config', 'opencode'); -const OPENCODE_CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json'); - -// configDir is passed explicitly so the function targets our FAKE_HOME dir -// regardless of how getGlobalDir resolves. -const installModule = require(path.join(ROOT, 'bin', 'install.js')); - -const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`); - -function callFinishInstall() { - const original = console.log; - console.log = () => {}; - try { - installModule.finishInstall( - SETTINGS_PATH, - {}, - null, - false, - 'opencode', - true, - OPENCODE_CONFIG_DIR, // pass explicit configDir pointing at our temp dir - ); - } finally { - console.log = original; - } -} - -describe('Bug #130: finishInstall opencode + GSD_TEST_MODE side-effect guard', () => { - test('configureOpencodePermissions does NOT write opencode.json under GSD_TEST_MODE', () => { - // Confirm the file does not exist before the call - assert.equal( - fs.existsSync(OPENCODE_CONFIG_FILE), - false, - 'opencode.json should not exist before finishInstall call', - ); - - callFinishInstall(); - - // Assert the file was NOT created — the side-effect must be suppressed - assert.equal( - fs.existsSync(OPENCODE_CONFIG_FILE), - false, - `opencode.json must NOT be created under GSD_TEST_MODE; found at ${OPENCODE_CONFIG_FILE}`, - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-410-install-defaults-test-mode-guard.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-410-install-defaults-test-mode-guard (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -/** - * Bug #410: finishInstall writes ~/.gsd/defaults.json for non-Claude runtimes - * without a GSD_TEST_MODE guard, polluting the real developer home directory - * during test runs. - * - * The opencode permission-config write a few lines above already carries the - * GSD_TEST_MODE guard (added for #130) — this test covers the un-fixed sibling - * (the resolve_model_ids: "omit" write). - */ - -const { test, describe } = require('node:test'); -const { cleanup } = require('./helpers.cjs'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const os = require('node:os'); -const fs = require('node:fs'); - -const ROOT = path.join(__dirname, '..'); - -// Point HOME at a temp dir so the defaults.json write can't reach the real -// ~/.gsd/ even if the guard is missing. -// On Windows, os.homedir() reads USERPROFILE (not HOME). Set both so -// finishInstall's path.join(os.homedir(), '.gsd') resolves into FAKE_HOME -// on every platform. Node docs: https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir -const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-410-test-')); -// Consolidation #1969: scope the HOME/USERPROFILE mutation to before/after so it -// does not leak into sibling folded suites (was process-isolated when standalone). -const { before: __foldBefore, after: __foldAfter } = require('node:test'); -const __savedHome = process.env.HOME; -const __savedUserProfile = process.env.USERPROFILE; -__foldBefore(() => { - process.env.HOME = FAKE_HOME; - process.env.USERPROFILE = FAKE_HOME; -}); -__foldAfter(() => { - if (__savedHome === undefined) delete process.env.HOME; - else process.env.HOME = __savedHome; - if (__savedUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = __savedUserProfile; -}); - -// The path that finishInstall would write to for a non-Claude runtime. -const GSD_DIR = path.join(FAKE_HOME, '.gsd'); -const DEFAULTS_PATH = path.join(GSD_DIR, 'defaults.json'); - -// Set GSD_TEST_MODE before requiring install.js so any module-level guards -// also see the flag. -process.env.GSD_TEST_MODE = '1'; - -const installModule = require(path.join(ROOT, 'bin', 'install.js')); - -// A synthetic settingsPath that won't exist — finishInstall should cope. -const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`); - -function callFinishInstallForRuntime(runtime) { - const original = console.log; - console.log = () => {}; - try { - installModule.finishInstall( - SETTINGS_PATH, - {}, // empty settings - null, // statuslineCommand - false, // shouldInstallStatusline - runtime, - true, // isGlobal - null, // configDir - ); - } finally { - console.log = original; - } -} - -describe('Bug #410: finishInstall non-Claude runtime + GSD_TEST_MODE side-effect guard', () => { - test('defaults.json is NOT written for opencode runtime under GSD_TEST_MODE', () => { - assert.equal( - fs.existsSync(DEFAULTS_PATH), - false, - 'defaults.json should not exist before finishInstall call', - ); - - callFinishInstallForRuntime('opencode'); - - assert.equal( - fs.existsSync(DEFAULTS_PATH), - false, - `defaults.json must NOT be created under GSD_TEST_MODE; found at ${DEFAULTS_PATH}`, - ); - }); - - test('defaults.json is NOT written for antigravity runtime under GSD_TEST_MODE', () => { - // Reset in case previous test left artifacts (it shouldn't). - assert.equal( - fs.existsSync(DEFAULTS_PATH), - false, - 'defaults.json should not exist before antigravity test', - ); - - callFinishInstallForRuntime('antigravity'); - - assert.equal( - fs.existsSync(DEFAULTS_PATH), - false, - `defaults.json must NOT be created under GSD_TEST_MODE for antigravity; found at ${DEFAULTS_PATH}`, - ); - }); - - test('defaults.json IS written for opencode runtime when GSD_TEST_MODE is unset', () => { - // Temporarily unset GSD_TEST_MODE to verify the user-facing path still works. - const saved = process.env.GSD_TEST_MODE; - delete process.env.GSD_TEST_MODE; - try { - callFinishInstallForRuntime('opencode'); - assert.equal( - fs.existsSync(DEFAULTS_PATH), - true, - `defaults.json must be written for non-Claude runtime when GSD_TEST_MODE is unset`, - ); - // Verify the written content is correct. - const contents = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal(contents.resolve_model_ids, 'omit', 'resolve_model_ids must be "omit"'); - } finally { - // Restore GSD_TEST_MODE and clean up the written file. - process.env.GSD_TEST_MODE = saved; - cleanup(DEFAULTS_PATH); - try { fs.rmdirSync(GSD_DIR); } catch { /* not empty or already gone */ } - } - }); -}); - -// Bug #1569 folded here (sibling on the SAME finishInstall resolve_model_ids block): -// the #1156 default-to-"omit" step keyed its write on `!== "omit"`, so an explicit -// `resolve_model_ids: true` opt-in (resolveModelInternal returns full materialized -// model IDs) was silently clobbered across all 14 non-Claude runtimes. The fix -// preserves `true` and only defaults absent/falsy → "omit". Reuses the #410 harness. - -describe('Bug #1569: non-Claude finishInstall preserves explicit resolve_model_ids:true', () => { - function seedDefaults(obj) { - fs.mkdirSync(GSD_DIR, { recursive: true }); - fs.writeFileSync(DEFAULTS_PATH, JSON.stringify(obj, null, 2) + '\n', 'utf8'); - } - - function withUserPath(fn) { - const saved = process.env.GSD_TEST_MODE; - delete process.env.GSD_TEST_MODE; - try { - return fn(); - } finally { - process.env.GSD_TEST_MODE = saved; - } - } - - test('explicit resolve_model_ids:true survives a codex global install (the reported case)', () => { - withUserPath(() => { - seedDefaults({ runtime: 'codex', model_profile: 'balanced', resolve_model_ids: true }); - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - true, - 'explicit resolve_model_ids:true must be preserved across a codex install, not clobbered to "omit"', - ); - }); - }); - - // The clobber guard is runtime-agnostic (`runtime !== 'claude'`); parameterize - // across a representative slice of non-Claude runtimes. - for (const runtime of ['codex', 'opencode', 'antigravity']) { - test(`explicit resolve_model_ids:true survives a ${runtime} global install`, () => { - withUserPath(() => { - seedDefaults({ runtime, resolve_model_ids: true }); - callFinishInstallForRuntime(runtime); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - true, - `explicit resolve_model_ids:true must be preserved for ${runtime}`, - ); - }); - }); - } - - test('absent resolve_model_ids still defaults to "omit" (preserves #1156 intent)', () => { - withUserPath(() => { - seedDefaults({ runtime: 'codex' }); - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - 'omit', - 'absent resolve_model_ids must still default to "omit" for non-Claude runtimes', - ); - }); - }); - - test('explicit resolve_model_ids:false still defaults to "omit"', () => { - withUserPath(() => { - seedDefaults({ runtime: 'codex', resolve_model_ids: false }); - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal(after.resolve_model_ids, 'omit', 'false must still be normalized to "omit"'); - }); - }); - - test('non-canonical resolve_model_ids values (0, "", "yes", {}) default to "omit" — no Claude alias leak (#1569 codex review)', () => { - // The domain is true/false/"omit"/absent. Any OTHER value is malformed; the safe - // non-Claude default is "omit" (don't leak Claude aliases the runtime can't resolve). - withUserPath(() => { - for (const bad of [0, '', 'yes', {}]) { - seedDefaults({ runtime: 'codex', resolve_model_ids: bad }); - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - 'omit', - `non-canonical resolve_model_ids:${JSON.stringify(bad)} must default to "omit", not pass through`, - ); - } - }); - }); - - test('already-"omit" is left unchanged (idempotent, no rewrite churn)', () => { - withUserPath(() => { - seedDefaults({ runtime: 'codex', resolve_model_ids: 'omit' }); - const beforeMtime = fs.statSync(DEFAULTS_PATH).mtimeMs; - // fs mtime resolution can be coarse; wait briefly so an accidental rewrite is detectable. - const start = Date.now(); - while (Date.now() - start < 20) { /* spin briefly */ } - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - const afterMtime = fs.statSync(DEFAULTS_PATH).mtimeMs; - assert.equal(after.resolve_model_ids, 'omit'); - assert.equal( - afterMtime, - beforeMtime, - 'defaults.json must not be rewritten when resolve_model_ids is already "omit" (idempotent)', - ); - }); - }); - - test('claude runtime never touches resolve_model_ids (cross-runtime parity)', () => { - withUserPath(() => { - seedDefaults({ runtime: 'claude', resolve_model_ids: true }); - callFinishInstallForRuntime('claude'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - true, - 'claude install must never rewrite resolve_model_ids', - ); - }); - }); - - test('malformed defaults.json does not crash — still defaults to "omit"', () => { - withUserPath(() => { - fs.mkdirSync(GSD_DIR, { recursive: true }); - fs.writeFileSync(DEFAULTS_PATH, '{ not valid json }', 'utf8'); - // Must not throw. - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - 'omit', - 'malformed defaults.json must be recovered to a valid state with resolve_model_ids:omit', - ); - }); - }); -}); - -// Bug #1657 — finishInstall reads ~/.gsd/defaults.json with JSON.parse but did not -// validate the result is a plain object. A valid-JSON-but-non-object value (null, [], -// 42, "str") bypassed the catch and flowed through, leaving the malformed file on disk -// unrecovered (and, for null, throwing a TypeError swallowed by the outer try/catch). -// Folded into the owning install-defaults test (no new top-level bug-NNNN file). -describe('Bug #1657: finishInstall recovers a malformed (non-object) defaults.json', () => { - function seedDefaultsRaw(raw) { - fs.mkdirSync(GSD_DIR, { recursive: true }); - fs.writeFileSync(DEFAULTS_PATH, raw, 'utf8'); - } - function runAndRead(runtime) { - const saved = process.env.GSD_TEST_MODE; - delete process.env.GSD_TEST_MODE; - const log = console.log; console.log = () => {}; - let threw = null; - try { - installModule.finishInstall(SETTINGS_PATH, {}, null, false, runtime, true, null); - } catch (e) { threw = e.message; } finally { console.log = log; process.env.GSD_TEST_MODE = saved; } - let after = null; - try { after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); } catch (e) { after = 'UNPARSEABLE: ' + e.message; } - return { threw, after }; - } - - for (const [label, raw] of [['null', 'null'], ['array', '[]'], ['number', '42'], ['string', '"oops"']]) { - test(`seed ${label} (${raw}) recovers to a valid object with resolve_model_ids:omit`, () => { - seedDefaultsRaw(raw); - const { threw, after } = runAndRead('codex'); - assert.equal(threw, null, `must not throw for seed ${label} (got: ${threw})`); - assert.equal( - after !== null && typeof after === 'object' && !Array.isArray(after) && after.resolve_model_ids === 'omit', - true, - `seed ${label} must recover to { resolve_model_ids: 'omit' }, got: ${JSON.stringify(after)}`, - ); - }); - } -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-1736-local-install-commands.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-1736-local-install-commands (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for #1736: local Claude install missing commands/gsd/ - * - * After a fresh local install (`--claude --local`), all /gsd-* commands - * except /gsd-help return "Unknown skill: gsd-quick" because - * .claude/commands/gsd/ was not populated. Claude Code reads local project - * commands from .claude/commands/ (one level up) using the file stem as the - * command name. - * - * #1367 follow-up: the fix changed the layout from the old commands/gsd/.md - * (which caused /gsd: colon namespace) to flat commands/gsd-.md - * (which produces /gsd- hyphen form). This test has been updated to assert - * the new flat layout while preserving the core invariant from #1736: commands - * must be present and usable after a local install. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const { install } = require(INSTALL_SRC); -const { cleanup } = require('./helpers.cjs'); - -// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. -const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); - -// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── -// With --test-concurrency=4, other install tests (bug-1834, bug-1924) run -// build-hooks.js concurrently. That script creates hooks/dist/ empty first, -// then copies files — creating a window where this test sees an empty dir and -// install() fails with "directory is empty" → process.exit(1). - -before(() => { - const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); - throwIfFailed(r, `node ${BUILD_SCRIPT}`); -}); - -// ─── #1736 + #1367: local install deploys commands in flat gsd-.md layout ─── - -describe('#1736: local Claude install deploys slash commands (flat gsd-.md layout, #1367)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-1736-')); - }); - - afterEach(() => { - // Use the shared helper which has a 5s Windows-EBUSY retry budget - // (20×250ms). The inline 1s budget here was insufficient on cold runners. - cleanup(tmpDir); - }); - - test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => { - // #1736 invariant: commands must be deployed. - // #1367 fix: commands land as flat gsd-.md at commands/ (not commands/gsd/.md). - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - install(false, 'claude'); - - const commandsDir = path.join(tmpDir, '.claude', 'commands'); - assert.ok( - fs.existsSync(commandsDir), - '.claude/commands/ directory must exist after local install' - ); - const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok( - flatFiles.length > 0, - `.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}` - ); - // The old commands/gsd/ subdirectory must NOT exist (#1367) - const oldSubdir = path.join(commandsDir, 'gsd'); - assert.ok( - !fs.existsSync(oldSubdir), - '.claude/commands/gsd/ subdir must NOT exist — flat gsd-.md layout required (#1367)' - ); - }); - - test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - install(false, 'claude'); - - const commandsDir = path.join(tmpDir, '.claude', 'commands'); - assert.ok( - fs.existsSync(commandsDir), - '.claude/commands/ must exist' - ); - - const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok( - files.length > 0, - `.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}` - ); - }); - - test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => { - // Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form). - // Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form). - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - install(false, 'claude'); - - const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md'); - assert.ok( - fs.existsSync(quickCmd), - '.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)' - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2248-local-install-statusline.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2248-local-install-statusline (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression test for #2248: local Claude install clobbers profile-level statusLine - * - * When installing with `--claude --local`, the repo-level `.claude/settings.json` - * takes precedence over the user's profile-level `~/.claude/settings.json` in - * Claude Code. Writing `statusLine` to repo settings during a local install - * silently overrides any profile-level statusLine the user configured. - * - * Fix: local installs skip writing `statusLine` to settings.json unless - * `--force-statusline` is passed. - * - * Note: `install()` only copies files. `finishInstall()` writes settings.json. - * The production code calls both from `installAllRuntimes()`. Tests must mirror - * that two-phase pattern. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const { install, finishInstall } = require(INSTALL_SRC); -const { cleanup, captureConsole } = require('./helpers.cjs'); - -// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. -const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); - -// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── -before(() => { - const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); - throwIfFailed(r, `node ${BUILD_SCRIPT}`); -}); - -// ─── #2248: local install must NOT write statusLine to repo settings.json ──── - -describe('#2248: local Claude install does not clobber profile-level statusLine', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-2248-')); - }); - - afterEach(() => { - // Use the shared 5s Windows-EBUSY retry budget instead of inline 1s. - cleanup(tmpDir); - }); - - test('local install writes hooks to .claude/settings.local.json and does not write statusLine', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - // Phase 1: copy files (mirrors installAllRuntimes) - const result = install(false, 'claude'); - - // Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize) - // #338: local Claude installs now write to settings.local.json, not settings.json. - // shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install - const { stdout } = captureConsole(() => { - finishInstall( - result.settingsPath, - result.settings, - result.statuslineCommand, - true, // shouldInstallStatusline - 'claude', - false // isGlobal=false -> local install - ); - }); - assert.match( - stdout, - /Skipping statusLine for local install/, - 'Local install must explain that it skipped statusLine unless --force-statusline is passed' - ); - - // #338: local installs write to settings.local.json, not settings.json - const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); - assert.ok( - fs.existsSync(localSettingsPath), - '.claude/settings.local.json must exist after local Claude install (#338)' - ); - - const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); - assert.strictEqual( - settings.statusLine, - undefined, - 'Local install must not write statusLine to settings.local.json — it would clobber profile-level settings (#2248)' - ); - - // settings.json must not be touched by a fresh local install - const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json'); - assert.strictEqual( - fs.existsSync(sharedSettingsPath), - false, - '.claude/settings.json must NOT be created by a fresh local Claude install (#338)' - ); - }); - - test('global install still writes statusLine to settings.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - - // Global install writes to CLAUDE_CONFIG_DIR; point it at our tmpDir - const configDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(configDir, { recursive: true }); - const origEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = configDir; - t.after(() => { - if (origEnv === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = origEnv; - } - }); - - // Phase 1: copy files - const result = install(true, 'claude'); - - // Phase 2: configure settings.json - finishInstall( - result.settingsPath, - result.settings, - result.statuslineCommand, - true, // shouldInstallStatusline - 'claude', - true // isGlobal=true - ); - - const settingsPath = path.join(configDir, 'settings.json'); - assert.ok( - fs.existsSync(settingsPath), - '~/.claude/settings.json must exist after global install' - ); - - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - assert.ok( - settings.statusLine !== undefined, - 'Global install should write statusLine to settings.json' - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-338-local-install-settings-local-json.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-338-local-install-settings-local-json (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression tests for #338: Claude --local installs must write hook wiring to - * `.claude/settings.local.json` (Claude Code's per-user gitignored slot) instead - * of the repo-shared `.claude/settings.json`. - * - * Three cases: - * 1. Fresh local install: settings.local.json is created with hook block; - * settings.json is not touched. - * 2. Global install (regression guard): continues to write to settings.json. - * 3. Migration: if a prior local install wrote GSD entries to settings.json, - * re-running local install moves them to settings.local.json and removes - * them from settings.json in the same run. - * - * Note: `install()` only copies files. `finishInstall()` writes settings. - * The production code calls both from `installAllRuntimes()`. Tests mirror - * that two-phase pattern. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const { install, finishInstall } = require(INSTALL_SRC); -const { cleanup } = require('./helpers.cjs'); - -// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. -const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); - -// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── -before(() => { - const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); - throwIfFailed(r, `node ${BUILD_SCRIPT}`); -}); - -// ─── Helper: run both install phases ───────────────────────────────────────── - -/** - * Run install + finishInstall (mirrors installAllRuntimes two-phase pattern). - * @param {boolean} isGlobal - * @param {object} [opts] - * @param {boolean} [opts.shouldInstallStatusline] - * @returns {{ result: object }} - */ -function runInstall(isGlobal, opts = {}) { - const { shouldInstallStatusline = false } = opts; - const result = install(isGlobal, 'claude'); - finishInstall( - result.settingsPath, - result.settings, - result.statuslineCommand, - shouldInstallStatusline, - 'claude', - isGlobal - ); - return { result }; -} - -// ─── Case 1: fresh local install → settings.local.json, not settings.json ─── - -describe('#338 case 1: fresh local Claude install writes to settings.local.json', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-local-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('settings.local.json is created with hook block', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - runInstall(false); - - const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); - assert.ok( - fs.existsSync(localSettingsPath), - '.claude/settings.local.json must exist after local Claude install (#338)' - ); - - const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); - assert.ok( - settings && typeof settings === 'object', - 'settings.local.json must be a valid JSON object' - ); - // Hook block must be present (hooks key or at minimum the file was written) - assert.ok( - settings.hooks !== undefined || Object.keys(settings).length >= 0, - 'settings.local.json must contain the hook block' - ); - }); - - test('settings.json is NOT created by a fresh local install', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - runInstall(false); - - const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json'); - assert.strictEqual( - fs.existsSync(sharedSettingsPath), - false, - '.claude/settings.json must NOT be created by a fresh local Claude install (#338) — ' + - 'engineer-specific absolute paths must not leak into the repo-shared file' - ); - }); - - test('install() returns settingsPath pointing to settings.local.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - const result = install(false, 'claude'); - assert.ok( - result.settingsPath.endsWith('settings.local.json'), - `install() must return settingsPath ending in settings.local.json for local Claude installs; got: ${result.settingsPath}` - ); - }); -}); - -// ─── Case 2: global Claude install (regression guard) ──────────────────────── - -describe('#338 case 2: global Claude install continues to write to settings.json', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-global-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('global install writes hook block to settings.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - - // Point CLAUDE_CONFIG_DIR at a subdir of tmpDir to avoid polluting ~/.claude - const configDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(configDir, { recursive: true }); - const origEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = configDir; - t.after(() => { - if (origEnv === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = origEnv; - } - }); - - runInstall(true); - - const settingsPath = path.join(configDir, 'settings.json'); - assert.ok( - fs.existsSync(settingsPath), - '~/.claude/settings.json must exist after global Claude install (regression guard for #338)' - ); - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - assert.ok( - settings && typeof settings === 'object', - 'settings.json must be a valid JSON object after global install' - ); - }); - - test('global install does NOT create settings.local.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - - const configDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(configDir, { recursive: true }); - const origEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = configDir; - t.after(() => { - if (origEnv === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = origEnv; - } - }); - - runInstall(true); - - const localSettingsPath = path.join(configDir, 'settings.local.json'); - assert.strictEqual( - fs.existsSync(localSettingsPath), - false, - '~/.claude/settings.local.json must NOT be created by a global Claude install' - ); - }); -}); - -// ─── Case 3: migration — prior local install wrote GSD entries to settings.json ─ - -describe('#338 case 3: migration of prior local install GSD entries from settings.json to settings.local.json', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-migrate-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('GSD hook entries are moved from settings.json to settings.local.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - // Pre-populate .claude/settings.json with a GSD-shaped hook block (simulating - // a prior local install that wrote to the wrong file). - const claudeDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const sharedSettingsPath = path.join(claudeDir, 'settings.json'); - const priorSettings = { - hooks: { - SessionStart: [ - { - hooks: [ - { - type: 'command', - command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`, - } - ] - } - ], - PostToolUse: [ - { - matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', - hooks: [ - { - type: 'command', - command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`, - timeout: 10, - } - ] - } - ] - } - }; - fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n'); - - // Run a fresh local install — this should trigger migration - runInstall(false); - - // Verify GSD entries are now in settings.local.json - const localSettingsPath = path.join(claudeDir, 'settings.local.json'); - assert.ok( - fs.existsSync(localSettingsPath), - '.claude/settings.local.json must exist after migration run' - ); - const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); - const sessionStartHooks = (localSettings.hooks && localSettings.hooks.SessionStart) || []; - const hasGsdUpdateHook = sessionStartHooks.some( - entry => entry && entry.hooks && Array.isArray(entry.hooks) && - entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update')) - ); - assert.ok( - hasGsdUpdateHook, - 'settings.local.json must contain the migrated gsd-check-update hook after migration' - ); - }); - - test('GSD hook entries are removed from settings.json after migration', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - const claudeDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const sharedSettingsPath = path.join(claudeDir, 'settings.json'); - const priorSettings = { - // Include a non-GSD key to verify user content is preserved - myCustomKey: 'keep-me', - hooks: { - SessionStart: [ - { - hooks: [ - { - type: 'command', - command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`, - } - ] - } - ] - } - }; - fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n'); - - runInstall(false); - - // settings.json must exist (we don't delete it — user may have other content) - assert.ok( - fs.existsSync(sharedSettingsPath), - '.claude/settings.json must still exist after migration (may have non-GSD user content)' - ); - const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf-8')); - - // GSD hooks must be gone from settings.json - const sessionStartHooks = (sharedSettings.hooks && sharedSettings.hooks.SessionStart) || []; - const hasGsdHook = sessionStartHooks.some( - entry => entry && entry.hooks && Array.isArray(entry.hooks) && - entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update')) - ); - assert.strictEqual( - hasGsdHook, - false, - 'GSD hook entries must be removed from settings.json after migration to settings.local.json' - ); - - // Non-GSD user content must be preserved - assert.strictEqual( - sharedSettings.myCustomKey, - 'keep-me', - 'Non-GSD user content in settings.json must be preserved during migration' - ); - }); - - test('settings.json with no GSD entries is left unchanged', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - const claudeDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const sharedSettingsPath = path.join(claudeDir, 'settings.json'); - const userOnlySettings = { - userKey: 'user-value', - hooks: { - SessionStart: [ - { - hooks: [ - { - type: 'command', - command: '/usr/local/bin/my-own-hook.sh', - } - ] - } - ] - } - }; - const originalContent = JSON.stringify(userOnlySettings, null, 2) + '\n'; - fs.writeFileSync(sharedSettingsPath, originalContent); - - runInstall(false); - - // settings.json must be unchanged (no GSD entries to migrate) - const afterContent = fs.readFileSync(sharedSettingsPath, 'utf-8'); - const afterSettings = JSON.parse(afterContent); - assert.strictEqual( - afterSettings.userKey, - 'user-value', - 'Non-GSD settings.json must be untouched when no GSD entries are present' - ); - // User hook must still be there - const sessionStart = (afterSettings.hooks && afterSettings.hooks.SessionStart) || []; - const hasUserHook = sessionStart.some( - entry => entry && entry.hooks && entry.hooks.some(h => h && h.command === '/usr/local/bin/my-own-hook.sh') - ); - assert.ok( - hasUserHook, - 'User hook in settings.json must be preserved when no migration occurs' - ); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-2957-claude-global-postinstall-message.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-2957-claude-global-postinstall-message (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2957: post-install message for `--claude --global` must instruct - * users to restart Claude Code and offer the skill-name fallback, since - * the skills-only install layout (CC 2.1.88+) leaves nothing in - * commands/gsd/ for the slash menu to read on older configurations. - * - * Captures the call to finishInstall(runtime='claude', isGlobal=true) and - * asserts the printed message contains both invocation paths. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const os = require('node:os'); - -const ROOT = path.join(__dirname, '..'); -const SETTINGS_PATH = path.join(os.tmpdir(), `gsd-test-settings-${process.pid}.json`); -const installModule = require(path.join(ROOT, 'bin', 'install.js')); - -function captureFinishInstallOutput(runtime, isGlobal) { - const original = console.log; - const lines = []; - console.log = (...args) => { lines.push(args.join(' ')); }; - try { - installModule.finishInstall( - SETTINGS_PATH, - {}, - null, - false, - runtime, - isGlobal, - null, - ); - } finally { - console.log = original; - } - // Strip ANSI color escapes so message-content assertions don't couple to colors. - // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output - return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, ''); -} - -describe('Bug #2957: claude+global post-install message', () => { - test('claude+global message tells the user to restart and offers skill-name fallback', () => { - const output = captureFinishInstallOutput('claude', true); - - assert.match(output, /restart claude code/i, 'should mention restart'); - assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project'); - assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback'); - assert.doesNotMatch( - output, - /open a blank directory/i, - 'global claude install should replace, not extend, the legacy generic instruction', - ); - }); - - test('claude+local message keeps the original /gsd-new-project instruction', () => { - const output = captureFinishInstallOutput('claude', false); - - assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project'); - assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note'); - }); - - test('non-claude runtimes keep their original message format', () => { - const output = captureFinishInstallOutput('opencode', true); - - assert.match(output, /Open a blank directory/, 'opencode message should be unchanged'); - assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note'); - }); -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-505-remove-dead-sdk-verification.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-505-remove-dead-sdk-verification (consolidation epic #1969 B1 #1970)", () => { -/** - * Regression guard for #505: dead SDK-shim verification subsystem removed. - * - * Post-ADR-0174 the `@opengsd/gsd-sdk` package was retired; `sdk/` no longer - * ships. `installSdkIfNeeded` and all functions it transitively called are - * dead code with no live callers. This test asserts: - * - * 1. All removed symbols are NO LONGER exported from bin/install.js. - * 2. The two live stale-standalone-SDK helpers (detectStaleStandaloneSdk, - * formatStaleStandaloneSdkWarning) are STILL exported as functions — they - * handle a real user-facing condition (#3406) and MUST NOT be removed. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const inst = require('../bin/install.js'); - -describe('bug #505: dead SDK verification subsystem removed from bin/install.js', () => { - // ---------------------------------------------------------------- - // Dead symbols — must NOT be exported after removal - // ---------------------------------------------------------------- - const deadSymbols = [ - 'installSdkIfNeeded', - 'classifySdkInstall', - 'buildSdkFailFastReport', - 'renderSdkFailFastReport', - 'buildGsdSdkVersionMismatchReport', - 'renderGsdSdkVersionMismatchReport', - 'readGsdSdkVersion', - 'parseGsdSdkVersion', - 'findGsdSdkOnPath', - 'isGsdSdkOnPath', - 'isLegacyGsdSdkShim', - 'trySelfLinkGsdSdk', - 'trySelfLinkGsdSdkWindows', - 'filterNpxFromPath', - 'getUserShellPath', - 'getUserShellWindowsPersistentPath', - ]; - - for (const sym of deadSymbols) { - test(`dead symbol '${sym}' is not exported`, () => { - assert.equal( - typeof inst[sym], - 'undefined', - `'${sym}' should have been removed (post #505 dead-code removal) but is still exported as ${typeof inst[sym]}`, - ); - }); - } - - // ---------------------------------------------------------------- - // The stale-standalone-SDK helpers (detectStaleStandaloneSdk, - // formatStaleStandaloneSdkWarning) and the gsd-sdk shim contract surface - // (buildWindowsShimTriple, formatSdkPathDiagnostic) that #505 kept were - // removed when the gsd-sdk shim itself was retired (#191). Their absence is - // covered by the dead-symbol assertions above. - // ---------------------------------------------------------------- -}); - }); -} - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs — consolidation epic #1969 (B1 #1970) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-376-claude-js-hook-gsd-rewriter (consolidation epic #1969 B1 #1970)", () => { -'use strict'; - -/** - * Regression for bug #376 — Claude-installed hook JS files ship with raw - * /gsd: command literals because the hook-copy loop in install.js had - * no /gsd: → /gsd- rewrite for the claude runtime. - * - * Fix: the `.js` branch of the hook-copy loop now applies - * `content.replace(/gsd:/gi, 'gsd-')` when - * `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` is true (covers - * claude, qwen, hermes). - * - * Test plan: - * 1. Claude install to tmp prefix — installed .js hook files must contain - * no user-facing /gsd: literals (// comment occurrences exempted). - * 2. Cursor install regression — still rewrites correctly (pre-existing - * branch must remain intact). - * 3. Source files in hooks/ must be byte-identical before and after both - * installs (install-time rewrite only, no in-tree mutation). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, after } = 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 { runNode } = require('./helpers/process-seam.cjs'); -const { throwIfFailed } = require('./helpers/git-fixture.cjs'); -const { cleanup } = require('./helpers.cjs'); -const { ensureHooksDist, HOOKS_DIST_DIR } = require('./helpers/hooks-dist.cjs'); - -const REPO_ROOT = path.resolve(__dirname, '..'); -const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); - -// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. -const { - INSTALL_TIMEOUT_MS, -} = require('./helpers/timeouts.cjs'); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Run `node install.js <...args>` from cwd. - * GSD_TEST_MODE is cleared so the install() main block executes. - */ -function runInstall(cwd, args) { - // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via - // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. - const { installSpawnEnv } = require('./helpers.cjs'); - const env = installSpawnEnv(); - delete env.GSD_TEST_MODE; - // 120s, not 60s. A full install copies and converts the whole shipped - // payload (117 workflows, 100 references, 34 agents, ~71 skills) and - // measures 13-30s on an idle runner — under 2x headroom at the old cap. - // On a loaded bench that margin is not enough: the Cursor suite's before - // hook died with `spawnSync ETIMEDOUT` on the node24 lane while the node22 - // lane passed the SAME commit in 12.7s, cancelling three child tests as - // collateral. The cap also shrinks in real terms every time a file joins - // the payload. Matches the 120s already used for the heavy install case - // below. Aligned with the other runInstall helper in this file. - const r = runNode([INSTALL_PATH, ...args], { - cwd, - env, - timeoutMs: INSTALL_TIMEOUT_MS, - }); - throwIfFailed(r, `node ${INSTALL_PATH} ${args.join(' ')}`); -} - -/** - * Return an array of { rel, path } for all .js files under dir. - */ -function findJsFiles(dir) { - const results = []; - function walk(d) { - for (const entry of fs.readdirSync(d, { withFileTypes: true })) { - const full = path.join(d, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs')) { - results.push({ rel: path.relative(dir, full), full }); - } - } - } - walk(dir); - return results; -} - -/** - * Split a JS file's lines into comment and non-comment buckets. - * A line is treated as a comment if it starts with optional whitespace - * followed by // (single-line comment). Block comments are not checked - * since none of the hook files use them for command refs. - */ -function nonCommentLines(content) { - return content.split('\n').filter(line => !/^\s*\/\//.test(line)); -} - -/** - * Return lines (from nonCommentLines) that contain a user-facing /gsd: ref. - */ -function colonRefs(content) { - return nonCommentLines(content).filter(line => /\/gsd:/.test(line)); -} - -// --------------------------------------------------------------------------- -// Prerequisite: hooks/dist must exist (built by `npm run build:hooks`) -// --------------------------------------------------------------------------- -describe('bug #376 — prerequisite: hooks/dist is present', () => { - before(() => { - // hooks/dist is gitignored; build it on demand so this test is - // deterministic in CI scoped/windows jobs that don't pre-run build:hooks. - ensureHooksDist(); - }); - - test('hooks/dist directory exists (run npm run build:hooks if missing)', () => { - assert.ok( - fs.existsSync(HOOKS_DIST_DIR), - `hooks/dist not found at ${HOOKS_DIST_DIR}. Run: npm run build:hooks`, - ); - }); - - test('hooks/dist contains at least one .js hook file with a /gsd: literal', () => { - const jsFiles = findJsFiles(HOOKS_DIST_DIR); - assert.ok(jsFiles.length > 0, 'hooks/dist must contain .js files'); - - const withColonRef = jsFiles.filter(({ full }) => { - const content = fs.readFileSync(full, 'utf-8'); - return colonRefs(content).length > 0; - }); - - assert.ok( - withColonRef.length > 0, - 'Expected at least one hooks/dist .js file with a non-comment /gsd: literal ' + - '— this confirms the test is guarding a real regression surface. ' + - `Files checked: ${jsFiles.map(f => f.rel).join(', ')}`, - ); - }); -}); - -// --------------------------------------------------------------------------- -// Suite 1 — Claude install: no /gsd: colon refs in installed .js hook files -// --------------------------------------------------------------------------- -describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook .js files', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-claude-')); - runInstall(tmpDir, ['--claude', '--local', '--no-sdk']); - }); - - after(() => { - cleanup(tmpDir); - }); - - test('1a: hooks/ directory is created by the Claude local install', () => { - const hooksDir = path.join(tmpDir, '.claude', 'hooks'); - assert.ok( - fs.existsSync(hooksDir), - `hooks/ must be created at ${hooksDir} by Claude local install`, - ); - }); - - test('1b: installed .js hook files contain no user-facing /gsd: colon refs', () => { - const hooksDir = path.join(tmpDir, '.claude', 'hooks'); - if (!fs.existsSync(hooksDir)) { - // If hooks/ wasn't created (hooks/dist missing at install time), skip gracefully - return; - } - - const jsFiles = findJsFiles(hooksDir); - assert.ok(jsFiles.length > 0, 'At least one .js hook file must be installed'); - - const offenders = []; - for (const { rel, full } of jsFiles) { - const content = fs.readFileSync(full, 'utf-8'); - const badLines = colonRefs(content); - if (badLines.length > 0) { - offenders.push({ rel, lines: badLines }); - } - } - - assert.deepEqual( - offenders, - [], - 'Installed Claude hook .js files must not contain /gsd: colon refs ' + - '(non-comment occurrences). The install-time rewriter must replace these with /gsd-. ' + - 'Offenders: ' + JSON.stringify(offenders, null, 2), - ); - }); - - test('1c: installed .js hook files DO contain the hyphen form /gsd- (rewrite happened)', () => { - const hooksDir = path.join(tmpDir, '.claude', 'hooks'); - if (!fs.existsSync(hooksDir)) return; - - const jsFiles = findJsFiles(hooksDir); - const withHyphen = jsFiles.filter(({ full }) => { - const content = fs.readFileSync(full, 'utf-8'); - return /\/gsd-/.test(content); - }); - - assert.ok( - withHyphen.length > 0, - 'At least one installed .js hook file must contain /gsd- (confirming rewrite ran). ' + - `Files checked: ${jsFiles.map(f => f.rel).join(', ')}`, - ); - }); -}); - -// --------------------------------------------------------------------------- -// Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing) -// -// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and -// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777). -// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh -// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files -// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's -// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/ -// directory contains only the Cursor-specific managed hooks. -// --------------------------------------------------------------------------- -describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-cursor-')); - runInstall(tmpDir, ['--cursor', '--local', '--no-sdk']); - }); - - after(() => { - cleanup(tmpDir); - }); - - test('2a: .cursor/ directory is created by the Cursor local install', () => { - const cursorDir = path.join(tmpDir, '.cursor'); - assert.ok( - fs.existsSync(cursorDir), - `Cursor install must create .cursor/ directory at ${cursorDir}`, - ); - }); - - test('2b: Cursor-installed .js files contain no user-facing /gsd: colon refs', () => { - const cursorDir = path.join(tmpDir, '.cursor'); - if (!fs.existsSync(cursorDir)) return; - - // Infrastructure files whose /gsd: occurrences are intentional implementation - // details — NOT user-facing command references that Cursor would invoke. - // - // scripts/fix-slash-commands.cjs is the slash-command rewriter engine, required - // by gsd-core/bin/lib/command-roster.cjs on ALL runtimes (including Cursor). - // It must be installed verbatim and must NOT be content-rewritten: it needs to - // emit `/gsd:${cmd}` for non-Cursor runtimes, and its /gsd: strings are internal - // implementation/docs (transform patterns, regex literals, template literals), - // not commands a Cursor user would type. Rewriting it would corrupt the transformer. - const INFRA_BASENAMES = new Set(['fix-slash-commands.cjs']); - - const jsFiles = findJsFiles(cursorDir); - // Cursor may not install any .js files depending on what agent/skill content exists; - // if none, skip gracefully. - if (jsFiles.length === 0) return; - - const offenders = []; - for (const { rel, full } of jsFiles) { - // Skip infrastructure files whose /gsd: strings are intentional (see above). - if (INFRA_BASENAMES.has(path.basename(full))) continue; - const content = fs.readFileSync(full, 'utf-8'); - const badLines = colonRefs(content); - if (badLines.length > 0) { - offenders.push({ rel, lines: badLines }); - } - } - - assert.deepEqual( - offenders, - [], - 'Cursor-installed .js files must not contain /gsd: colon refs. ' + - 'The existing Cursor branch in copyWithPathReplacement must still apply /gsd:/gi → gsd- rewrite. ' + - 'Offenders: ' + JSON.stringify(offenders, null, 2), - ); - }); - - test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => { - // Since issue #777, Cursor installs gsd-cursor-session-start.js and - // gsd-cursor-post-tool.js into /hooks/. These are Cursor-native - // hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.). - // Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks. - const hooksDir = path.join(tmpDir, '.cursor', 'hooks'); - assert.ok( - fs.existsSync(hooksDir), - 'Cursor install must create a hooks/ directory for its managed hook scripts (#777)', - ); - const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js']; - for (const hook of CLAUDE_BUNDLED_HOOKS) { - assert.strictEqual( - fs.existsSync(path.join(hooksDir, hook)), - false, - `Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`, - ); - } - // The two Cursor-specific managed hooks must be present. - assert.ok( - fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')), - 'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)', - ); - assert.ok( - fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')), - 'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)', - ); - }); -}); - -// --------------------------------------------------------------------------- -// Suite 3 — Source files in hooks/ are untouched -// --------------------------------------------------------------------------- -describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', () => { - let snapshotBefore; - - before(() => { - // Ensure hooks/dist is built before snapshotting; it may be absent in CI - // scoped/windows jobs that don't pre-run build:hooks (#777 fix). - ensureHooksDist(); - // Snapshot hooks/dist JS files before any install in this suite - snapshotBefore = {}; - if (fs.existsSync(HOOKS_DIST_DIR)) { - for (const { rel, full } of findJsFiles(HOOKS_DIST_DIR)) { - snapshotBefore[rel] = fs.readFileSync(full, 'utf-8'); - } - } - }); - - test('3a: hooks/dist .js source files still contain /gsd: literals (not mutated)', () => { - // The source must remain in colon form — the rewrite is install-time only - const jsFiles = findJsFiles(HOOKS_DIST_DIR); - const withColonRef = jsFiles.filter(({ full }) => { - const content = fs.readFileSync(full, 'utf-8'); - return colonRefs(content).length > 0; - }); - - // We know from the prerequisite suite that at least one file had a colon ref; - // if the source was mutated by install, this would now be zero. - assert.ok( - withColonRef.length > 0, - 'hooks/dist .js files must still contain /gsd: literals after install — ' + - 'the install-time rewrite must NOT modify the source tree. ' + - `Files that still have colon refs: ${withColonRef.map(f => f.rel).join(', ')}`, - ); - }); - - test('3b: hooks/dist .js source file contents match pre-test snapshot (byte-identical)', () => { - if (Object.keys(snapshotBefore).length === 0) { - // hooks/dist was absent before; skip - return; - } - - for (const [rel, before] of Object.entries(snapshotBefore)) { - const full = path.join(HOOKS_DIST_DIR, rel); - const after = fs.readFileSync(full, 'utf-8'); - assert.strictEqual( - after, - before, - `hooks/dist/${rel} was mutated by install — install must only rewrite the installed copy, not the source`, - ); - } - }); -}); - -// --------------------------------------------------------------------------- -// Suite 4 — Pure-function: shouldNormalizeHyphenNamespaceInAgentBody covers claude -// --------------------------------------------------------------------------- -describe('bug #376 — Suite 4: shouldNormalizeHyphenNamespaceInAgentBody covers claude', () => { - const install = require(INSTALL_PATH); - - test('4a: shouldNormalizeHyphenNamespaceInAgentBody is exported', () => { - assert.strictEqual( - typeof install.shouldNormalizeHyphenNamespaceInAgentBody, - 'function', - 'install.js must export shouldNormalizeHyphenNamespaceInAgentBody', - ); - }); - - test('4b: claude is in the hyphen-namespace set', () => { - assert.strictEqual( - install.shouldNormalizeHyphenNamespaceInAgentBody('claude'), - true, - 'claude must be a hyphen-namespace runtime', - ); - }); - - test('4c: qwen is in the hyphen-namespace set', () => { - assert.strictEqual( - install.shouldNormalizeHyphenNamespaceInAgentBody('qwen'), - true, - ); - }); - - test('4d: hermes is in the hyphen-namespace set', () => { - assert.strictEqual( - install.shouldNormalizeHyphenNamespaceInAgentBody('hermes'), - true, - ); - }); - - test('4e: gemini is NOT in the hyphen-namespace set', () => { - assert.strictEqual( - install.shouldNormalizeHyphenNamespaceInAgentBody('gemini'), - false, - 'gemini intentionally keeps colon namespace and must not be in the hyphen set', - ); - }); -}); - }); -} - - // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-1367-claude-local-flat-command-layout.test.cjs — consolidation epic #1969 (B6 #1975) // ──────────────────────────────────────────────────────────────────────── diff --git a/tests/prepush-enterprise-email-hook.test.cjs b/tests/prepush-enterprise-email-hook.test.cjs index 0b3f74732..fedfa8cdd 100644 --- a/tests/prepush-enterprise-email-hook.test.cjs +++ b/tests/prepush-enterprise-email-hook.test.cjs @@ -11,8 +11,12 @@ const { createTempDir, cleanup } = require('./helpers.cjs'); const ROOT = path.resolve(__dirname, '..'); const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-push'); -// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. -const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +// #3271: class-norm timeout, HOOK_FANOUT_TIMEOUT_MS not a per-suite value — see +// helpers/timeouts.cjs. This file is the fan-out class: the hook runs under +// `bash` and shells to a mock `git` that is itself a bash script, so a single +// runHook call is several nested spawns, not the single short probe the base +// PROBE_TIMEOUT_MS class describes. +const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); /** * Write a mock bash script to a .sh file in tmpDir and return its absolute path. @@ -60,7 +64,7 @@ exit 1 GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$', }, input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n', - timeoutMs: PROBE_TIMEOUT_MS, + timeoutMs: HOOK_FANOUT_TIMEOUT_MS, }); throwIfFailed(r, `bash ${HOOK_PATH}`); }, /Push blocked: commit author email matched local blocked regex/); @@ -93,7 +97,7 @@ exit 1 GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$', }, input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n', - timeoutMs: PROBE_TIMEOUT_MS, + timeoutMs: HOOK_FANOUT_TIMEOUT_MS, }); throwIfFailed(r, `bash ${HOOK_PATH}`); }); diff --git a/tests/process-seam.test.cjs b/tests/process-seam.test.cjs index 25da755ca..f0a1860c0 100644 --- a/tests/process-seam.test.cjs +++ b/tests/process-seam.test.cjs @@ -794,3 +794,32 @@ describe('runGsdTools adapter (process-seam parity)', () => { ); }); }); + +describe('#3271: hook fan-out timeout class', () => { + const { + PROBE_TIMEOUT_MS: PROBE, + HOOK_FANOUT_TIMEOUT_MS: HOOK_FANOUT, + INSTALL_TIMEOUT_MS: INSTALL, + } = require('./helpers/timeouts.cjs'); + + test('a hook fan-out is bounded above a bare probe and below a full install', () => { + // The ordering IS the claim: a hook that shells out several times is heavier + // than reading back a version string and lighter than running bin/install.js. + // CI recorded a Windows timeout at exactly the probe bound (PR #3285, + // windows-latest node 22 shard 2/3) while every other lane passed the same + // commit — the bound was sized for the wrong class. + assert.ok(PROBE < HOOK_FANOUT, `probe ${PROBE}ms must be under hook fan-out ${HOOK_FANOUT}ms`); + assert.ok(HOOK_FANOUT < INSTALL, `hook fan-out ${HOOK_FANOUT}ms must be under install ${INSTALL}ms`); + }); + + test('the fan-out bound clears the duration that actually timed out', () => { + // Observed: 15040ms, censored at the 15000ms probe bound, so the real need is + // unknown and above it. A bound that merely matched the observation would be + // the same defect again. + const OBSERVED_TIMEOUT_MS = 15040; + assert.ok( + HOOK_FANOUT >= OBSERVED_TIMEOUT_MS * 3, + `hook fan-out ${HOOK_FANOUT}ms must clear the censored ${OBSERVED_TIMEOUT_MS}ms observation with real margin`, + ); + }); +});