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`, + ); + }); +});