diff --git a/docs/reference/workflow-fragments.md b/docs/reference/workflow-fragments.md index f2c23841d..60b1a8b4e 100644 --- a/docs/reference/workflow-fragments.md +++ b/docs/reference/workflow-fragments.md @@ -610,7 +610,7 @@ steps (`backup_custom_files`, `restore_custom_files`) still depend on. `--rc`) is resolved in PARALLEL with, not in place of, `update.md`'s own `TAG="next"`/`TAG="latest"` case-statement in `parse_update_channel`, which stays byte-identical — issue #815's regression test -(`tests/issue-815-update-next-channel.test.cjs`) asserts that literal +(`tests/update-workflow.test.cjs`) asserts that literal case-statement text remains in the workflow, since the npm dist-tag selection has to run in the workflow's own shell before any `gsd_run` round-trip. diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 0b71e4bfa..0376ff9a5 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -77,9 +77,6 @@ "tests/intel.test.cjs :: source-text-is-the-product", "tests/inventory-headings-countfree.test.cjs :: source-text-is-the-product", "tests/ios-scaffold-safety.test.cjs :: source-text-is-the-product", - "tests/issue-429-comment-text-gate.test.cjs :: source-text-is-the-product", - "tests/issue-498-update-backup-runtime-dir.test.cjs :: source-text-is-the-product", - "tests/issue-815-update-next-channel.test.cjs :: source-text-is-the-product", "tests/legacy-cleanup.test.cjs :: integration-test-input", "tests/locking-bugs-1909-1916-1925-1927.test.cjs :: architectural-invariant", "tests/mcp-tool-inheritance.test.cjs :: source-text-is-the-product", diff --git a/scripts/lint-allow-test-rule-refs.ceiling.json b/scripts/lint-allow-test-rule-refs.ceiling.json index d9ef39660..cb1d9734a 100644 --- a/scripts/lint-allow-test-rule-refs.ceiling.json +++ b/scripts/lint-allow-test-rule-refs.ceiling.json @@ -1,4 +1,4 @@ { - "maxFiles": 305, + "maxFiles": 297, "grace": 3 } diff --git a/src/init.cts b/src/init.cts index 02beeb3e2..315663e14 100644 --- a/src/init.cts +++ b/src/init.cts @@ -2659,7 +2659,7 @@ function cmdInitDocsUpdate(cwd: string, raw: boolean, options: Record { }); }); }); + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2771-advisor-subagent-type.test.cjs — H3 Wave 6 (#3338) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-2771-advisor-subagent-type', () => { +// allow-test-rule: structural-implementation-guard (#2771) +'use strict'; + +// Regression guard for #2771: the discuss-phase advisor mode must spawn the REGISTERED +// `gsd-advisor-researcher` subagent (auto-loads the agent def), not `general-purpose` — +// which contradicts universal-anti-patterns rule 10 (injected into discuss-phase via +// ): "NEVER use non-GSD agent types — ALWAYS use gsd-{agent}". +// Spawning general-purpose + a manual "read the agent def" prompt re-specifies what the +// def already owns (a drift risk; the same shape assumptions's answer_validation hit). + +const ADVISOR_MD = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'advisor.md' +); + +test('advisor mode spawns the registered gsd-advisor-researcher subagent, not general-purpose (#2771)', () => { + const src = fs.readFileSync(ADVISOR_MD, 'utf8'); + + // Locate the Agent() block that researches gray areas. + const agentIdx = src.indexOf('subagent_type='); + assert.ok(agentIdx !== -1, 'advisor.md must contain an Agent() subagent_type declaration'); + + assert.ok( + src.includes('subagent_type="gsd-advisor-researcher"'), + 'advisor mode must spawn subagent_type="gsd-advisor-researcher" (the registered agent def auto-loads) — not general-purpose (#2771, universal-anti-patterns rule 10)' + ); + assert.ok( + !src.includes('subagent_type="general-purpose"'), + 'advisor mode must NOT spawn subagent_type="general-purpose" (contradicts universal-anti-patterns rule 10, injected into the same context) (#2771)' + ); + // The manual "read @.../gsd-advisor-researcher.md" prompt line must be gone — + // spawning by type auto-loads the def; re-specifying it is a drift risk. Deny the + // full class (any "read @" lead-in, case-insensitive) so a phrasing variant can't + // sneak the drift back in. + assert.ok( + !/read\s+@.*gsd-advisor-researcher\.md/i.test(src), + 'advisor mode must not manually instruct reading the agent def — spawning by type auto-loads it (#2771)' + ); +}); + }); +} + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2772-discuss-phase-text-inconsistencies.test.cjs — H3 Wave 6 (#3338) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-2772-discuss-phase-text-inconsistencies', () => { +// allow-test-rule: structural-implementation-guard (#2772) +'use strict'; + +// Regression guard for #2772: four self-contained text inconsistencies in the +// discuss-phase surface, each a literal-instruction hazard. The shipped markdown IS +// the runtime contract, so structural inspection is the correct guard. + +const ROOT = path.join(__dirname, '..'); +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); + +test('auto.md does not read the dead MAX_PASSES / max_discuss_passes config (#2772.1)', () => { + const src = read('gsd-core/workflows/discuss-phase/modes/auto.md'); + assert.ok(/single pass/i.test(src), 'auto.md must still mandate the single-pass rule'); + assert.ok(!/MAX_PASSES=/.test(src), 'auto.md must not read MAX_PASSES (dead config — single-pass rule governs) (#2772)'); + assert.ok(!/max_discuss_passes/.test(src), 'auto.md must not reference max_discuss_passes (contradicts the single-pass rule) (#2772)'); +}); + +test('gate-prompts context-handling matches the actual check_existing options (#2772.2)', () => { + const src = read('gsd-core/references/gate-prompts.md'); + const ctx = src.slice(src.indexOf('## Pattern: context-handling'), src.indexOf('## Pattern: gray-area-option')); + assert.ok(/Update it \| View it \| Skip/.test(ctx), 'context-handling options must be "Update it | View it | Skip" (the actual check_existing flow) (#2772)'); + assert.ok(!/Overwrite \| Append \| Cancel/.test(ctx), 'context-handling must NOT document the obsolete "Overwrite | Append | Cancel" (#2772)'); +}); + +test('gate-prompts gray-area-option does not mandate "Let Claude decide" (#2772.2)', () => { + const src = read('gsd-core/references/gate-prompts.md'); + const gray = src.slice(src.indexOf('## Pattern: gray-area-option')); + assert.ok(!/Always include "Let Claude decide"/i.test(gray), 'gray-area-option must NOT mandate "Let Claude decide" — it contradicts discuss-phase.md:353 ("Do NOT include a skip or you decide option") (#2772)'); +}); + +test('discuss-phase auto_advance fallback ends the workflow, not routes back to confirm_creation (#2772.3)', () => { + const src = read('gsd-core/workflows/discuss-phase.md'); + const step = src.slice(src.indexOf(''), src.indexOf('', src.indexOf(''))); + assert.ok(!/route to `confirm_creation`/.test(step), 'auto_advance fallback must not route back to confirm_creation (it already ran earlier in the step order — circular) (#2772)'); + assert.ok(/end here|workflow is complete/i.test(step), 'auto_advance fallback must explicitly END the workflow (positive anchor — a re-phrased regression should not slip past) (#2772)'); +}); + +test('discuss-phase-assumptions auto_advance fallback also ends the workflow (sibling of #2772.3)', () => { + const src = read('gsd-core/workflows/discuss-phase-assumptions.md'); + const step = src.slice(src.indexOf(''), src.indexOf('', src.indexOf(''))); + assert.ok(!/Route to confirm_creation step/.test(step), 'assumptions auto_advance fallback must not route back to confirm_creation (same circularity as the parent) (#2772)'); + assert.ok(/end here|workflow is complete/i.test(step), 'assumptions auto_advance fallback must explicitly END the workflow (#2772)'); +}); + +test('discuss-phase-assumptions answer_validation matches the parent canonical content (#2772.4)', () => { + const parent = read('gsd-core/workflows/discuss-phase.md'); + const assumptions = read('gsd-core/workflows/discuss-phase-assumptions.md'); + // The parent's canonical answer_validation includes the "Other" empty-text branch. + const parentBlock = parent.slice(parent.indexOf(''), parent.indexOf('') + ''.length); + const assumptionsBlock = assumptions.slice(assumptions.indexOf(''), assumptions.indexOf('') + ''.length); + assert.ok(/"Other" with empty text/.test(assumptionsBlock), 'assumptions answer_validation must include the "Other" empty-text branch (was drifted) (#2772)'); + // The two blocks must now agree on the empty-response handling. + assert.strictEqual(assumptionsBlock, parentBlock, 'discuss-phase-assumptions answer_validation must match the parent canonical block exactly (single source of truth) (#2772)'); +}); + }); +} diff --git a/tests/issue-2701-nul-corrupted-validators.test.cjs b/tests/issue-2701-nul-corrupted-validators.test.cjs deleted file mode 100644 index 700144687..000000000 --- a/tests/issue-2701-nul-corrupted-validators.test.cjs +++ /dev/null @@ -1,212 +0,0 @@ -// Regression tests for #2701 — plan/summary/verification/state validators silently -// accept NUL-corrupted files and report valid:true. -// -// A NUL-corrupted text artifact is binary-classified by file(1) and silently -// OMITTED from recursive / binary-skipping search results (rg -l, grep -rI, -// exit 0), so the corruption reads downstream as "file absent" rather than -// "file corrupt." The validators must fail loud, naming the encoding problem and -// its consequence, before any schema/structure check. The fix is at the -// validator entry points (a shared textEncodingError helper in validate.cjs), -// NOT inside the broadly-shared platformReadSync read primitive. -// -// NUL bytes are written via Buffer so they survive onto disk (a string write -// would not). Cleanup via t.after(() => cleanup(tmpDir)). - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { writeState } = require('./fixtures/index.cjs'); - -// A structurally-complete PLAN.md that passes both validators when clean. -function validPlanBody() { - return [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [some/file.ts]', - 'autonomous: true', - 'must_haves:', - ' truths:', - ' - "something is true"', - '---', - '', - '', - '', - '', - ' Task 1: Do something', - ' some/file.ts', - ' Do the thing', - ' npx vitest run', - ' Thing is done', - '', - '', - '', - ].join('\n'); -} - -/** Write `body` to a fresh phase plan path, optionally injecting a NUL at `nulAt`. */ -function writePlan(tmpDir, name, body, nulAt) { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); - const p = path.join(tmpDir, '.planning', 'phases', '01-test', name); - let buf = Buffer.from(body, 'utf8'); - if (nulAt !== undefined) { - buf = Buffer.concat([buf.subarray(0, nulAt), Buffer.from([0x00]), buf.subarray(nulAt)]); - } - fs.writeFileSync(p, buf); - return p; -} - -function parseResult(t, argv, tmpDir) { - const r = runGsdTools(argv, tmpDir); - assert.ok(r.success, `command failed: ${r.error}`); - return JSON.parse(r.output); -} - -// ─── frontmatter validate --schema plan|summary|verification ──────────────── - -describe('#2701: frontmatter validate rejects NUL-corrupted artifacts', () => { - test('PLAN.md with an embedded NUL byte → valid:false, error names encoding + consequence', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const rel = '.planning/phases/01-test/01-01-PLAN.md'; - writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), 200); - - const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir); - assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`); - assert.ok(Array.isArray(out.errors) && out.errors.length > 0, 'must report errors'); - const msg = out.errors.join(' '); - assert.ok(/NUL/i.test(msg), `error must name NUL/encoding: ${msg}`); - assert.ok(/skip|search|absent|missing/i.test(msg), `error must name the downstream consequence: ${msg}`); - }); - - test('SUMMARY.md with an embedded NUL byte → valid:false', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const dir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(dir, { recursive: true }); - const body = ['---', 'phase: 01-test', 'plan: 01', 'status: in_progress', '---', '', '# Summary', 'did the work'].join('\n'); - const buf = Buffer.concat([Buffer.from(body, 'utf8').subarray(0, 30), Buffer.from([0x00]), Buffer.from(body, 'utf8').subarray(30)]); - fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), buf); - - const out = parseResult(t, ['frontmatter', 'validate', '.planning/phases/01-test/01-01-SUMMARY.md', '--schema', 'summary'], tmpDir); - assert.strictEqual(out.valid, false); - assert.ok(out.errors.some((e) => /NUL/i.test(e))); - }); - - test('VERIFICATION.md with an embedded NUL byte → valid:false', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const dir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(dir, { recursive: true }); - const body = ['---', 'phase: 01-test', 'plan: 01', 'status: passed', '---', '', '# Verification', 'all green'].join('\n'); - const buf = Buffer.concat([Buffer.from(body, 'utf8').subarray(0, 40), Buffer.from([0x00]), Buffer.from(body, 'utf8').subarray(40)]); - fs.writeFileSync(path.join(dir, '01-01-VERIFICATION.md'), buf); - - const out = parseResult(t, ['frontmatter', 'validate', '.planning/phases/01-test/01-01-VERIFICATION.md', '--schema', 'verification'], tmpDir); - assert.strictEqual(out.valid, false); - assert.ok(out.errors.some((e) => /NUL/i.test(e))); - }); -}); - -// ─── verify plan-structure ────────────────────────────────────────────────── - -describe('#2701: verify plan-structure rejects NUL-corrupted PLAN.md', () => { - test('PLAN.md with an embedded NUL byte → valid:false, error names encoding', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const rel = '.planning/phases/01-test/01-01-PLAN.md'; - writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), 200); - - const out = parseResult(t, ['verify', 'plan-structure', rel], tmpDir); - assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`); - assert.ok(out.errors.some((e) => /NUL/i.test(e)), `error must name NUL: ${JSON.stringify(out.errors)}`); - }); -}); - -// ─── state validate ───────────────────────────────────────────────────────── - -describe('#2701: state validate rejects NUL-corrupted STATE.md', () => { - test('STATE.md with an embedded NUL byte → valid:false', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - // createTempProject() does NOT seed STATE.md; use writeState to create one, - // then corrupt it in place with a NUL byte (Buffer write so it survives). - const seed = [ - '# Project', - '', - '## Status', - 'executing', - '## Current Phase', - '01 of 01', - '## Total Plans in Phase', - '1', - ].join('\n'); - const statePath = writeState(tmpDir, seed); - const body = Buffer.from(seed, 'utf8'); - const buf = Buffer.concat([body.subarray(0, 50), Buffer.from([0x00]), body.subarray(50)]); - fs.writeFileSync(statePath, buf); - - const out = parseResult(t, ['state', 'validate'], tmpDir); - assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`); - assert.ok(out.warnings.some((w) => /NUL/i.test(w)), `warning must name NUL: ${JSON.stringify(out.warnings)}`); - }); -}); - -// ─── negative space: clean files still pass; non-ASCII UTF-8 not over-rejected ─ - -describe('#2701: clean and valid-UTF-8 files are not over-rejected', () => { - test('clean PLAN.md (no NUL) → frontmatter validate valid:true', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const rel = '.planning/phases/01-test/01-01-PLAN.md'; - writePlan(tmpDir, '01-01-PLAN.md', validPlanBody()); - - const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir); - assert.strictEqual(out.valid, true, `clean plan must pass; got ${JSON.stringify(out)}`); - }); - - test('clean PLAN.md (no NUL) → verify plan-structure valid:true', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const rel = '.planning/phases/01-test/01-01-PLAN.md'; - writePlan(tmpDir, '01-01-PLAN.md', validPlanBody()); - - const out = parseResult(t, ['verify', 'plan-structure', rel], tmpDir); - assert.strictEqual(out.valid, true, `clean plan must pass; got ${JSON.stringify(out)}`); - }); - - test('non-ASCII UTF-8 (é, emoji) without NUL is NOT rejected', (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const rel = '.planning/phases/01-test/01-01-PLAN.md'; - // High bytes are valid UTF-8; only a NUL (0x00) is the corruption signal. - const body = validPlanBody().replace('Do the thing', 'Do the thing — café ☕ naïve'); - writePlan(tmpDir, '01-01-PLAN.md', body); - - const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir); - assert.strictEqual(out.valid, true, `valid UTF-8 high bytes must not be rejected; got ${JSON.stringify(out)}`); - }); -}); - -// ─── boundary: NUL at offset 0 and mid-file both rejected ─────────────────── - -describe('#2701: NUL position does not matter (start and middle both rejected)', () => { - for (const nulAt of [0, 5, 250]) { - test(`NUL at offset ${nulAt} → frontmatter validate valid:false`, (t) => { - const tmpDir = createTempProject(); - t.after(() => cleanup(tmpDir)); - const rel = '.planning/phases/01-test/01-01-PLAN.md'; - writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), nulAt); - - const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir); - assert.strictEqual(out.valid, false, `NUL at offset ${nulAt} must be rejected; got ${JSON.stringify(out)}`); - }); - } -}); diff --git a/tests/issue-2762-plan-reviews-chunked.test.cjs b/tests/issue-2762-plan-reviews-chunked.test.cjs deleted file mode 100644 index 5ba56f8c2..000000000 --- a/tests/issue-2762-plan-reviews-chunked.test.cjs +++ /dev/null @@ -1,62 +0,0 @@ -// allow-test-rule: structural-implementation-guard (#2762) -'use strict'; - -// Regression guard for #2762: /gsd-plan-phase --reviews was a silent no-op in chunked -// mode. Two defects in plan-phase.md §8.5: -// A. §8.5.1 outline resume-check greps for a marker the agent only RETURNED (never -// wrote to the file) → outline always re-ran/overwrote (broke crash-resume). -// B. §8.5.2 per-plan resume-check skipped any plan with frontmatter, with no -// --reviews exception → --reviews skipped 100% of plans (contradicted §6's -// "go straight to replanning" contract). - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -// #2993 fragmentization moved §8.5 (chunked planning mode, including §8.5.1 / -// §8.5.2) out of plan-phase.md into gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md. -// Read that step file directly — it is the sole remaining source of the §8.5.1/§8.5.2 -// content these regression guards assert on. -const MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md'); -const read = () => fs.readFileSync(MD, 'utf8'); - -test('§8.5.1 outline agent writes the resume marker into the file (#2762 defect A)', () => { - const src = read(); - // The outline prompt must instruct writing ## OUTLINE COMPLETE into PLAN-OUTLINE.md - // (the §8.5.1 resume-check greps for it in the file). - const outlineSection = src.slice(src.indexOf('### 8.5.1'), src.indexOf('### 8.5.2')); - assert.ok( - /outline|PLAN-OUTLINE/i.test(outlineSection) && /End the file.*## OUTLINE COMPLETE|write.*## OUTLINE COMPLETE.*file/i.test(outlineSection.replace(/\s+/g, ' ')), - 'the outline agent prompt must instruct writing ## OUTLINE COMPLETE into the file (the resume-check greps the file for it) (#2762)' - ); -}); - -test('§8.5.2 per-plan resume-check does NOT skip under --reviews (#2762 defect B)', () => { - const src = read(); - const perPlanSection = src.slice(src.indexOf('### 8.5.2')); - // The resume-check bash must gate the skip on --reviews being ABSENT. - const bashMatch = perPlanSection.match(/PLAN_FILE=[\s\S]*?fi\s*\n/); - assert.ok(bashMatch, '§8.5.2 must contain the per-plan resume-check bash block'); - const bash = bashMatch[0]; - assert.ok( - /--reviews/.test(bash), - 'the per-plan resume-check must reference --reviews so it does NOT skip when replanning with review feedback (#2762)' - ); - // The skip must be conditional on --reviews being ABSENT (e.g. ARGUMENTS != *"--reviews"*). - assert.ok( - /!=\s*\*"--reviews"\*|!~.*--reviews|--reviews.*absent|not.*--reviews/i.test(bash), - 'the resume-check skip must be gated on --reviews being ABSENT (so --reviews overwrites/replans) (#2762)' - ); -}); - -test('§8.5.2 crash-resume (non-reviews) still skips written plans (#2762 negative space)', () => { - const src = read(); - const perPlanSection = src.slice(src.indexOf('### 8.5.2')); - const bashMatch = perPlanSection.match(/PLAN_FILE=[\s\S]*?fi\s*\n/); - const bash = bashMatch ? bashMatch[0] : ''; - assert.ok( - /head -1.*grep.*\^---|frontmatter/i.test(bash + perPlanSection.slice(0, 400)), - 'crash-resume (non-reviews) must still skip plans with valid frontmatter (resume safety preserved) (#2762)' - ); -}); diff --git a/tests/issue-2765-brace-expansion-lockfile.test.cjs b/tests/issue-2765-brace-expansion-lockfile.test.cjs deleted file mode 100644 index 061e0f6b4..000000000 --- a/tests/issue-2765-brace-expansion-lockfile.test.cjs +++ /dev/null @@ -1,57 +0,0 @@ -// allow-test-rule: structural-implementation-guard (#2765) -'use strict'; - -// Regression guard for #2765: the lockfile must pin the patched brace-expansion -// versions (>=1.1.18 for the 1.x line, >=5.0.9 for the 5.x line) published 2026-07-30 -// to resolve the high-severity DoS/OOM advisories (GHSA-3jxr-9vmj-r5cp / -// GHSA-mh99-v99m-4gvg, range <=5.0.7). This is a lockfile-only devDependency bump -// (eslint/stryker → minimatch → brace-expansion); production (npm audit --omit=dev) is -// unaffected. The test pins the installed versions so the bump can't silently regress. - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const { execFileSync } = require('node:child_process'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); - -// `npm` is not process.execPath, git, or a bash script/hook, so this does not -// route through tests/helpers/process-seam.cjs (whose runNode/runGit/runHook -// primitives cover exactly those three shapes and forward no `shell` option) -// — `npm` needs `shell: true` on Windows (npm.cmd), which the seam has no -// surface for. Bounding this directly with an explicit `timeout` is the -// documented alternative in eslint-rules/no-unbounded-spawn.cjs. -const NPM_LS_TIMEOUT_MS = 30000; - -function npmLs(pkg) { - // `npm ls --json --all` lists every installed copy with its version. Collect - // the version of every node whose key is `pkg` (not the parent packages). - const out = execFileSync('npm', ['ls', pkg, '--json', '--all'], { - cwd: ROOT, encoding: 'utf8', shell: true, stdio: ['ignore', 'pipe', 'ignore'], - timeout: NPM_LS_TIMEOUT_MS, - }); - const versions = []; - const walk = (node) => { - if (!node || !node.dependencies) return; - for (const [k, v] of Object.entries(node.dependencies)) { - if (k === pkg && v && v.version) versions.push(v.version); - walk(v); - } - }; - walk(JSON.parse(out)); - return versions; -} - -test('all installed brace-expansion copies are patched (>=1.1.18 / >=5.0.9) — #2765', () => { - const versions = npmLs('brace-expansion'); - assert.ok(versions.length > 0, 'brace-expansion must be installed (devDependency) to guard'); - for (const v of versions) { - const [maj, min, pat] = v.split('.').map(Number); - const ok = (maj === 1 && (min > 1 || (min === 1 && pat >= 18))) // 1.x >= 1.1.18 - || (maj === 5 && (min > 0 || pat >= 9)) // 5.x >= 5.0.9 - || (maj > 5); // >5.x - assert.ok(ok, - `brace-expansion@${v} is within the vulnerable range (<=5.0.7) — lockfile regressed the #2765 patch bump. ` + - 'Re-apply: npm audit fix (non-breaking) to bump to 1.1.18 / 5.0.9.'); - } -}); diff --git a/tests/issue-2771-advisor-subagent-type.test.cjs b/tests/issue-2771-advisor-subagent-type.test.cjs deleted file mode 100644 index c1b41e692..000000000 --- a/tests/issue-2771-advisor-subagent-type.test.cjs +++ /dev/null @@ -1,43 +0,0 @@ -// allow-test-rule: structural-implementation-guard (#2771) -'use strict'; - -// Regression guard for #2771: the discuss-phase advisor mode must spawn the REGISTERED -// `gsd-advisor-researcher` subagent (auto-loads the agent def), not `general-purpose` — -// which contradicts universal-anti-patterns rule 10 (injected into discuss-phase via -// ): "NEVER use non-GSD agent types — ALWAYS use gsd-{agent}". -// Spawning general-purpose + a manual "read the agent def" prompt re-specifies what the -// def already owns (a drift risk; the same shape assumptions's answer_validation hit). - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const ADVISOR_MD = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'advisor.md' -); - -test('advisor mode spawns the registered gsd-advisor-researcher subagent, not general-purpose (#2771)', () => { - const src = fs.readFileSync(ADVISOR_MD, 'utf8'); - - // Locate the Agent() block that researches gray areas. - const agentIdx = src.indexOf('subagent_type='); - assert.ok(agentIdx !== -1, 'advisor.md must contain an Agent() subagent_type declaration'); - - assert.ok( - src.includes('subagent_type="gsd-advisor-researcher"'), - 'advisor mode must spawn subagent_type="gsd-advisor-researcher" (the registered agent def auto-loads) — not general-purpose (#2771, universal-anti-patterns rule 10)' - ); - assert.ok( - !src.includes('subagent_type="general-purpose"'), - 'advisor mode must NOT spawn subagent_type="general-purpose" (contradicts universal-anti-patterns rule 10, injected into the same context) (#2771)' - ); - // The manual "read @.../gsd-advisor-researcher.md" prompt line must be gone — - // spawning by type auto-loads the def; re-specifying it is a drift risk. Deny the - // full class (any "read @" lead-in, case-insensitive) so a phrasing variant can't - // sneak the drift back in. - assert.ok( - !/read\s+@.*gsd-advisor-researcher\.md/i.test(src), - 'advisor mode must not manually instruct reading the agent def — spawning by type auto-loads it (#2771)' - ); -}); diff --git a/tests/issue-2772-discuss-phase-text-inconsistencies.test.cjs b/tests/issue-2772-discuss-phase-text-inconsistencies.test.cjs deleted file mode 100644 index 4e9c79801..000000000 --- a/tests/issue-2772-discuss-phase-text-inconsistencies.test.cjs +++ /dev/null @@ -1,59 +0,0 @@ -// allow-test-rule: structural-implementation-guard (#2772) -'use strict'; - -// Regression guard for #2772: four self-contained text inconsistencies in the -// discuss-phase surface, each a literal-instruction hazard. The shipped markdown IS -// the runtime contract, so structural inspection is the correct guard. - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const ROOT = path.join(__dirname, '..'); -const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); - -test('auto.md does not read the dead MAX_PASSES / max_discuss_passes config (#2772.1)', () => { - const src = read('gsd-core/workflows/discuss-phase/modes/auto.md'); - assert.ok(/single pass/i.test(src), 'auto.md must still mandate the single-pass rule'); - assert.ok(!/MAX_PASSES=/.test(src), 'auto.md must not read MAX_PASSES (dead config — single-pass rule governs) (#2772)'); - assert.ok(!/max_discuss_passes/.test(src), 'auto.md must not reference max_discuss_passes (contradicts the single-pass rule) (#2772)'); -}); - -test('gate-prompts context-handling matches the actual check_existing options (#2772.2)', () => { - const src = read('gsd-core/references/gate-prompts.md'); - const ctx = src.slice(src.indexOf('## Pattern: context-handling'), src.indexOf('## Pattern: gray-area-option')); - assert.ok(/Update it \| View it \| Skip/.test(ctx), 'context-handling options must be "Update it | View it | Skip" (the actual check_existing flow) (#2772)'); - assert.ok(!/Overwrite \| Append \| Cancel/.test(ctx), 'context-handling must NOT document the obsolete "Overwrite | Append | Cancel" (#2772)'); -}); - -test('gate-prompts gray-area-option does not mandate "Let Claude decide" (#2772.2)', () => { - const src = read('gsd-core/references/gate-prompts.md'); - const gray = src.slice(src.indexOf('## Pattern: gray-area-option')); - assert.ok(!/Always include "Let Claude decide"/i.test(gray), 'gray-area-option must NOT mandate "Let Claude decide" — it contradicts discuss-phase.md:353 ("Do NOT include a skip or you decide option") (#2772)'); -}); - -test('discuss-phase auto_advance fallback ends the workflow, not routes back to confirm_creation (#2772.3)', () => { - const src = read('gsd-core/workflows/discuss-phase.md'); - const step = src.slice(src.indexOf(''), src.indexOf('', src.indexOf(''))); - assert.ok(!/route to `confirm_creation`/.test(step), 'auto_advance fallback must not route back to confirm_creation (it already ran earlier in the step order — circular) (#2772)'); - assert.ok(/end here|workflow is complete/i.test(step), 'auto_advance fallback must explicitly END the workflow (positive anchor — a re-phrased regression should not slip past) (#2772)'); -}); - -test('discuss-phase-assumptions auto_advance fallback also ends the workflow (sibling of #2772.3)', () => { - const src = read('gsd-core/workflows/discuss-phase-assumptions.md'); - const step = src.slice(src.indexOf(''), src.indexOf('', src.indexOf(''))); - assert.ok(!/Route to confirm_creation step/.test(step), 'assumptions auto_advance fallback must not route back to confirm_creation (same circularity as the parent) (#2772)'); - assert.ok(/end here|workflow is complete/i.test(step), 'assumptions auto_advance fallback must explicitly END the workflow (#2772)'); -}); - -test('discuss-phase-assumptions answer_validation matches the parent canonical content (#2772.4)', () => { - const parent = read('gsd-core/workflows/discuss-phase.md'); - const assumptions = read('gsd-core/workflows/discuss-phase-assumptions.md'); - // The parent's canonical answer_validation includes the "Other" empty-text branch. - const parentBlock = parent.slice(parent.indexOf(''), parent.indexOf('') + ''.length); - const assumptionsBlock = assumptions.slice(assumptions.indexOf(''), assumptions.indexOf('') + ''.length); - assert.ok(/"Other" with empty text/.test(assumptionsBlock), 'assumptions answer_validation must include the "Other" empty-text branch (was drifted) (#2772)'); - // The two blocks must now agree on the empty-response handling. - assert.strictEqual(assumptionsBlock, parentBlock, 'discuss-phase-assumptions answer_validation must match the parent canonical block exactly (single source of truth) (#2772)'); -}); diff --git a/tests/issue-429-comment-text-gate.test.cjs b/tests/issue-429-comment-text-gate.test.cjs deleted file mode 100644 index e0771de5d..000000000 --- a/tests/issue-429-comment-text-gate.test.cjs +++ /dev/null @@ -1,711 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Issue #429: the gate logic is tested behaviorally via the exported pure -// function + runGsdTools; the discipline rule + allowlist escape hatch are -// asserted against the agent/reference .md whose text IS the deployed contract. - -'use strict'; - -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 { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -// Build path to built verify.cjs -const VERIFY_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs'); - -// fast-check: loaded at top level so skip flags evaluate correctly -let fc; -try { fc = require('fast-check'); } catch { fc = null; } -// Build path to agent/reference files -const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); -const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md'); - -// ─── Fixtures ────────────────────────────────────────────────────────────────── - -function makePlan({ negativeGrep, actionEcho, allowlistMarker, positiveGrep } = {}) { - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [src/animal-detail.tsx]', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '# Test Plan', - '', - ]; - - if (allowlistMarker) { - lines.push(allowlistMarker, ''); - } - - lines.push(''); - lines.push('Test task'); - lines.push(''); - if (actionEcho) { - lines.push(actionEcho); - } else { - lines.push('Do the work.'); - } - lines.push(''); - - if (positiveGrep) { - lines.push(`${positiveGrep}`); - } else if (negativeGrep) { - lines.push(`${negativeGrep}`); - } else { - lines.push('npm test'); - } - - lines.push('Task complete'); - lines.push(''); - - return lines.join('\n'); -} - -// ─── Group 1: pure-function unit tests ──────────────────────────────────────── - -describe('scanNegativeGrepCommentEcho — pure unit tests', () => { - let scanNegativeGrepCommentEcho; - - before(() => { - const verify = require(VERIFY_CJS); - scanNegativeGrepCommentEcho = verify.scanNegativeGrepCommentEcho; - }); - - test('case 1 — regression Plan 12-04: action echoes the forbidden literal', () => { - const content = makePlan({ - negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", - actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', - }); - const result = scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('?from='), `error should mention ?from=, got: ${result.errors[0]}`); - }); - - test('case 2 — regression Plan 11-04: JSDoc head-comment echoes CardModalHost', () => { - const content = makePlan({ - negativeGrep: "grep -c 'CardModalHost' file == 0", - actionEcho: '* @see CardModalHost for the deprecated pattern.', - }); - const result = scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('CardModalHost'), `error should mention CardModalHost, got: ${result.errors[0]}`); - }); - - test('case 3 — regression Plan 12-02: head-comment echoes .catch(() => null) (regex-special chars)', () => { - const content = makePlan({ - negativeGrep: "grep -c '.catch(() => null)' file == 0", - actionEcho: '// Old pattern: .catch(() => null)', - }); - const result = scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('.catch(() => null)'), `error should mention the literal, got: ${result.errors[0]}`); - }); - - test('case 4 — boundary: positive count gate (== 60) must NOT be flagged (AC#2)', () => { - const content = makePlan({ - positiveGrep: "grep -c '= makeParallel(' file == 60", - actionEcho: 'Use makeParallel() for concurrent processing.', - }); - const result = scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 0, `positive count gate must not flag, errors: ${JSON.stringify(result.errors)}`); - }); - - test('case 5 — no echo: literal only in verify, not in action', () => { - const content = makePlan({ - negativeGrep: "grep -c 'LEGACY_TOKEN' file == 0", - actionEcho: 'Remove the old token handling.', - }); - const result = scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 0, 'should be no errors'); - assert.strictEqual(result.warnings.length, 0, 'should be no warnings'); - }); - - test('case 6 — allowlist marker suppresses the error', () => { - const content = makePlan({ - negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", - actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', - allowlistMarker: '', - }); - const result = scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 0, `allowlist should suppress error, got: ${JSON.stringify(result.errors)}`); - }); - - test('case 7 — ambiguous unquoted bareword echo: warning not error', () => { - const content = makePlan({ - negativeGrep: 'grep -c badToken file == 0', - actionEcho: 'Remove badToken from codebase.', - }); - const result = scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 0, `ambiguous token must not error, got: ${JSON.stringify(result.errors)}`); - assert.strictEqual(result.warnings.length, 1, `ambiguous token should warn once, got: ${JSON.stringify(result.warnings)}`); - assert.ok(result.warnings[0].includes('badToken'), `warning should mention badToken, got: ${result.warnings[0]}`); - }); - - test('case 8 — negative-grep command inside an does NOT self-flag', () => { - // action tells executor to ADD the verify command — the grep itself is in the action - // but there is no echo of selfToken outside the grep command - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [file.ts]', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '', - 'Add verify command', - '', - "Add this to the CI script: grep -c 'selfToken' file == 0", - '', - 'npm test', - 'Done', - '', - ].join('\n'); - const verify = require(VERIFY_CJS); - const r = verify.scanNegativeGrepCommentEcho(lines); - assert.strictEqual(r.errors.length, 0, `grep command in action must not self-flag, errors: ${JSON.stringify(r.errors)}`); - }); - - test('case 9 — CRLF newlines are normalized', () => { - const content = makePlan({ - negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", - actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', - }); - const crlfContent = content.split('\n').join('\r\n'); - const result = scanNegativeGrepCommentEcho(crlfContent); - assert.strictEqual(result.errors.length, 1, `CRLF content should still find error, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('?from=')); - }); - - test('case 10 — multiple distinct echoed literals each produce their own error', () => { - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [file.ts]', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '', - 'Multi literal task', - '', - "Remove tokA and tokB from the codebase.", - '', - "grep -c 'tokA' file == 0 && grep -c 'tokB' file == 0", - 'Done', - '', - ].join('\n'); - const verify = require(VERIFY_CJS); - const result = verify.scanNegativeGrepCommentEcho(lines); - assert.strictEqual(result.errors.length, 2, `expected 2 errors (one per literal), got: ${JSON.stringify(result.errors)}`); - }); - - test('case 11 — != 0 and >= 0 are NOT negative gates', () => { - const verify = require(VERIFY_CJS); - const content1 = makePlan({ - negativeGrep: "grep -c 'nz' file != 0", - actionEcho: 'Ensure nz is present.', - }); - const r1 = verify.scanNegativeGrepCommentEcho(content1); - assert.strictEqual(r1.errors.length, 0, `!= 0 must not trigger, errors: ${JSON.stringify(r1.errors)}`); - - const content2 = makePlan({ - negativeGrep: "grep -c 'nz' file >= 0", - actionEcho: 'Ensure nz is present.', - }); - const r2 = verify.scanNegativeGrepCommentEcho(content2); - assert.strictEqual(r2.errors.length, 0, `>= 0 must not trigger, errors: ${JSON.stringify(r2.errors)}`); - }); - - // ── Bug-fix regression tests (adversarial-review findings) ─────────────────── - - test('case 12 — mixed positive+negative on one line: no false positive for positive gate token', () => { - // Bug 1: mixed positive+negative greps on one physical line — presentTok is a - // *positive* gate (== 1) and absentTok is a *negative* gate (== 0). Only absentTok - // should be flagged; presentTok must not produce a spurious error. - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [file.ts]', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '', - 'Mixed gate task', - '', - 'Use presentTok for the new pattern.', - 'Do not use absentTok any more.', - '', - "grep -c 'presentTok' f == 1 && grep -c 'absentTok' f == 0", - 'Done', - '', - ].join('\n'); - const verify = require(VERIFY_CJS); - const result = verify.scanNegativeGrepCommentEcho(lines); - assert.strictEqual(result.errors.length, 1, `expected exactly 1 error (absentTok only), got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('absentTok'), `error must name absentTok, got: ${result.errors[0]}`); - assert.ok(!result.errors[0].includes('presentTok'), `error must NOT name presentTok, got: ${result.errors[0]}`); - }); - - test('case 13 — grep -c -F (separate count+fixed flags) extracts literal', () => { - // Bug 2: grep -c -F 'LIT' was not extracted by the old regex that required -c - // immediately before the pattern without intervening flags. - const verify = require(VERIFY_CJS); - const content = makePlan({ - negativeGrep: "grep -c -F '.catch(() => null)' f == 0", - actionEcho: '// Old pattern: .catch(() => null)', - }); - const result = verify.scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 1, `grep -c -F must extract literal, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('.catch(() => null)'), `error must name the literal, got: ${result.errors[0]}`); - }); - - test('case 14 — grep -F -c (reversed flag order) extracts literal', () => { - // Bug 2: grep -F -c 'LIT' — count flag not in the first position after grep. - const verify = require(VERIFY_CJS); - const content = makePlan({ - negativeGrep: "grep -F -c 'CardModalHost' f == 0", - actionEcho: '* @see CardModalHost for the deprecated pattern.', - }); - const result = verify.scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 1, `grep -F -c must extract literal, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('CardModalHost'), `error must name CardModalHost, got: ${result.errors[0]}`); - }); - - test('case 15 — grep --count (long option) extracts literal', () => { - // Bug 2: grep --count 'LIT' was not matched by the old -c pattern. - const verify = require(VERIFY_CJS); - const content = makePlan({ - negativeGrep: "grep --count 'longCountTok' f == 0", - actionEcho: 'Remove longCountTok from the codebase.', - }); - const result = verify.scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 1, `grep --count must extract literal, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('longCountTok'), `error must name longCountTok, got: ${result.errors[0]}`); - }); - - test('case 16 — same-line command span stripped but prose echo on same line is still caught', () => { - // Bug 3: the old code filtered entire lines; a line with a pasted grep command AND - // a prose echo would be dropped, silencing the error. Only the command SPAN should - // be stripped; prose on the same line that echoes the token must still be detected. - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [file.ts]', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '', - 'Span strip task', - '', - // Single line: pasted command PLUS a prose mention of spanTok outside the command - "Run grep -c 'spanTok' f == 0 to confirm; note spanTok must be gone.", - '', - "grep -c 'spanTok' f == 0", - 'Done', - '', - ].join('\n'); - const verify = require(VERIFY_CJS); - const result = verify.scanNegativeGrepCommentEcho(lines); - assert.strictEqual(result.errors.length, 1, `prose echo outside command span must still be caught, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('spanTok'), `error must name spanTok, got: ${result.errors[0]}`); - }); - - test('case 17 — command-only action (no prose echo) still does NOT self-flag', () => { - // Bug 3 regression guard: when the ONLY occurrence of the token in an action is - // inside the grep command span itself, no error should fire. - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [file.ts]', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '', - 'Solo command task', - '', - "grep -c 'soloTok' file == 0", - '', - "grep -c 'soloTok' file == 0", - 'Done', - '', - ].join('\n'); - const verify = require(VERIFY_CJS); - const result = verify.scanNegativeGrepCommentEcho(lines); - assert.strictEqual(result.errors.length, 0, `command-only action must not self-flag, errors: ${JSON.stringify(result.errors)}`); - }); - - test('case 18 — multi-line backslash continuation in verify command is joined and detected', () => { - // Bug 4: a verify command split with trailing backslash was not joined, so the - // == 0 appeared on a continuation line without the grep prefix → missed. - const lines = [ - '---', - 'phase: 01-test', - 'plan: 01', - 'type: execute', - 'wave: 1', - 'depends_on: []', - 'files_modified: [file.ts]', - 'autonomous: true', - 'must_haves:', - ' - AC1', - '---', - '', - '', - 'Multi-line verify task', - '', - 'Remove mlTok from all modules.', - '', - 'grep -c \'mlTok\' file \\\n == 0', - 'Done', - '', - ].join('\n'); - const verify = require(VERIFY_CJS); - const result = verify.scanNegativeGrepCommentEcho(lines); - assert.strictEqual(result.errors.length, 1, `backslash-continued verify must be detected, got: ${JSON.stringify(result.errors)}`); - assert.ok(result.errors[0].includes('mlTok'), `error must name mlTok, got: ${result.errors[0]}`); - }); - - // ── (A) assignment is not a gate ────────────────────────────────────────────── - - test('case 19 — bare STATUS=0 assignment after semicolon is not a negative gate', () => { - // grep -c '...' f > /dev/null; STATUS=0 is an assignment, not a == 0 gate. - // deprecatedTok is echoed in the action but the verify line has no == 0 gate, - // so no error should fire. - const content = makePlan({ - negativeGrep: "grep -c 'deprecatedTok' src/m.ts > /dev/null; STATUS=0", - actionEcho: 'Remove deprecatedTok from the module.', - }); - const verify = require(VERIFY_CJS); - const result = verify.scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 0, [ - 'assignment after semicolon must not be treated as a negative gate,', - `errors: ${JSON.stringify(result.errors)}`, - ].join(' ')); - }); - - test('case 19b — positive control: spaced == 0 IS a gate and fires when token is echoed', () => { - // Same plan as case 19 but the verify line now uses the real == 0 gate form. - // deprecatedTok is echoed in the action → expect exactly 1 error. - const content = makePlan({ - negativeGrep: "grep -c 'deprecatedTok' src/m.ts == 0", - actionEcho: 'Remove deprecatedTok from the module.', - }); - const verify = require(VERIFY_CJS); - const result = verify.scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 1, [ - 'spaced == 0 gate with echoed token must produce exactly 1 error,', - `errors: ${JSON.stringify(result.errors)}`, - ].join(' ')); - assert.ok(result.errors[0].includes('deprecatedTok'), `error must name deprecatedTok, got: ${result.errors[0]}`); - }); - - // ── (B) inverted count is not a negative gate ───────────────────────────────── - - test('case 20 — grep -cv with == 0 is NOT a negative gate', () => { - // -cv counts non-matching lines; "== 0" on a -cv result is a positive assertion - // (all lines match), which is out of scope for the negative-grep gate rule. - // invTok is echoed in the action but no error should fire. - const content = makePlan({ - negativeGrep: "grep -cv 'invTok' file == 0", - actionEcho: 'Ensure every line contains invTok.', - }); - const verify = require(VERIFY_CJS); - const result = verify.scanNegativeGrepCommentEcho(content); - assert.strictEqual(result.errors.length, 0, [ - 'grep -cv counts non-matching lines; == 0 is a positive assertion — must not flag,', - `errors: ${JSON.stringify(result.errors)}`, - ].join(' ')); - }); -}); - -// ─── Group 2: end-to-end via runGsdTools ────────────────────────────────────── - -describe('scanNegativeGrepCommentEcho — end-to-end via verify plan-structure', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('e2e case 1 — echoed literal causes valid:false', () => { - const planContent = makePlan({ - negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", - actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', - }); - const planDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(planDir, { recursive: true }); - fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent); - - const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); - const output = JSON.parse(result.output); - assert.strictEqual(output.valid, false, `expected valid:false, got: ${JSON.stringify(output)}`); - assert.ok( - output.errors.some(e => e.includes('?from=')), - `expected an error mentioning ?from=, got: ${JSON.stringify(output.errors)}`, - ); - }); - - test('e2e case 2 — allowlist marker causes valid:true', () => { - const planContent = makePlan({ - negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", - actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', - allowlistMarker: '', - }); - const planDir = path.join(tmpDir, '.planning', 'phases', '01-test'); - fs.mkdirSync(planDir, { recursive: true }); - fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent); - - const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); - const output = JSON.parse(result.output); - assert.strictEqual(output.valid, true, `expected valid:true with allowlist, got: ${JSON.stringify(output)}`); - }); -}); - -// ─── Group 3: doc-contract (source-text-is-the-product) ─────────────────────── - -describe('doc-contract: agent/reference .md files carry the deployed contract text', () => { - test('gsd-planner.md contains block', () => { - const content = fs.readFileSync(PLANNER_MD, 'utf8'); - assert.ok(content.includes(''), 'gsd-planner.md must contain '); - }); - - test('gsd-planner.md contains a usage example (`, - }); - const r2 = scanNegativeGrepCommentEcho(withMarker); - assert.strictEqual(r2.errors.length, 0, [ - `allowlist marker "${ALLOW_PREFIX} parityTok -->" must suppress error,`, - `got: ${JSON.stringify(r2.errors)}`, - ].join(' ')); - }); -}); diff --git a/tests/issue-498-update-backup-runtime-dir.test.cjs b/tests/issue-498-update-backup-runtime-dir.test.cjs deleted file mode 100644 index 454b85a9d..000000000 --- a/tests/issue-498-update-backup-runtime-dir.test.cjs +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Regression (#498, adversarial-review finding): the custom-file backup step in - * update.md must derive RUNTIME_DIR from GSD_DIR. - * - * The get_installed_version step was rewritten to call `gsd-tools update-context` - * and now emits GSD_DIR (the resolved config dir) instead of the old probe-loop - * variables LOCAL_DIR / GLOBAL_DIR. The backup_custom_files step still read - * LOCAL_DIR / GLOBAL_DIR, which are no longer assigned anywhere — so RUNTIME_DIR - * went empty for every LOCAL/GLOBAL install and detect-custom-files was skipped. - * Because the update then runs a clean install that wipes managed dirs - * (commands/gsd, gsd-core), user-added files inside those dirs could be - * deleted without the intended backup. - * - * This locks the fix: RUNTIME_DIR comes from GSD_DIR, and the dead LOCAL_DIR / - * GLOBAL_DIR references are gone. - * - * Source-text-is-the-product: update.md's bash blocks ARE the deployed /gsd:update - * program; asserting their shape is asserting on the deployed contract. - */ - -// allow-test-rule: source-text-is-the-product -// update.md's bash blocks ARE the deployed /gsd:update program; asserting -// their shape is asserting on the deployed contract. The data-loss behavior -// only manifests against a real install during a clean reinstall, which CI -// does not perform. - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'); - -function codeOnly(file) { - // Strip fenced-block prose is unnecessary here; we assert on the whole doc - // but ignore markdown comment prose by only matching shell-assignment forms. - return fs.readFileSync(file, 'utf8'); -} - -describe('#498 regression: update.md backup uses GSD_DIR, not the removed LOCAL_DIR/GLOBAL_DIR', () => { - const src = codeOnly(UPDATE_MD); - - test('RUNTIME_DIR is assigned from GSD_DIR', () => { - assert.match( - src, - /RUNTIME_DIR="\$GSD_DIR"/, - 'backup_custom_files must set RUNTIME_DIR="$GSD_DIR" (the resolved config dir from update-context)', - ); - }); - - test('no shell assignment reads the removed LOCAL_DIR/GLOBAL_DIR probe variables', () => { - // The get_installed_version rewrite no longer assigns LOCAL_DIR/GLOBAL_DIR. - // Any RUNTIME_DIR="$LOCAL_DIR" / "$GLOBAL_DIR" would silently resolve to empty. - assert.doesNotMatch( - src, - /="\$(LOCAL_DIR|GLOBAL_DIR)"/, - 'update.md still reads LOCAL_DIR/GLOBAL_DIR, which get_installed_version no longer sets — backup will be skipped', - ); - }); - - test('detect-custom-files stays gated on a non-empty RUNTIME_DIR', () => { - assert.match( - src, - /\[ -n "\$RUNTIME_DIR" \][\s\S]*?detect-custom-files --config-dir "\$RUNTIME_DIR"/, - 'backup must still skip when RUNTIME_DIR is empty (UNKNOWN scope)', - ); - }); -}); diff --git a/tests/issue-3238-js-yaml-lockfile.test.cjs b/tests/lockfile-cve-audit.test.cjs similarity index 59% rename from tests/issue-3238-js-yaml-lockfile.test.cjs rename to tests/lockfile-cve-audit.test.cjs index 69d1ec669..d4ac5cea9 100644 --- a/tests/issue-3238-js-yaml-lockfile.test.cjs +++ b/tests/lockfile-cve-audit.test.cjs @@ -1,13 +1,23 @@ -// allow-test-rule: structural-implementation-guard (#3238) +// allow-test-rule: structural-implementation-guard (#2765, #3238) 'use strict'; -// Regression guard for #3238: the lockfile must pin a patched js-yaml (>=4.3.1 on the -// 4.x line, >=3.15.1 on the 3.x line) to resolve GHSA-5p4m-2wfm-xmqj — a high-severity -// (CVSS 7.5, CWE-407) quadratic-CPU DoS in `!!omap` resolution, vulnerable range -// `>=4.0.0 <4.3.1`. `!!omap` is in the DEFAULT schema, so a plain yaml.load() is -// affected. This is a lockfile-only devDependency bump (direct, plus an -// @eslint/eslintrc dedupe); production (npm audit --omit=dev) was already clean. -// The test pins every installed copy so the bump can't silently regress. +// Regression guard for #2765: the lockfile must pin the patched brace-expansion +// versions (>=1.1.18 for the 1.x line, >=5.0.9 for the 5.x line) published 2026-07-30 +// to resolve the high-severity DoS/OOM advisories (GHSA-3jxr-9vmj-r5cp / +// GHSA-mh99-v99m-4gvg, range <=5.0.7). This is a lockfile-only devDependency bump +// (eslint/stryker → minimatch → brace-expansion); production (npm audit --omit=dev) is +// unaffected. The test pins the installed versions so the bump can't silently regress. +// +// Regression guard for #3238: the lockfile must also pin a patched js-yaml (>=4.3.1 on +// the 4.x line, >=3.15.1 on the 3.x line) to resolve GHSA-5p4m-2wfm-xmqj — a +// high-severity (CVSS 7.5, CWE-407) quadratic-CPU DoS in `!!omap` resolution, +// vulnerable range `>=4.0.0 <4.3.1`. `!!omap` is in the DEFAULT schema, so a plain +// yaml.load() is affected. This is a lockfile-only devDependency bump (direct, plus +// an @eslint/eslintrc dedupe); production (npm audit --omit=dev) was already clean. +// The test pins every installed copy so the bump can't silently regress. Folded into +// this file (originally tests/issue-3238-js-yaml-lockfile.test.cjs) because it is the +// same shape of lockfile CVE-pin regression test for a different package/CVE; it +// shares the ROOT/npmLs/NPM_LS_TIMEOUT_MS helpers below rather than duplicating them. const { test } = require('node:test'); const assert = require('node:assert/strict'); @@ -43,12 +53,26 @@ function npmLs(pkg) { return versions; } +test('all installed brace-expansion copies are patched (>=1.1.18 / >=5.0.9) — #2765', () => { + const versions = npmLs('brace-expansion'); + assert.ok(versions.length > 0, 'brace-expansion must be installed (devDependency) to guard'); + for (const v of versions) { + const [maj, min, pat] = v.split('.').map(Number); + const ok = (maj === 1 && (min > 1 || (min === 1 && pat >= 18))) // 1.x >= 1.1.18 + || (maj === 5 && (min > 0 || pat >= 9)) // 5.x >= 5.0.9 + || (maj > 5); // >5.x + assert.ok(ok, + `brace-expansion@${v} is within the vulnerable range (<=5.0.7) — lockfile regressed the #2765 patch bump. ` + + 'Re-apply: npm audit fix (non-breaking) to bump to 1.1.18 / 5.0.9.'); + } +}); + // GHSA-5p4m-2wfm-xmqj names only the 3.x (<3.15.1) and 4.x (<4.3.1) lines. The SAME // weakness in the 5.x line is CVE-2026-59870 / GHSA-724g-mxrg-4qvm, fixed in 5.2.1 — // so a guard against this bug CLASS must require 5.2.1 there too rather than waving // every 5.x through, or an accidental major bump to 5.0.0 would reintroduce the exact // quadratic `!!omap` resolution this test exists to prevent. -function isPatched(version) { +function isPatchedJsYaml(version) { const core = String(version).split('+')[0]; // drop build metadata // A prerelease of the patched version (e.g. 4.3.1-beta.1) sorts BELOW it in semver // and may predate the fix — fail closed rather than guess. @@ -67,7 +91,7 @@ test('all installed js-yaml copies are patched (>=4.3.1 / >=3.15.1 / >=5.2.1) // Vacuity guard: an empty list would make every assertion below trivially true. assert.ok(versions.length > 0, 'js-yaml must be installed (devDependency) to guard'); for (const v of versions) { - assert.ok(isPatched(v), + assert.ok(isPatchedJsYaml(v), `js-yaml@${v} is not a patched version — the quadratic \`!!omap\` resolution bug is ` + 'present in 3.x <3.15.1 (GHSA-5p4m-2wfm-xmqj), 4.x <4.3.1 (same), and 5.x <5.2.1 ' + '(CVE-2026-59870). Re-apply: npm install js-yaml@^4.3.1'); diff --git a/tests/plan-phase-drift-guard.test.cjs b/tests/plan-phase-drift-guard.test.cjs index 22d89475f..43bdf66df 100644 --- a/tests/plan-phase-drift-guard.test.cjs +++ b/tests/plan-phase-drift-guard.test.cjs @@ -1744,3 +1744,72 @@ describe('runtime wiring for #2492 gates', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2762-plan-reviews-chunked.test.cjs — H3 Wave 6 (#3338) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-2762-plan-reviews-chunked', () => { +// allow-test-rule: structural-implementation-guard (#2762) +// Regression guard for #2762: /gsd-plan-phase --reviews was a silent no-op in chunked +// mode. Two defects in plan-phase.md §8.5: +// A. §8.5.1 outline resume-check greps for a marker the agent only RETURNED (never +// wrote to the file) → outline always re-ran/overwrote (broke crash-resume). +// B. §8.5.2 per-plan resume-check skipped any plan with frontmatter, with no +// --reviews exception → --reviews skipped 100% of plans (contradicted §6's +// "go straight to replanning" contract). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +// #2993 fragmentization moved §8.5 (chunked planning mode, including §8.5.1 / +// §8.5.2) out of plan-phase.md into gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md. +// Read that step file directly — it is the sole remaining source of the §8.5.1/§8.5.2 +// content these regression guards assert on. +const MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md'); +const read = () => fs.readFileSync(MD, 'utf8'); + +test('§8.5.1 outline agent writes the resume marker into the file (#2762 defect A)', () => { + const src = read(); + // The outline prompt must instruct writing ## OUTLINE COMPLETE into PLAN-OUTLINE.md + // (the §8.5.1 resume-check greps for it in the file). + const outlineSection = src.slice(src.indexOf('### 8.5.1'), src.indexOf('### 8.5.2')); + assert.ok( + /outline|PLAN-OUTLINE/i.test(outlineSection) && /End the file.*## OUTLINE COMPLETE|write.*## OUTLINE COMPLETE.*file/i.test(outlineSection.replace(/\s+/g, ' ')), + 'the outline agent prompt must instruct writing ## OUTLINE COMPLETE into the file (the resume-check greps the file for it) (#2762)' + ); +}); + +test('§8.5.2 per-plan resume-check does NOT skip under --reviews (#2762 defect B)', () => { + const src = read(); + const perPlanSection = src.slice(src.indexOf('### 8.5.2')); + // The resume-check bash must gate the skip on --reviews being ABSENT. + const bashMatch = perPlanSection.match(/PLAN_FILE=[\s\S]*?fi\s*\n/); + assert.ok(bashMatch, '§8.5.2 must contain the per-plan resume-check bash block'); + const bash = bashMatch[0]; + assert.ok( + /--reviews/.test(bash), + 'the per-plan resume-check must reference --reviews so it does NOT skip when replanning with review feedback (#2762)' + ); + // The skip must be conditional on --reviews being ABSENT (e.g. ARGUMENTS != *"--reviews"*). + assert.ok( + /!=\s*\*"--reviews"\*|!~.*--reviews|--reviews.*absent|not.*--reviews/i.test(bash), + 'the resume-check skip must be gated on --reviews being ABSENT (so --reviews overwrites/replans) (#2762)' + ); +}); + +test('§8.5.2 crash-resume (non-reviews) still skips written plans (#2762 negative space)', () => { + const src = read(); + const perPlanSection = src.slice(src.indexOf('### 8.5.2')); + const bashMatch = perPlanSection.match(/PLAN_FILE=[\s\S]*?fi\s*\n/); + const bash = bashMatch ? bashMatch[0] : ''; + assert.ok( + /head -1.*grep.*\^---|frontmatter/i.test(bash + perPlanSection.slice(0, 400)), + 'crash-resume (non-reviews) must still skip plans with valid frontmatter (resume safety preserved) (#2762)' + ); +}); + }); +} diff --git a/tests/issue-498-update-context.test.cjs b/tests/update-context.test.cjs similarity index 100% rename from tests/issue-498-update-context.test.cjs rename to tests/update-context.test.cjs diff --git a/tests/issue-815-update-next-channel.test.cjs b/tests/update-workflow.test.cjs similarity index 56% rename from tests/issue-815-update-next-channel.test.cjs rename to tests/update-workflow.test.cjs index e82aefbee..9f28e91ba 100644 --- a/tests/issue-815-update-next-channel.test.cjs +++ b/tests/update-workflow.test.cjs @@ -1,5 +1,81 @@ +/** + * Regression (#498, adversarial-review finding): the custom-file backup step in + * update.md must derive RUNTIME_DIR from GSD_DIR. + * + * The get_installed_version step was rewritten to call `gsd-tools update-context` + * and now emits GSD_DIR (the resolved config dir) instead of the old probe-loop + * variables LOCAL_DIR / GLOBAL_DIR. The backup_custom_files step still read + * LOCAL_DIR / GLOBAL_DIR, which are no longer assigned anywhere — so RUNTIME_DIR + * went empty for every LOCAL/GLOBAL install and detect-custom-files was skipped. + * Because the update then runs a clean install that wipes managed dirs + * (commands/gsd, gsd-core), user-added files inside those dirs could be + * deleted without the intended backup. + * + * This locks the fix: RUNTIME_DIR comes from GSD_DIR, and the dead LOCAL_DIR / + * GLOBAL_DIR references are gone. + * + * Source-text-is-the-product: update.md's bash blocks ARE the deployed /gsd:update + * program; asserting their shape is asserting on the deployed contract. + */ + +// allow-test-rule: source-text-is-the-product (#3338) +// update.md's bash blocks ARE the deployed /gsd:update program; asserting +// their shape is asserting on the deployed contract. The data-loss behavior +// only manifests against a real install during a clean reinstall, which CI +// does not perform. + 'use strict'; -// allow-test-rule: source-text-is-the-product + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'); + +function codeOnly(file) { + // Strip fenced-block prose is unnecessary here; we assert on the whole doc + // but ignore markdown comment prose by only matching shell-assignment forms. + return fs.readFileSync(file, 'utf8'); +} + +describe('#498 regression: update.md backup uses GSD_DIR, not the removed LOCAL_DIR/GLOBAL_DIR', () => { + const src = codeOnly(UPDATE_MD); + + test('RUNTIME_DIR is assigned from GSD_DIR', () => { + assert.match( + src, + /RUNTIME_DIR="\$GSD_DIR"/, + 'backup_custom_files must set RUNTIME_DIR="$GSD_DIR" (the resolved config dir from update-context)', + ); + }); + + test('no shell assignment reads the removed LOCAL_DIR/GLOBAL_DIR probe variables', () => { + // The get_installed_version rewrite no longer assigns LOCAL_DIR/GLOBAL_DIR. + // Any RUNTIME_DIR="$LOCAL_DIR" / "$GLOBAL_DIR" would silently resolve to empty. + assert.doesNotMatch( + src, + /="\$(LOCAL_DIR|GLOBAL_DIR)"/, + 'update.md still reads LOCAL_DIR/GLOBAL_DIR, which get_installed_version no longer sets — backup will be skipped', + ); + }); + + test('detect-custom-files stays gated on a non-empty RUNTIME_DIR', () => { + assert.match( + src, + /\[ -n "\$RUNTIME_DIR" \][\s\S]*?detect-custom-files --config-dir "\$RUNTIME_DIR"/, + 'backup must still skip when RUNTIME_DIR is empty (UNKNOWN scope)', + ); + }); +}); + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-815-update-next-channel.test.cjs (#3338 H3 Wave 6) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-815-update-next-channel', () => { +// allow-test-rule: source-text-is-the-product (#3338) // Reads product workflow/command markdown to verify the --next RC channel // contract. @@ -12,9 +88,9 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const ROOT = path.join(__dirname, '..'); -const WF = fs.readFileSync(path.join(ROOT, 'gsd-core', 'workflows', 'update.md'), 'utf8'); -const CMD = fs.readFileSync(path.join(ROOT, 'commands', 'gsd', 'update.md'), 'utf8'); +const ROOT815 = path.join(__dirname, '..'); +const WF = fs.readFileSync(path.join(ROOT815, 'gsd-core', 'workflows', 'update.md'), 'utf8'); +const CMD = fs.readFileSync(path.join(ROOT815, 'commands', 'gsd', 'update.md'), 'utf8'); test('issue #815: workflow parses --next/--rc into a TAG channel', () => { assert.match(WF, /--next/); @@ -44,7 +120,8 @@ test('issue #815: command documents --next/--rc and routes it to the update work assert.match(CMD, /--rc/); assert.match(CMD, /argument-hint:.*--next/); }); - + }); +} // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2470-update-md-claude-path.test.cjs — consolidation epic #1969 (B4 #1973) @@ -56,7 +133,6 @@ test('issue #815: command documents --next/--rc and routes it to the update work // Workflow .md / agent .md / command .md / reference .md files — their text // IS what the runtime loads. Testing text content tests the deployed contract. // Per CONTRIBUTING.md exception matrix. -'use strict'; /** @@ -70,23 +146,23 @@ test('issue #815: command documents --next/--rc and routes it to the update work * update.md would slip through and trigger the installer warning for non-Claude runtimes. */ -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); +const { test: __t2470, describe: __d2470 } = require('node:test'); +const assert2470 = require('node:assert/strict'); +const fs2470 = require('fs'); +const path2470 = require('path'); -const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'); +const UPDATE_MD_2470 = path2470.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'); -describe('update.md — no bare ~.claude path references (#2470)', () => { - const content = fs.readFileSync(UPDATE_MD, 'utf-8'); +__d2470('update.md — no bare ~.claude path references (#2470)', () => { + const content = fs2470.readFileSync(UPDATE_MD_2470, 'utf-8'); - test('update.md does not contain bare ~/\\.claude (without trailing slash)', () => { + __t2470('update.md does not contain bare ~/\\.claude (without trailing slash)', () => { // This is the exact pattern from the installer's scanForLeakedPaths(): // /(?:~|\$HOME)\/\.claude\b/g // The replacer handles ~/\.claude\/ (with trailing slash) but misses bare ~/\.claude // so we must not have bare references in the source file. const matches = content.match(/(?:~|\$HOME)\/\.claude(?!\/)/g); - assert.strictEqual( + assert2470.strictEqual( matches, null, `update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}` @@ -103,7 +179,6 @@ describe('update.md — no bare ~.claude path references (#2470)', () => { { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3130-update-npx-robust-invocation (consolidation epic #1969 B4 #1973)", () => { -'use strict'; // allow-test-rule: source-text-is-the-product (see #3130) // Reads product workflow markdown (update.md) to verify structural // invocation contract. @@ -126,32 +201,32 @@ describe('update.md — no bare ~.claude path references (#2470)', () => { // `$TAG` is a shell variable (latest by default, next under --next/--rc), // set by the parse_update_channel step (#815). -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); +const { test: __t3130 } = require('node:test'); +const assert3130 = require('node:assert/strict'); +const fs3130 = require('node:fs'); +const path3130 = require('node:path'); -const ROOT = path.join(__dirname, '..'); -const UPDATE_WF = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); +const ROOT_3130 = path3130.join(__dirname, '..'); +const UPDATE_WF_3130 = path3130.join(ROOT_3130, 'gsd-core', 'workflows', 'update.md'); -const src = fs.readFileSync(UPDATE_WF, 'utf8'); +const src3130 = fs3130.readFileSync(UPDATE_WF_3130, 'utf8'); -test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => { +__t3130('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => { // Any occurrence of `npx -y @opengsd/gsd-core@` without `--package=` // is the stale form that triggers the two failure modes. - const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []); - assert.deepEqual( + const stale = (src3130.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []); + assert3130.deepEqual( stale, [], `Stale npx forms found in update.md (must use --package= form): ${stale.join('; ')}`, ); }); -test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => { +__t3130('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => { // Three sibling invocations: local, global, and unknown/fallback. // The tag is now a $TAG variable (latest by default, next under --next/--rc). - const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []); - assert.ok( + const robust = (src3130.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []); + assert3130.ok( robust.length >= 3, `Expected >=3 robust npx invocations in update.md, found ${robust.length}`, ); diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs index 0e45d74a0..a1b62d944 100644 --- a/tests/verify.test.cjs +++ b/tests/verify.test.cjs @@ -3064,3 +3064,944 @@ describe('bug #1883 — listMilestoneArchiveDirs distinguishes a permission erro 'an absent milestones/ dir (ENOENT) must still return [] — Hyrum: empty path unchanged'); }); }); + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-2701-nul-corrupted-validators.test.cjs — test-hygiene sweep #3338 (H3 wave 6) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:issue-2701-nul-corrupted-validators", () => { +// Regression tests for #2701 — plan/summary/verification/state validators silently +// accept NUL-corrupted files and report valid:true. +// +// A NUL-corrupted text artifact is binary-classified by file(1) and silently +// OMITTED from recursive / binary-skipping search results (rg -l, grep -rI, +// exit 0), so the corruption reads downstream as "file absent" rather than +// "file corrupt." The validators must fail loud, naming the encoding problem and +// its consequence, before any schema/structure check. The fix is at the +// validator entry points (a shared textEncodingError helper in validate.cjs), +// NOT inside the broadly-shared platformReadSync read primitive. +// +// NUL bytes are written via Buffer so they survive onto disk (a string write +// would not). Cleanup via t.after(() => cleanup(tmpDir)). + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { writeState } = require('./fixtures/index.cjs'); + +// A structurally-complete PLAN.md that passes both validators when clean. +function validPlanBody() { + return [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [some/file.ts]', + 'autonomous: true', + 'must_haves:', + ' truths:', + ' - "something is true"', + '---', + '', + '', + '', + '', + ' Task 1: Do something', + ' some/file.ts', + ' Do the thing', + ' npx vitest run', + ' Thing is done', + '', + '', + '', + ].join('\n'); +} + +/** Write `body` to a fresh phase plan path, optionally injecting a NUL at `nulAt`. */ +function writePlan(tmpDir, name, body, nulAt) { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); + const p = path.join(tmpDir, '.planning', 'phases', '01-test', name); + let buf = Buffer.from(body, 'utf8'); + if (nulAt !== undefined) { + buf = Buffer.concat([buf.subarray(0, nulAt), Buffer.from([0x00]), buf.subarray(nulAt)]); + } + fs.writeFileSync(p, buf); + return p; +} + +function parseResult(t, argv, tmpDir) { + const r = runGsdTools(argv, tmpDir); + assert.ok(r.success, `command failed: ${r.error}`); + return JSON.parse(r.output); +} + +// ─── frontmatter validate --schema plan|summary|verification ──────────────── + +describe('#2701: frontmatter validate rejects NUL-corrupted artifacts', () => { + test('PLAN.md with an embedded NUL byte → valid:false, error names encoding + consequence', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const rel = '.planning/phases/01-test/01-01-PLAN.md'; + writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), 200); + + const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir); + assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`); + assert.ok(Array.isArray(out.errors) && out.errors.length > 0, 'must report errors'); + const msg = out.errors.join(' '); + assert.ok(/NUL/i.test(msg), `error must name NUL/encoding: ${msg}`); + assert.ok(/skip|search|absent|missing/i.test(msg), `error must name the downstream consequence: ${msg}`); + }); + + test('SUMMARY.md with an embedded NUL byte → valid:false', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const dir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(dir, { recursive: true }); + const body = ['---', 'phase: 01-test', 'plan: 01', 'status: in_progress', '---', '', '# Summary', 'did the work'].join('\n'); + const buf = Buffer.concat([Buffer.from(body, 'utf8').subarray(0, 30), Buffer.from([0x00]), Buffer.from(body, 'utf8').subarray(30)]); + fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), buf); + + const out = parseResult(t, ['frontmatter', 'validate', '.planning/phases/01-test/01-01-SUMMARY.md', '--schema', 'summary'], tmpDir); + assert.strictEqual(out.valid, false); + assert.ok(out.errors.some((e) => /NUL/i.test(e))); + }); + + test('VERIFICATION.md with an embedded NUL byte → valid:false', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const dir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(dir, { recursive: true }); + const body = ['---', 'phase: 01-test', 'plan: 01', 'status: passed', '---', '', '# Verification', 'all green'].join('\n'); + const buf = Buffer.concat([Buffer.from(body, 'utf8').subarray(0, 40), Buffer.from([0x00]), Buffer.from(body, 'utf8').subarray(40)]); + fs.writeFileSync(path.join(dir, '01-01-VERIFICATION.md'), buf); + + const out = parseResult(t, ['frontmatter', 'validate', '.planning/phases/01-test/01-01-VERIFICATION.md', '--schema', 'verification'], tmpDir); + assert.strictEqual(out.valid, false); + assert.ok(out.errors.some((e) => /NUL/i.test(e))); + }); +}); + +// ─── verify plan-structure ────────────────────────────────────────────────── + +describe('#2701: verify plan-structure rejects NUL-corrupted PLAN.md', () => { + test('PLAN.md with an embedded NUL byte → valid:false, error names encoding', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const rel = '.planning/phases/01-test/01-01-PLAN.md'; + writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), 200); + + const out = parseResult(t, ['verify', 'plan-structure', rel], tmpDir); + assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`); + assert.ok(out.errors.some((e) => /NUL/i.test(e)), `error must name NUL: ${JSON.stringify(out.errors)}`); + }); +}); + +// ─── state validate ───────────────────────────────────────────────────────── + +describe('#2701: state validate rejects NUL-corrupted STATE.md', () => { + test('STATE.md with an embedded NUL byte → valid:false', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // createTempProject() does NOT seed STATE.md; use writeState to create one, + // then corrupt it in place with a NUL byte (Buffer write so it survives). + const seed = [ + '# Project', + '', + '## Status', + 'executing', + '## Current Phase', + '01 of 01', + '## Total Plans in Phase', + '1', + ].join('\n'); + const statePath = writeState(tmpDir, seed); + const body = Buffer.from(seed, 'utf8'); + const buf = Buffer.concat([body.subarray(0, 50), Buffer.from([0x00]), body.subarray(50)]); + fs.writeFileSync(statePath, buf); + + const out = parseResult(t, ['state', 'validate'], tmpDir); + assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`); + assert.ok(out.warnings.some((w) => /NUL/i.test(w)), `warning must name NUL: ${JSON.stringify(out.warnings)}`); + }); +}); + +// ─── negative space: clean files still pass; non-ASCII UTF-8 not over-rejected ─ + +describe('#2701: clean and valid-UTF-8 files are not over-rejected', () => { + test('clean PLAN.md (no NUL) → frontmatter validate valid:true', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const rel = '.planning/phases/01-test/01-01-PLAN.md'; + writePlan(tmpDir, '01-01-PLAN.md', validPlanBody()); + + const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir); + assert.strictEqual(out.valid, true, `clean plan must pass; got ${JSON.stringify(out)}`); + }); + + test('clean PLAN.md (no NUL) → verify plan-structure valid:true', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const rel = '.planning/phases/01-test/01-01-PLAN.md'; + writePlan(tmpDir, '01-01-PLAN.md', validPlanBody()); + + const out = parseResult(t, ['verify', 'plan-structure', rel], tmpDir); + assert.strictEqual(out.valid, true, `clean plan must pass; got ${JSON.stringify(out)}`); + }); + + test('non-ASCII UTF-8 (é, emoji) without NUL is NOT rejected', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const rel = '.planning/phases/01-test/01-01-PLAN.md'; + // High bytes are valid UTF-8; only a NUL (0x00) is the corruption signal. + const body = validPlanBody().replace('Do the thing', 'Do the thing — café ☕ naïve'); + writePlan(tmpDir, '01-01-PLAN.md', body); + + const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir); + assert.strictEqual(out.valid, true, `valid UTF-8 high bytes must not be rejected; got ${JSON.stringify(out)}`); + }); +}); + +// ─── boundary: NUL at offset 0 and mid-file both rejected ─────────────────── + +describe('#2701: NUL position does not matter (start and middle both rejected)', () => { + for (const nulAt of [0, 5, 250]) { + test(`NUL at offset ${nulAt} → frontmatter validate valid:false`, (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const rel = '.planning/phases/01-test/01-01-PLAN.md'; + writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), nulAt); + + const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir); + assert.strictEqual(out.valid, false, `NUL at offset ${nulAt} must be rejected; got ${JSON.stringify(out)}`); + }); + } +}); + }); +} + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-429-comment-text-gate.test.cjs — test-hygiene sweep #3338 (H3 wave 6) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:issue-429-comment-text-gate", () => { +// allow-test-rule: source-text-is-the-product (#3338) +// Issue #429: the gate logic is tested behaviorally via the exported pure +// function + runGsdTools; the discipline rule + allowlist escape hatch are +// asserted against the agent/reference .md whose text IS the deployed contract. + +'use strict'; + +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 { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +// Build path to built verify.cjs +const VERIFY_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs'); + +// fast-check: loaded at top level so skip flags evaluate correctly +let fc; +try { fc = require('fast-check'); } catch { fc = null; } +// Build path to agent/reference files +const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); +const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md'); + +// ─── Fixtures ────────────────────────────────────────────────────────────────── + +function makePlan({ negativeGrep, actionEcho, allowlistMarker, positiveGrep } = {}) { + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [src/animal-detail.tsx]', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '# Test Plan', + '', + ]; + + if (allowlistMarker) { + lines.push(allowlistMarker, ''); + } + + lines.push(''); + lines.push('Test task'); + lines.push(''); + if (actionEcho) { + lines.push(actionEcho); + } else { + lines.push('Do the work.'); + } + lines.push(''); + + if (positiveGrep) { + lines.push(`${positiveGrep}`); + } else if (negativeGrep) { + lines.push(`${negativeGrep}`); + } else { + lines.push('npm test'); + } + + lines.push('Task complete'); + lines.push(''); + + return lines.join('\n'); +} + +// ─── Group 1: pure-function unit tests ──────────────────────────────────────── + +describe('scanNegativeGrepCommentEcho — pure unit tests', () => { + let scanNegativeGrepCommentEcho; + + before(() => { + const verify = require(VERIFY_CJS); + scanNegativeGrepCommentEcho = verify.scanNegativeGrepCommentEcho; + }); + + test('case 1 — regression Plan 12-04: action echoes the forbidden literal', () => { + const content = makePlan({ + negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", + actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', + }); + const result = scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('?from='), `error should mention ?from=, got: ${result.errors[0]}`); + }); + + test('case 2 — regression Plan 11-04: JSDoc head-comment echoes CardModalHost', () => { + const content = makePlan({ + negativeGrep: "grep -c 'CardModalHost' file == 0", + actionEcho: '* @see CardModalHost for the deprecated pattern.', + }); + const result = scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('CardModalHost'), `error should mention CardModalHost, got: ${result.errors[0]}`); + }); + + test('case 3 — regression Plan 12-02: head-comment echoes .catch(() => null) (regex-special chars)', () => { + const content = makePlan({ + negativeGrep: "grep -c '.catch(() => null)' file == 0", + actionEcho: '// Old pattern: .catch(() => null)', + }); + const result = scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('.catch(() => null)'), `error should mention the literal, got: ${result.errors[0]}`); + }); + + test('case 4 — boundary: positive count gate (== 60) must NOT be flagged (AC#2)', () => { + const content = makePlan({ + positiveGrep: "grep -c '= makeParallel(' file == 60", + actionEcho: 'Use makeParallel() for concurrent processing.', + }); + const result = scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 0, `positive count gate must not flag, errors: ${JSON.stringify(result.errors)}`); + }); + + test('case 5 — no echo: literal only in verify, not in action', () => { + const content = makePlan({ + negativeGrep: "grep -c 'LEGACY_TOKEN' file == 0", + actionEcho: 'Remove the old token handling.', + }); + const result = scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 0, 'should be no errors'); + assert.strictEqual(result.warnings.length, 0, 'should be no warnings'); + }); + + test('case 6 — allowlist marker suppresses the error', () => { + const content = makePlan({ + negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", + actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', + allowlistMarker: '', + }); + const result = scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 0, `allowlist should suppress error, got: ${JSON.stringify(result.errors)}`); + }); + + test('case 7 — ambiguous unquoted bareword echo: warning not error', () => { + const content = makePlan({ + negativeGrep: 'grep -c badToken file == 0', + actionEcho: 'Remove badToken from codebase.', + }); + const result = scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 0, `ambiguous token must not error, got: ${JSON.stringify(result.errors)}`); + assert.strictEqual(result.warnings.length, 1, `ambiguous token should warn once, got: ${JSON.stringify(result.warnings)}`); + assert.ok(result.warnings[0].includes('badToken'), `warning should mention badToken, got: ${result.warnings[0]}`); + }); + + test('case 8 — negative-grep command inside an does NOT self-flag', () => { + // action tells executor to ADD the verify command — the grep itself is in the action + // but there is no echo of selfToken outside the grep command + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [file.ts]', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '', + 'Add verify command', + '', + "Add this to the CI script: grep -c 'selfToken' file == 0", + '', + 'npm test', + 'Done', + '', + ].join('\n'); + const verify = require(VERIFY_CJS); + const r = verify.scanNegativeGrepCommentEcho(lines); + assert.strictEqual(r.errors.length, 0, `grep command in action must not self-flag, errors: ${JSON.stringify(r.errors)}`); + }); + + test('case 9 — CRLF newlines are normalized', () => { + const content = makePlan({ + negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", + actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', + }); + const crlfContent = content.split('\n').join('\r\n'); + const result = scanNegativeGrepCommentEcho(crlfContent); + assert.strictEqual(result.errors.length, 1, `CRLF content should still find error, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('?from=')); + }); + + test('case 10 — multiple distinct echoed literals each produce their own error', () => { + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [file.ts]', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '', + 'Multi literal task', + '', + "Remove tokA and tokB from the codebase.", + '', + "grep -c 'tokA' file == 0 && grep -c 'tokB' file == 0", + 'Done', + '', + ].join('\n'); + const verify = require(VERIFY_CJS); + const result = verify.scanNegativeGrepCommentEcho(lines); + assert.strictEqual(result.errors.length, 2, `expected 2 errors (one per literal), got: ${JSON.stringify(result.errors)}`); + }); + + test('case 11 — != 0 and >= 0 are NOT negative gates', () => { + const verify = require(VERIFY_CJS); + const content1 = makePlan({ + negativeGrep: "grep -c 'nz' file != 0", + actionEcho: 'Ensure nz is present.', + }); + const r1 = verify.scanNegativeGrepCommentEcho(content1); + assert.strictEqual(r1.errors.length, 0, `!= 0 must not trigger, errors: ${JSON.stringify(r1.errors)}`); + + const content2 = makePlan({ + negativeGrep: "grep -c 'nz' file >= 0", + actionEcho: 'Ensure nz is present.', + }); + const r2 = verify.scanNegativeGrepCommentEcho(content2); + assert.strictEqual(r2.errors.length, 0, `>= 0 must not trigger, errors: ${JSON.stringify(r2.errors)}`); + }); + + // ── Bug-fix regression tests (adversarial-review findings) ─────────────────── + + test('case 12 — mixed positive+negative on one line: no false positive for positive gate token', () => { + // Bug 1: mixed positive+negative greps on one physical line — presentTok is a + // *positive* gate (== 1) and absentTok is a *negative* gate (== 0). Only absentTok + // should be flagged; presentTok must not produce a spurious error. + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [file.ts]', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '', + 'Mixed gate task', + '', + 'Use presentTok for the new pattern.', + 'Do not use absentTok any more.', + '', + "grep -c 'presentTok' f == 1 && grep -c 'absentTok' f == 0", + 'Done', + '', + ].join('\n'); + const verify = require(VERIFY_CJS); + const result = verify.scanNegativeGrepCommentEcho(lines); + assert.strictEqual(result.errors.length, 1, `expected exactly 1 error (absentTok only), got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('absentTok'), `error must name absentTok, got: ${result.errors[0]}`); + assert.ok(!result.errors[0].includes('presentTok'), `error must NOT name presentTok, got: ${result.errors[0]}`); + }); + + test('case 13 — grep -c -F (separate count+fixed flags) extracts literal', () => { + // Bug 2: grep -c -F 'LIT' was not extracted by the old regex that required -c + // immediately before the pattern without intervening flags. + const verify = require(VERIFY_CJS); + const content = makePlan({ + negativeGrep: "grep -c -F '.catch(() => null)' f == 0", + actionEcho: '// Old pattern: .catch(() => null)', + }); + const result = verify.scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 1, `grep -c -F must extract literal, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('.catch(() => null)'), `error must name the literal, got: ${result.errors[0]}`); + }); + + test('case 14 — grep -F -c (reversed flag order) extracts literal', () => { + // Bug 2: grep -F -c 'LIT' — count flag not in the first position after grep. + const verify = require(VERIFY_CJS); + const content = makePlan({ + negativeGrep: "grep -F -c 'CardModalHost' f == 0", + actionEcho: '* @see CardModalHost for the deprecated pattern.', + }); + const result = verify.scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 1, `grep -F -c must extract literal, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('CardModalHost'), `error must name CardModalHost, got: ${result.errors[0]}`); + }); + + test('case 15 — grep --count (long option) extracts literal', () => { + // Bug 2: grep --count 'LIT' was not matched by the old -c pattern. + const verify = require(VERIFY_CJS); + const content = makePlan({ + negativeGrep: "grep --count 'longCountTok' f == 0", + actionEcho: 'Remove longCountTok from the codebase.', + }); + const result = verify.scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 1, `grep --count must extract literal, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('longCountTok'), `error must name longCountTok, got: ${result.errors[0]}`); + }); + + test('case 16 — same-line command span stripped but prose echo on same line is still caught', () => { + // Bug 3: the old code filtered entire lines; a line with a pasted grep command AND + // a prose echo would be dropped, silencing the error. Only the command SPAN should + // be stripped; prose on the same line that echoes the token must still be detected. + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [file.ts]', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '', + 'Span strip task', + '', + // Single line: pasted command PLUS a prose mention of spanTok outside the command + "Run grep -c 'spanTok' f == 0 to confirm; note spanTok must be gone.", + '', + "grep -c 'spanTok' f == 0", + 'Done', + '', + ].join('\n'); + const verify = require(VERIFY_CJS); + const result = verify.scanNegativeGrepCommentEcho(lines); + assert.strictEqual(result.errors.length, 1, `prose echo outside command span must still be caught, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('spanTok'), `error must name spanTok, got: ${result.errors[0]}`); + }); + + test('case 17 — command-only action (no prose echo) still does NOT self-flag', () => { + // Bug 3 regression guard: when the ONLY occurrence of the token in an action is + // inside the grep command span itself, no error should fire. + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [file.ts]', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '', + 'Solo command task', + '', + "grep -c 'soloTok' file == 0", + '', + "grep -c 'soloTok' file == 0", + 'Done', + '', + ].join('\n'); + const verify = require(VERIFY_CJS); + const result = verify.scanNegativeGrepCommentEcho(lines); + assert.strictEqual(result.errors.length, 0, `command-only action must not self-flag, errors: ${JSON.stringify(result.errors)}`); + }); + + test('case 18 — multi-line backslash continuation in verify command is joined and detected', () => { + // Bug 4: a verify command split with trailing backslash was not joined, so the + // == 0 appeared on a continuation line without the grep prefix → missed. + const lines = [ + '---', + 'phase: 01-test', + 'plan: 01', + 'type: execute', + 'wave: 1', + 'depends_on: []', + 'files_modified: [file.ts]', + 'autonomous: true', + 'must_haves:', + ' - AC1', + '---', + '', + '', + 'Multi-line verify task', + '', + 'Remove mlTok from all modules.', + '', + 'grep -c \'mlTok\' file \\\n == 0', + 'Done', + '', + ].join('\n'); + const verify = require(VERIFY_CJS); + const result = verify.scanNegativeGrepCommentEcho(lines); + assert.strictEqual(result.errors.length, 1, `backslash-continued verify must be detected, got: ${JSON.stringify(result.errors)}`); + assert.ok(result.errors[0].includes('mlTok'), `error must name mlTok, got: ${result.errors[0]}`); + }); + + // ── (A) assignment is not a gate ────────────────────────────────────────────── + + test('case 19 — bare STATUS=0 assignment after semicolon is not a negative gate', () => { + // grep -c '...' f > /dev/null; STATUS=0 is an assignment, not a == 0 gate. + // deprecatedTok is echoed in the action but the verify line has no == 0 gate, + // so no error should fire. + const content = makePlan({ + negativeGrep: "grep -c 'deprecatedTok' src/m.ts > /dev/null; STATUS=0", + actionEcho: 'Remove deprecatedTok from the module.', + }); + const verify = require(VERIFY_CJS); + const result = verify.scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 0, [ + 'assignment after semicolon must not be treated as a negative gate,', + `errors: ${JSON.stringify(result.errors)}`, + ].join(' ')); + }); + + test('case 19b — positive control: spaced == 0 IS a gate and fires when token is echoed', () => { + // Same plan as case 19 but the verify line now uses the real == 0 gate form. + // deprecatedTok is echoed in the action → expect exactly 1 error. + const content = makePlan({ + negativeGrep: "grep -c 'deprecatedTok' src/m.ts == 0", + actionEcho: 'Remove deprecatedTok from the module.', + }); + const verify = require(VERIFY_CJS); + const result = verify.scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 1, [ + 'spaced == 0 gate with echoed token must produce exactly 1 error,', + `errors: ${JSON.stringify(result.errors)}`, + ].join(' ')); + assert.ok(result.errors[0].includes('deprecatedTok'), `error must name deprecatedTok, got: ${result.errors[0]}`); + }); + + // ── (B) inverted count is not a negative gate ───────────────────────────────── + + test('case 20 — grep -cv with == 0 is NOT a negative gate', () => { + // -cv counts non-matching lines; "== 0" on a -cv result is a positive assertion + // (all lines match), which is out of scope for the negative-grep gate rule. + // invTok is echoed in the action but no error should fire. + const content = makePlan({ + negativeGrep: "grep -cv 'invTok' file == 0", + actionEcho: 'Ensure every line contains invTok.', + }); + const verify = require(VERIFY_CJS); + const result = verify.scanNegativeGrepCommentEcho(content); + assert.strictEqual(result.errors.length, 0, [ + 'grep -cv counts non-matching lines; == 0 is a positive assertion — must not flag,', + `errors: ${JSON.stringify(result.errors)}`, + ].join(' ')); + }); +}); + +// ─── Group 2: end-to-end via runGsdTools ────────────────────────────────────── + +describe('scanNegativeGrepCommentEcho — end-to-end via verify plan-structure', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('e2e case 1 — echoed literal causes valid:false', () => { + const planContent = makePlan({ + negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", + actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', + }); + const planDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(planDir, { recursive: true }); + fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent); + + const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); + const output = JSON.parse(result.output); + assert.strictEqual(output.valid, false, `expected valid:false, got: ${JSON.stringify(output)}`); + assert.ok( + output.errors.some(e => e.includes('?from=')), + `expected an error mentioning ?from=, got: ${JSON.stringify(output.errors)}`, + ); + }); + + test('e2e case 2 — allowlist marker causes valid:true', () => { + const planContent = makePlan({ + negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0", + actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.', + allowlistMarker: '', + }); + const planDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(planDir, { recursive: true }); + fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent); + + const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); + const output = JSON.parse(result.output); + assert.strictEqual(output.valid, true, `expected valid:true with allowlist, got: ${JSON.stringify(output)}`); + }); +}); + +// ─── Group 3: doc-contract (source-text-is-the-product) ─────────────────────── + +describe('doc-contract: agent/reference .md files carry the deployed contract text', () => { + test('gsd-planner.md contains block', () => { + const content = fs.readFileSync(PLANNER_MD, 'utf8'); + assert.ok(content.includes(''), 'gsd-planner.md must contain '); + }); + + test('gsd-planner.md contains a usage example (`, + }); + const r2 = scanNegativeGrepCommentEcho(withMarker); + assert.strictEqual(r2.errors.length, 0, [ + `allowlist marker "${ALLOW_PREFIX} parityTok -->" must suppress error,`, + `got: ${JSON.stringify(r2.errors)}`, + ].join(' ')); + }); +}); + }); +}