/** * GSD Tools Tests - Verify */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { runHook } = require('./helpers/process-seam.cjs'); // #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); /** * Bound for the grep/sed availability probes and region-extraction calls in * the region-scoped negative-gate proof below (#3144). These are not git — * reusing `GIT_TIMEOUT_MS` for them would tie an unrelated tool's budget to * git's, so they get their own named constant even though the value happens * to match; a `grep -Eq`/`sed -n` over a small temp file is well under this. */ const TEXT_TOOL_TIMEOUT_MS = 15000; // ─── helpers ────────────────────────────────────────────────────────────────── // Build a minimal valid PLAN.md content with all required frontmatter fields function validPlanContent({ wave = 1, dependsOn = '[]', autonomous = 'true', extraTasks = '' } = {}) { return [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', `wave: ${wave}`, `depends_on: ${dependsOn}`, 'files_modified: [some/file.ts]', `autonomous: ${autonomous}`, 'must_haves:', ' truths:', ' - "something is true"', '---', '', '', '', '', ' Task 1: Do something', ' some/file.ts', ' Do the thing', ' echo ok', ' Thing is done', '', extraTasks, '', '', ].join('\n'); } describe('validate consistency command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('passes for consistent project', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: A\n### Phase 2: B\n### Phase 3: C\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); const result = runGsdTools('validate consistency', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.passed, true, 'should pass'); assert.strictEqual(output.warning_count, 0, 'no warnings'); }); test('warns about phase on disk but not in roadmap', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: A\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-orphan'), { recursive: true }); const result = runGsdTools('validate consistency', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.warning_count > 0, 'should have warnings'); assert.ok( output.warnings.some(w => w.includes('disk but not in ROADMAP')), 'should warn about orphan directory' ); }); test('warns about gaps in phase numbering', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap\n### Phase 1: A\n### Phase 3: C\n` ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-c'), { recursive: true }); const result = runGsdTools('validate consistency', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.warnings.some(w => w.includes('Gap in phase numbering')), 'should warn about gap' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // verify plan-structure command // ───────────────────────────────────────────────────────────────────────────── describe('verify plan-structure command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); test('reports missing required frontmatter fields', () => { const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, '# No frontmatter here\n\nJust a plan without YAML.\n'); const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('Missing required frontmatter field')), `Expected "Missing required frontmatter field" in errors: ${JSON.stringify(output.errors)}` ); }); test('validates complete plan with all required fields and tasks', () => { const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, validPlanContent()); const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.valid, true, `should be valid, errors: ${JSON.stringify(output.errors)}`); assert.deepStrictEqual(output.errors, [], 'should have no errors'); assert.strictEqual(output.task_count, 1, 'should have 1 task'); }); test('reports task missing name element', () => { const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [some/file.ts]', 'autonomous: true', 'must_haves:', ' truths:', ' - "something"', '---', '', '', '', ' Do it', ' echo ok', ' Done', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, content); const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.errors.some(e => e.includes('Task missing ')), `Expected "Task missing " in errors: ${JSON.stringify(output.errors)}` ); }); test('reports task missing action element', () => { const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [some/file.ts]', 'autonomous: true', 'must_haves:', ' truths:', ' - "something"', '---', '', '', '', ' Task 1: No action', ' echo ok', ' Done', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, content); const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " in errors: ${JSON.stringify(output.errors)}` ); }); test('warns about wave > 1 with empty depends_on', () => { const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, validPlanContent({ wave: 2, dependsOn: '[]' })); const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.warnings.some(w => w.includes('Wave > 1 but depends_on is empty')), `Expected "Wave > 1 but depends_on is empty" in warnings: ${JSON.stringify(output.warnings)}` ); }); test('errors when checkpoint task but autonomous is true', () => { const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [some/file.ts]', 'autonomous: true', 'must_haves:', ' truths:', ' - "something"', '---', '', '', '', ' Task 1: Normal', ' some/file.ts', ' Do it', ' echo ok', ' Done', '', '', ' Task 2: Verify UI', ' UI at localhost:3000', ' Visit the app', ' Type "approved"', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, content); const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.errors.some(e => e.includes('checkpoint tasks but autonomous is not false')), `Expected checkpoint/autonomous error in errors: ${JSON.stringify(output.errors)}` ); }); test('returns error for nonexistent file', () => { const result = runGsdTools('verify plan-structure .planning/phases/01-test/nonexistent.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.error, `Expected error field in output: ${JSON.stringify(output)}`); assert.ok( output.error.includes('File not found'), `Expected "File not found" in error: ${output.error}` ); }); }); // ───────────────────────────────────────────────────────────────────────────── // verify plan-structure — checkpoint task types (#2444) // A checkpoint:* task uses type-specific required fields (per // gsd-core/references/checkpoints.md), NOT the auto-task //. // ───────────────────────────────────────────────────────────────────────────── describe('verify plan-structure — checkpoint task types (#2444)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); // Helper: wrap a task body in a complete valid PLAN.md scaffold. function planWithTask(taskBody, { autonomous = 'false' } = {}) { return [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [some/file.ts]', `autonomous: ${autonomous}`, 'must_haves:', ' truths:', ' - "something"', '---', '', '', taskBody, '', ].join('\n'); } function runVerify(planContent) { const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, planContent); const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); return JSON.parse(result.output); } // ── AC1: canonical checkpoint tasks pass with zero findings ──────────────── test('checkpoint:human-verify with canonical triple passes (AC1)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: verify UI', ' Dashboard at localhost:3000', ' Visit /dashboard, check layout', ' Type "approved" or describe issues', '', ].join('\n'))); assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`); assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`); assert.strictEqual(output.task_count, 1, 'should count the checkpoint task'); }); test('checkpoint:decision with canonical fields passes (AC1)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: pick auth provider', ' Select authentication provider', ' Need user authentication.', ' ', ' ', ' ', ' ', ' Select: supabase or clerk', '', ].join('\n'))); assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`); assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`); }); test('checkpoint:human-action with canonical fields passes (AC1)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: complete email verification', ' Click the verification link in your inbox', ' I created the account; check your email.', ' API key works via curl', ' Type "done" when email verified', '', ].join('\n'))); assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`); assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`); }); test('unknown checkpoint:* subtype passes with just (forward-compat)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: future', ' Type "ok"', '', ].join('\n'))); assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`); assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`); }); test('mixed plan: auto task + checkpoint:human-verify task passes (AC1 realistic)', () => { const output = runVerify(planWithTask([ '', ' Task 1: build dashboard', ' src/dashboard.ts', ' Scaffold the dashboard', ' npm test', ' Dashboard renders', '', '', ' Checkpoint: visual review', ' Dashboard at localhost:3000', ' Visit /dashboard, check responsive layout', ' Type "approved"', '', ].join('\n'))); assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`); assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`); assert.strictEqual(output.task_count, 2, 'should count both tasks'); }); // ── AC2: checkpoint tasks missing required fields are still flagged ──────── test('checkpoint:human-verify missing is flagged (AC2)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: verify UI', ' UI at localhost:3000', ' Type "approved"', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('checkpoint:human-verify missing is flagged (AC2)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: verify UI', ' Visit /dashboard', ' Type "approved"', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('checkpoint:decision missing is flagged (AC2)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: pick', ' Select provider', ' Select: a or b', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('checkpoint:human-action missing is flagged (AC2)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: act', ' Do the thing', ' curl returns 200', ' Type "done"', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('checkpoint:decision missing is flagged (AC2)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: pick', ' ', ' ', ' ', ' Select: a', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('checkpoint:human-action missing is flagged (AC2)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: act', ' Do it.', ' curl returns 200', ' Type "done"', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('checkpoint:human-action missing is flagged (AC2)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: act', ' Do it', ' Do it.', ' Type "done"', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('any checkpoint:* missing is flagged (AC2)', () => { const output = runVerify(planWithTask([ '', ' Checkpoint: verify UI', ' UI', ' Visit', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('checkpoint task without a type attribute still gets non-checkpoint rules (regression guard)', () => { // A bare (no type=) is NOT treated as a checkpoint; current rules apply. const output = runVerify(planWithTask([ '', ' Bare task', ' x.ts', ' echo ok', ' ok', '', ].join('\n'))); assert.strictEqual(output.valid, false, 'should be invalid (missing )'); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); // ── AC3: non-checkpoint tasks missing fields are still flagged (no regression) ── test('non-checkpoint task missing is still flagged (AC3)', () => { const output = runVerify(planWithTask([ '', ' Task 1: no action', ' echo ok', ' Done', '', ].join('\n'), { autonomous: 'true' })); assert.ok( output.errors.some(e => e.includes('missing ')), `Expected "missing " error: ${JSON.stringify(output.errors)}` ); }); test('non-checkpoint task missing still warns (AC3)', () => { const output = runVerify(planWithTask([ '', ' Task 1: no verify', ' x.ts', ' Do it', ' Done', '', ].join('\n'), { autonomous: 'true' })); assert.ok( output.warnings.some(w => w.includes('missing ')), `Expected "missing " warning: ${JSON.stringify(output.warnings)}` ); }); test('non-checkpoint task missing still warns (AC3)', () => { const output = runVerify(planWithTask([ '', ' Task 1: no done', ' x.ts', ' Do it', ' echo ok', '', ].join('\n'), { autonomous: 'true' })); assert.ok( output.warnings.some(w => w.includes('missing ')), `Expected "missing " warning: ${JSON.stringify(output.warnings)}` ); }); test('non-checkpoint task missing still warns (AC3)', () => { const output = runVerify(planWithTask([ '', ' Task 1: no files', ' Do it', ' echo ok', ' Done', '', ].join('\n'), { autonomous: 'true' })); assert.ok( output.warnings.some(w => w.includes('missing ')), `Expected "missing " warning: ${JSON.stringify(output.warnings)}` ); }); // ── Security: type-attribute charset is bounded (no markup injection) ────── test('task type attribute with hostile markup fragment is not surfaced unsanitized', () => { // Per CONTRIBUTING.md §"Security and prompt-injection surfaces": a hostile // PLAN.md cannot inject unclosed-tag fragments into the verifier's typed // JSON output via the type= attribute. The charset [a-zA-Z0-9_:-] rejects // '<', '>', '(', '&', etc., so a payload like type=evil', ' Hostile', ' do', ' echo ok', ' ok', '', ].join('\n'), { autonomous: 'true' })); const hostile = output.tasks.find(t => t.name === 'Hostile'); assert.ok(hostile, `Expected to find Hostile task in output.tasks: ${JSON.stringify(output.tasks)}`); assert.ok( !/[<>()&]/.test(hostile.type), `Expected type to contain no markup chars; got: ${JSON.stringify(hostile.type)}` ); assert.strictEqual(hostile.type, 'evil', `Expected capture to stop at '<'; got: ${JSON.stringify(hostile.type)}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // verify phase-completeness command // ───────────────────────────────────────────────────────────────────────────── describe('verify phase-completeness command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); // Create ROADMAP.md referencing phase 01 so findPhaseInternal can locate it fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Test\n**Goal**: Test phase\n' ); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); test('reports complete phase with matching plans and summaries', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); const result = runGsdTools('verify phase-completeness 01', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.complete, true, `should be complete, errors: ${JSON.stringify(output.errors)}`); assert.strictEqual(output.plan_count, 1, 'should have 1 plan'); assert.strictEqual(output.summary_count, 1, 'should have 1 summary'); assert.deepStrictEqual(output.incomplete_plans, [], 'should have no incomplete plans'); }); test('reports incomplete phase with plan missing summary', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); const result = runGsdTools('verify phase-completeness 01', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.complete, false, 'should be incomplete'); assert.ok( output.incomplete_plans.some(id => id.includes('01-01')), `Expected "01-01" in incomplete_plans: ${JSON.stringify(output.incomplete_plans)}` ); assert.ok( output.errors.some(e => e.includes('Plans without summaries')), `Expected "Plans without summaries" in errors: ${JSON.stringify(output.errors)}` ); }); test('warns about orphan summaries', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); const result = runGsdTools('verify phase-completeness 01', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.warnings.some(w => w.includes('Summaries without plans')), `Expected "Summaries without plans" in warnings: ${JSON.stringify(output.warnings)}` ); }); test('returns error for nonexistent phase', () => { const result = runGsdTools('verify phase-completeness 99', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.error, `Expected error field in output: ${JSON.stringify(output)}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // verify-summary command // ───────────────────────────────────────────────────────────────────────────── describe('verify summary command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempGitProject(); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); test('returns not found for nonexistent summary', () => { const result = runGsdTools('verify-summary .planning/phases/01-test/nonexistent.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.passed, false, 'should not pass'); assert.strictEqual(output.checks.summary_exists, false, 'summary should not exist'); assert.ok( output.errors.some(e => e.includes('SUMMARY.md not found')), `Expected "SUMMARY.md not found" in errors: ${JSON.stringify(output.errors)}` ); }); test('passes for valid summary with real files and commits', () => { // Create a source file and commit it fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("hello");\n'); gitOrThrow(['add', '-A'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); gitOrThrow(['commit', '-m', 'add app.js'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); const hash = gitOrThrow(['rev-parse', '--short', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(); // Write SUMMARY.md referencing the file and commit hash const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', `Created: \`src/app.js\``, '', `Commit: ${hash}`, ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.passed, true, `should pass, errors: ${JSON.stringify(output.errors)}`); assert.strictEqual(output.checks.summary_exists, true, 'summary should exist'); assert.strictEqual(output.checks.commits_exist, true, 'commits should exist'); }); test('reports missing files mentioned in summary', () => { const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', 'Created: `src/nonexistent.js`', ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.checks.files_created.missing.includes('src/nonexistent.js'), `Expected missing to include "src/nonexistent.js": ${JSON.stringify(output.checks.files_created.missing)}` ); }); test('detects self-check section with pass indicators', () => { const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', '## Self-Check', '', 'All tests pass', ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.checks.self_check, 'passed', `Expected self_check "passed": ${JSON.stringify(output.checks)}`); }); test('detects self-check section with fail indicators', () => { const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', '## Verification', '', 'Tests failed', ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.checks.self_check, 'failed', `Expected self_check "failed": ${JSON.stringify(output.checks)}`); }); test('REG-03: returns self_check "not_found" when no self-check section exists', () => { const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', '## Accomplishments', '', 'Everything went well.', ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.checks.self_check, 'not_found', `Expected self_check "not_found": ${JSON.stringify(output.checks)}`); assert.strictEqual(output.passed, true, `Missing self-check should not fail: ${JSON.stringify(output)}`); }); test('search(-1) regression: self-check guard prevents entry when no heading', () => { // No Self-Check/Verification/Quality Check heading — guard on line 79 prevents // content.search(selfCheckPattern) from ever being called, so -1 is impossible const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', '## Notes', '', 'Some content here without a self-check heading.', ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Guard works: selfCheckPattern.test() is false, if block not entered, selfCheck stays 'not_found' assert.strictEqual(output.checks.self_check, 'not_found', `Expected not_found since no heading: ${JSON.stringify(output.checks)}`); }); test('respects checkFileCount parameter', () => { // Write summary referencing 5 files (none exist) const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', 'Files: `src/a.js`, `src/b.js`, `src/c.js`, `src/d.js`, `src/e.js`', ].join('\n')); // Pass checkFileCount = 1 so only 1 file is checked const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md --check-count 1', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.checks.files_created.checked <= 1, `Expected checked <= 1, got ${output.checks.files_created.checked}` ); }); // #2844: a prose MENTION of a path (not a creation claim) must not be treated // as a file claim. Pre-fix Pattern 1 matched any backticked path-like token, so // `shared/types.ts` in a "next phase will add…" sentence was checked for // existence and its absence failed the verdict on a healthy phase. test('#2844 a prose path mention is not treated as a missing file claim', () => { // Real created file exists. fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'src', 'real.ts'), 'export const x = 1;\n'); const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', 'This phase investigated the schema surface.', '', // PROSE mention — NOT a creation claim; shared/types.ts does NOT exist. 'Next phase will add `shared/types.ts` for the shared schema.', '', 'Created: `src/real.ts`', ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.passed, true, `prose mention must not fail the verdict; errors: ${JSON.stringify(output.errors)}`); assert.ok(!JSON.stringify(output.checks.files_created.missing).includes('shared/types.ts'), 'shared/types.ts (a prose mention, absent) must NOT be reported missing'); }); test('#2844 a SUMMARY with only future/prose path mentions passes', () => { // The mentioned paths are FUTURE deliverables (not produced this phase) and // are absent — they must not be probed. isFutureMention excludes the lines. const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', 'Investigation only. No artifacts created this phase.', '`docs/schema.md` is planned for a later phase.', 'Next phase will add `shared/types.ts` for the shared schema.', ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.passed, true, `future/prose mentions must not fail the verdict; errors: ${JSON.stringify(output.errors)}`); }); test('#2844 negative-space: a real Created claim for an ABSENT file still fails', () => { // src/missing.ts is claimed but does NOT exist — must still be caught. const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md'); fs.writeFileSync(summaryPath, [ '# Summary', '', 'Created: `src/missing.ts`', ].join('\n')); const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.passed, false, 'an absent claimed file must fail the verdict'); assert.ok(JSON.stringify(output.checks.files_created.missing).includes('src/missing.ts'), 'src/missing.ts must be reported missing'); }); }); // ───────────────────────────────────────────────────────────────────────────── // verify references command // ───────────────────────────────────────────────────────────────────────────── describe('verify references command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.mkdirSync(path.join(tmpDir, 'src', 'utils'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); test('reports valid when all referenced files exist', () => { fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("app");\n'); const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md'); fs.writeFileSync(filePath, '@src/app.js\n'); const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.valid, true, `should be valid: ${JSON.stringify(output)}`); assert.strictEqual(output.found, 1, `should find 1 file: ${JSON.stringify(output)}`); }); test('reports missing for nonexistent referenced files', () => { const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md'); fs.writeFileSync(filePath, '@src/missing.js\n'); const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.valid, false, 'should be invalid'); assert.ok( output.missing.includes('src/missing.js'), `Expected missing to include "src/missing.js": ${JSON.stringify(output.missing)}` ); }); test('detects backtick file paths', () => { fs.writeFileSync(path.join(tmpDir, 'src', 'utils', 'helper.js'), 'module.exports = {};\n'); const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md'); fs.writeFileSync(filePath, 'See `src/utils/helper.js` for details.\n'); const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.found >= 1, `Expected at least 1 found, got ${output.found}`); }); test('skips backtick template expressions', () => { // Template expressions like ${variable} in backtick paths are skipped // @-refs with http are processed but not found on disk const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md'); fs.writeFileSync(filePath, '`${variable}/path/file.js`\n'); const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Template expression is skipped entirely — total should be 0 assert.strictEqual(output.total, 0, `Expected total 0 (template skipped): ${JSON.stringify(output)}`); }); test('returns error for nonexistent file', () => { const result = runGsdTools('verify references .planning/phases/01-test/nonexistent.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.error, `Expected error field: ${JSON.stringify(output)}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // verify commits command // ───────────────────────────────────────────────────────────────────────────── describe('verify commits command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempGitProject(); }); afterEach(() => { cleanup(tmpDir); }); test('validates real commit hashes', () => { const hash = gitOrThrow(['rev-parse', '--short', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(); const result = runGsdTools(`verify commits ${hash}`, tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_valid, true, `Expected all_valid true: ${JSON.stringify(output)}`); assert.ok(output.valid.includes(hash), `Expected valid to include ${hash}: ${JSON.stringify(output.valid)}`); }); test('reports invalid for fake hashes', () => { const result = runGsdTools('verify commits abcdef1234567', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_valid, false, `Expected all_valid false: ${JSON.stringify(output)}`); assert.ok( output.invalid.includes('abcdef1234567'), `Expected invalid to include "abcdef1234567": ${JSON.stringify(output.invalid)}` ); }); test('handles mixed valid and invalid hashes', () => { const hash = gitOrThrow(['rev-parse', '--short', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(); const result = runGsdTools(`verify commits ${hash} abcdef1234567`, tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.valid.length, 1, `Expected 1 valid: ${JSON.stringify(output)}`); assert.strictEqual(output.invalid.length, 1, `Expected 1 invalid: ${JSON.stringify(output)}`); assert.strictEqual(output.all_valid, false, `Expected all_valid false: ${JSON.stringify(output)}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // verify artifacts command // ───────────────────────────────────────────────────────────────────────────── describe('verify artifacts command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); function writePlanWithArtifacts(tmpDir, artifactsYaml) { // parseMustHavesBlock expects 4-space indent for block name, 6-space for items, 8-space for keys const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [src/app.js]', 'autonomous: true', 'must_haves:', ' artifacts:', ...artifactsYaml.map(line => ` ${line}`), '---', '', '', '', ' Task 1: Do thing', ' src/app.js', ' Do it', ' echo ok', ' Done', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, content); } test('passes when all artifacts exist and match criteria', () => { writePlanWithArtifacts(tmpDir, [ '- path: "src/app.js"', ' min_lines: 2', ' contains: "export"', ]); fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'const x = 1;\nexport default x;\nconst y = 2;\n'); const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_passed, true, `Expected all_passed true: ${JSON.stringify(output)}`); }); test('reports missing artifact file', () => { writePlanWithArtifacts(tmpDir, [ '- path: "src/nonexistent.js"', ]); const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_passed, false, 'Expected all_passed false'); assert.ok( output.artifacts[0].issues.some(i => i.includes('File not found')), `Expected "File not found" in issues: ${JSON.stringify(output.artifacts[0].issues)}` ); }); test('reports insufficient line count', () => { writePlanWithArtifacts(tmpDir, [ '- path: "src/app.js"', ' min_lines: 10', ]); fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'const x = 1;\n'); const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_passed, false, 'Expected all_passed false'); assert.ok( output.artifacts[0].issues.some(i => i.includes('Only') && i.includes('lines, need 10')), `Expected line count issue: ${JSON.stringify(output.artifacts[0].issues)}` ); }); test('reports missing pattern', () => { writePlanWithArtifacts(tmpDir, [ '- path: "src/app.js"', ' contains: "module.exports"', ]); fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'const x = 1;\n'); const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_passed, false, 'Expected all_passed false'); assert.ok( output.artifacts[0].issues.some(i => i.includes('Missing pattern')), `Expected "Missing pattern" in issues: ${JSON.stringify(output.artifacts[0].issues)}` ); }); test('reports missing export', () => { writePlanWithArtifacts(tmpDir, [ '- path: "src/app.js"', ' exports:', ' - GET', ]); fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'const x = 1;\nexport const POST = () => {};\n'); const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_passed, false, 'Expected all_passed false'); assert.ok( output.artifacts[0].issues.some(i => i.includes('Missing export')), `Expected "Missing export" in issues: ${JSON.stringify(output.artifacts[0].issues)}` ); }); test('returns error when no artifacts in frontmatter', () => { const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [src/app.js]', 'autonomous: true', 'must_haves:', ' truths:', ' - "something is true"', '---', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, content); const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.error, `Expected error field: ${JSON.stringify(output)}`); assert.ok( output.error.includes('No must_haves.artifacts'), `Expected "No must_haves.artifacts" in error: ${output.error}` ); }); }); // ───────────────────────────────────────────────────────────────────────────── // verify key-links command // ───────────────────────────────────────────────────────────────────────────── describe('verify key-links command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true }); fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); function writePlanWithKeyLinks(tmpDir, keyLinksYaml) { // parseMustHavesBlock expects 4-space indent for block name, 6-space for items, 8-space for keys const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [src/a.js]', 'autonomous: true', 'must_haves:', ' key_links:', ...keyLinksYaml.map(line => ` ${line}`), '---', '', '', '', ' Task 1: Do thing', ' src/a.js', ' Do it', ' echo ok', ' Done', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, content); } test('verifies link when pattern found in source', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/a.js"', ' to: "src/b.js"', ' pattern: "import.*b"', ]); fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), "import { x } from './b';\n"); fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'exports.x = 1;\n'); const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_verified, true, `Expected all_verified true: ${JSON.stringify(output)}`); }); test('verifies link when pattern found in target', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/a.js"', ' to: "src/b.js"', ' pattern: "exports\\.targetFunc"', ]); // pattern NOT in source, but found in target fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), 'const x = 1;\n'); fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'exports.targetFunc = () => {};\n'); const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_verified, true, `Expected verified via target: ${JSON.stringify(output)}`); assert.ok( output.links[0].detail.includes('target'), `Expected detail about target: ${output.links[0].detail}` ); }); test('fails when pattern not found in source or target', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/a.js"', ' to: "src/b.js"', ' pattern: "missingPattern"', ]); fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), 'const x = 1;\n'); fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'const y = 2;\n'); const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_verified, false, `Expected all_verified false: ${JSON.stringify(output)}`); assert.strictEqual(output.links[0].verified, false, 'link should not be verified'); }); test('verifies link without pattern using string inclusion', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/a.js"', ' to: "src/b.js"', ]); // source file contains the 'to' value as a string fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), "const b = require('./src/b.js');\n"); fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'module.exports = {};\n'); const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.all_verified, true, `Expected all_verified true: ${JSON.stringify(output)}`); assert.ok( output.links[0].detail.includes('Target referenced in source'), `Expected "Target referenced in source" in detail: ${output.links[0].detail}` ); }); test('reports source file not found', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/nonexistent.js"', ' to: "src/b.js"', ' pattern: "something"', ]); fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'module.exports = {};\n'); const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok( output.links[0].detail.includes('Source file not found'), `Expected "Source file not found" in detail: ${output.links[0].detail}` ); }); test('returns error when no key_links in frontmatter', () => { const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [src/a.js]', 'autonomous: true', 'must_haves:', ' truths:', ' - "something is true"', '---', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.writeFileSync(planPath, content); const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(output.error, `Expected error field: ${JSON.stringify(output)}`); assert.ok( output.error.includes('No must_haves.key_links'), `Expected "No must_haves.key_links" in error: ${output.error}` ); }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-967-verify-key-links-strict-paths.test.cjs — consolidation epic #1969 (B2 #1971) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-967-verify-key-links-strict-paths (consolidation epic #1969 B2 #1971)", () => { /** * Regression test for bug #967: verify key-links reads from:/to: as literal * relative file paths; the reference docs wrongly implied component/endpoint * values were valid. Fix direction: author-strict — docs corrected to match code. * * Contract pinned here: * 1. from: must be a relative file path; pattern: is evaluated against its content. * 2. from: pointing to a non-existent file → verified:false, detail "Source file not found". * 3. docs/reference/plan-md.md reference example uses a file path for to: (NOT /api/feed). */ 'use strict'; const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); // ─── helpers ────────────────────────────────────────────────────────────────── function writePlanWithKeyLinks(tmpDir, keyLinksYaml, opts) { // parseMustHavesBlock expects 4-space indent for block name, 6-space for items const wave = (opts && opts.wave != null) ? opts.wave : 1; const filesModified = (opts && opts.filesModified) ? opts.filesModified : ['src/a.js']; const filesModifiedYaml = filesModified.length === 1 ? `[${filesModified[0]}]` : `[${filesModified.join(', ')}]`; const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', `wave: ${wave}`, 'depends_on: []', `files_modified: ${filesModifiedYaml}`, 'autonomous: true', 'must_haves:', ' key_links:', ...keyLinksYaml.map(line => ` ${line}`), '---', '', '', '', ' Task 1: Do thing', ' src/a.js', ' Do it', ' echo ok', ' Done', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md'); fs.mkdirSync(path.dirname(planPath), { recursive: true }); fs.writeFileSync(planPath, content); } /** * Write an additional plan file in the same phase directory with specific * wave + files_modified (no key_links, just declaring future artifacts). */ function writeCompanionPlan(tmpDir, planFileName, wave, filesModified) { const filesModifiedYaml = `[${filesModified.join(', ')}]`; const content = [ '---', 'phase: 01-test', 'plan: 02', 'type: execute', `wave: ${wave}`, 'depends_on: []', `files_modified: ${filesModifiedYaml}`, 'autonomous: true', 'must_haves:', ' key_links: []', '---', '', '', '', ' Task 2: Create file', ' src/b.js', ' Create it', ' echo ok', ' Done', '', '', ].join('\n'); const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', planFileName); fs.writeFileSync(planPath, content); } describe('bug-967 verify key-links strict file-path contract', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); // ── 1. Happy path: from: is a real file path and pattern: matches ────────── test('verified:true when from: is a relative file path and pattern: matches', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/component.js"', ' to: "src/api/feed.js"', ' via: "fetch in useEffect"', ' pattern: "fetch.*api/feed"', ]); // Create the source file containing the pattern fs.writeFileSync( path.join(tmpDir, 'src', 'component.js'), "fetch('/api/feed').then(r => r.json());\n", ); // Create the target file too (not strictly needed for this path, but realistic) fs.mkdirSync(path.join(tmpDir, 'src', 'api'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'src', 'api', 'feed.js'), 'module.exports = {};\n'); const result = runGsdTools( 'verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir, ); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.all_verified, true, `Expected all_verified:true (file-path from: + matching pattern:). Got: ${JSON.stringify(output)}`, ); assert.strictEqual(output.links[0].verified, true); }); // ── 2. Contract: missing source file → verified:false, explicit detail ───── test('verified:false with "Source file not found" detail when from: file does not exist', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/missing-file.js"', ' to: "src/api/feed.js"', ' via: "fetch in useEffect"', ' pattern: "fetch.*api/feed"', ]); // Deliberately do NOT create src/missing-file.js const result = runGsdTools( 'verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir, ); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual( output.links[0].verified, false, `Expected verified:false for absent source file. Got: ${JSON.stringify(output.links[0])}`, ); assert.ok( output.links[0].detail.includes('Source file not found'), `Expected detail to include "Source file not found". Got: "${output.links[0].detail}"`, ); }); // ── 3. Regression #1202: missing from: file promised by a same-wave plan → pending:true ── // // A from: file absent on disk but listed in files_modified of another plan at // the same wave must be reported pending:true (not verified:false) and must NOT // count against the all_verified gate. // // This test MUST FAIL before the fix is applied (the gate hard-fails today). test('pending:true and all_verified:true when from: file is promised by a same-wave plan', () => { // Plan under test is wave 2; it references src/future-artifact.js which does not // exist on disk yet. writePlanWithKeyLinks(tmpDir, [ '- from: "src/future-artifact.js"', ' to: "src/consumer.js"', ' via: "requires future-artifact"', ' pattern: "future-artifact"', ], { wave: 2, filesModified: ['src/consumer.js'] }); // A companion plan also at wave 2 declares src/future-artifact.js in files_modified writeCompanionPlan(tmpDir, '01-02-PLAN.md', 2, ['src/future-artifact.js']); // Do NOT create src/future-artifact.js on disk — it is a planned future file const result = runGsdTools( 'verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir, ); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual( out.links[0].pending, true, `Expected pending:true for a from: file promised by a same-wave plan. Got: ${JSON.stringify(out.links[0])}`, ); assert.strictEqual( out.all_verified, true, `Expected all_verified:true (pending links should not fail the gate). Got: ${JSON.stringify(out)}`, ); assert.strictEqual( out.links[0].verified, false, `Expected verified:false (file is not yet verified — just pending). Got: ${JSON.stringify(out.links[0])}`, ); }); // ── 4. Regression #1202: missing from: file promised by a LATER-wave plan → pending:true ── test('pending:true and all_verified:true when from: file is promised by a later-wave plan', () => { // Plan under test is wave 1; companion plan is wave 3 (later wave promises the file) writePlanWithKeyLinks(tmpDir, [ '- from: "src/later-artifact.js"', ' to: "src/consumer.js"', ' via: "later wave dependency"', ], { wave: 1, filesModified: ['src/consumer.js'] }); writeCompanionPlan(tmpDir, '01-02-PLAN.md', 3, ['src/later-artifact.js']); const result = runGsdTools( 'verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir, ); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual( out.links[0].pending, true, `Expected pending:true for from: file promised by a later-wave plan. Got: ${JSON.stringify(out.links[0])}`, ); assert.strictEqual( out.all_verified, true, `Expected all_verified:true (pending links not counted against gate). Got: ${JSON.stringify(out)}`, ); }); // ── 5. Regression #1202: missing from: file NOT promised by any plan → hard failure ── // // Absence of from: file with no plan promising it must remain a genuine verified:false failure. test('verified:false and all_verified:false when from: file is absent and not promised by any plan', () => { writePlanWithKeyLinks(tmpDir, [ '- from: "src/truly-missing.js"', ' to: "src/consumer.js"', ' via: "no plan promises this"', ], { wave: 1, filesModified: ['src/consumer.js'] }); // No companion plan that promises src/truly-missing.js const result = runGsdTools( 'verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir, ); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual( out.links[0].verified, false, `Expected verified:false for absent+unpromised from: file. Got: ${JSON.stringify(out.links[0])}`, ); assert.strictEqual( out.all_verified, false, `Expected all_verified:false (hard failure). Got: ${JSON.stringify(out)}`, ); // pending must not be true assert.notStrictEqual( out.links[0].pending, true, `Expected pending not to be true for an absent+unpromised file. Got: ${JSON.stringify(out.links[0])}`, ); }); // ── 6. Doc-contract guard: reference example must use a file path for to: ── // // The old reference example had to: "/api/feed" (an HTTP endpoint). // After fix #967, to: must be a relative file path like "app/api/feed/route.ts". // This test reads the canonical docs file and asserts the example is consistent // with the strict-path contract. // // allow-test-rule: source-text-is-the-product the plan-md.md reference (see #967) // example IS the documented authoring surface for key_links; asserting it uses // a file path (not an endpoint) directly tests the documented contract. test('docs/reference/plan-md.md key_links example uses a relative file path for to:, not an HTTP endpoint', () => { // Locate plan-md.md relative to this test file's repo root const docPath = path.join(__dirname, '..', 'docs', 'reference', 'plan-md.md'); assert.ok(fs.existsSync(docPath), `plan-md.md not found at ${docPath}`); const content = fs.readFileSync(docPath, 'utf-8'); // allow-test-rule: source-text-is-the-product the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract. (see #967) // Find the key_links block in the annotated example (the first YAML frontmatter fence) // The bad old value was: to: "/api/feed" assert.ok( !content.includes('to: "/api/feed"'), 'docs/reference/plan-md.md still contains the endpoint-style to: "/api/feed" — ' + 'the reference example must use a relative file path (e.g. "app/api/feed/route.ts") ' + 'to match the strict file-path contract.', ); // Also assert the corrected example actually uses a path-like value // (must contain at least one '/' and not start with 'http') const toMatch = content.match(/key_links:[\s\S]*?to:\s*"([^"]+)"/); assert.ok( toMatch, 'Could not find a to: field in the key_links example in plan-md.md', ); const toValue = toMatch[1]; assert.ok( !toValue.startsWith('/api') && !toValue.startsWith('http'), `to: value in the docs example looks like an HTTP endpoint: "${toValue}". ` + 'It must be a relative file path.', ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/enh-2446-milestones-drift.test.cjs — consolidation epic #1969 (B3 #1972) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:enh-2446-milestones-drift (consolidation epic #1969 B3 #1972)", () => { 'use strict'; // allow-test-rule: source-text-is-the-product (see #2446) // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. /** * Tests for gsd-health MILESTONES.md drift detection (#2446). */ const { test, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const helpers = require('./helpers.cjs'); const { cmdValidateHealth } = require('../gsd-core/bin/lib/verify.cjs'); const _dirsToClean = []; after(() => { for (const d of _dirsToClean) helpers.cleanup(d); }); function makeTempProject(files = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2446-')); _dirsToClean.push(dir); fs.mkdirSync(path.join(dir, '.planning', 'milestones'), { recursive: true }); for (const [rel, content] of Object.entries(files)) { const abs = path.join(dir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, content, 'utf-8'); } return dir; } test('W018: warns when archived snapshot has no MILESTONES.md entry', () => { const dir = makeTempProject({ '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', '.planning/ROADMAP.md': '# Roadmap\n', '.planning/STATE.md': '# State\n', '.planning/config.json': '{}', '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n', // No MILESTONES.md entry for v1.0 }); const result = cmdValidateHealth(dir, { repair: false }, false); const w018 = result.warnings.find(w => w.code === 'W018'); assert.ok(w018, 'W018 warning should be emitted'); assert.ok(w018.message.includes('v1.0'), 'warning should mention v1.0'); assert.ok(w018.repairable, 'W018 should be marked repairable'); }); test('no W018 when all snapshots have MILESTONES.md entries', () => { const dir = makeTempProject({ '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', '.planning/ROADMAP.md': '# Roadmap\n', '.planning/STATE.md': '# State\n', '.planning/config.json': '{}', '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n', '.planning/MILESTONES.md': '# Milestones\n\n## v1.0 My App (Shipped: 2026-01-01)\n\n---\n\n', }); const result = cmdValidateHealth(dir, { repair: false }, false); const w018 = result.warnings.find(w => w.code === 'W018'); assert.strictEqual(w018, undefined, 'no W018 when entries are present'); }); test('no W018 when milestones archive dir is empty', () => { const dir = makeTempProject({ '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', '.planning/ROADMAP.md': '# Roadmap\n', '.planning/STATE.md': '# State\n', '.planning/config.json': '{}', // No snapshots in milestones/ }); const result = cmdValidateHealth(dir, { repair: false }, false); const w018 = result.warnings.find(w => w.code === 'W018'); assert.strictEqual(w018, undefined, 'no W018 with empty archive dir'); }); test('--backfill synthesizes missing MILESTONES.md entry from snapshot', () => { const dir = makeTempProject({ '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', '.planning/ROADMAP.md': '# Roadmap\n', '.planning/STATE.md': '# State\n', '.planning/config.json': '{}', '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n', }); cmdValidateHealth(dir, { repair: true, backfill: true }, false); const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md'); assert.ok(fs.existsSync(milestonesPath), 'MILESTONES.md should be created'); const content = fs.readFileSync(milestonesPath, 'utf-8'); assert.ok(content.includes('## v1.0'), 'backfilled entry should contain v1.0'); assert.ok(content.includes('Backfilled'), 'should note it was backfilled'); }); test('health.md mentions --backfill flag', () => { const healthMd = fs.readFileSync( path.join(__dirname, '../gsd-core/workflows/health.md'), 'utf-8' ); assert.ok(healthMd.includes('--backfill'), 'health.md should document --backfill'); assert.ok(healthMd.includes('W018'), 'health.md should list W018 error code'); assert.ok(healthMd.includes('backfillMilestones'), 'repair_actions should include backfillMilestones'); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/enh-968-region-scoped-negative-grep.test.cjs — consolidation epic #1969 (B3 #1972) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:enh-968-region-scoped-negative-grep (consolidation epic #1969 B3 #1972)", () => { // allow-test-rule: source-text-is-the-product #968 // Enhancement #968: region-scoped negative gate detector + guidance docs. // Tests the pure function scanFileWideNegativeGateConflict exported from // verify.cjs, plus CLI integration and doc-contract assertions. '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 os = require('node:os'); 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'); // Build paths to doc files const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md'); const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md'); // PLAN_MD_REF removed — was unused (doc-contract cases test PLANNER_MD and ANTIPATTERNS_MD only) // ─── Fixture helpers ─────────────────────────────────────────────────────────── /** * Build a minimal two-task plan fixture. * taskA: file=app/page.py, gateText=the verify/acceptance_criteria block, actionText=action block * taskB: file=app/page.py (default) or otherFile, action text * allowlistMarker: optional HTML comment to insert at the top */ function makeTwoTaskPlan({ taskAFile = 'app/page.py', taskAGate = '! grep -Eq \'await .*refresh\' app/page.py', taskAAction = 'Refactor the factory to be synchronous.', taskBFile = 'app/page.py', taskBAction = 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', allowlistMarker = '', } = {}) { const lines = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', `files_modified: [${taskAFile}, ${taskBFile}]`, 'autonomous: true', 'must_haves:', ' - AC1', '---', '', '# Test Plan', '', ]; if (allowlistMarker) { lines.push(allowlistMarker, ''); } // Task A: the one with the negative gate lines.push(''); lines.push('Task A: factory refactor'); lines.push(`${taskAFile}`); lines.push(`${taskAAction}`); lines.push(`${taskAGate}`); lines.push('Factory is synchronous.'); lines.push(''); lines.push(''); // Task B: the sibling that requires the construct lines.push(''); lines.push('Task B: reindex handler'); lines.push(`${taskBFile}`); lines.push(`${taskBAction}`); lines.push('npm test'); lines.push('Handler is in place.'); lines.push(''); return lines.join('\n'); } /** * Build a single-task plan (no sibling). */ function makeSingleTaskPlan({ taskFile = 'app/page.py', taskGate = '! grep -Eq \'await .*refresh\' app/page.py', taskAction = 'Refactor the factory to be synchronous.', } = {}) { return [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', `files_modified: [${taskFile}]`, 'autonomous: true', 'must_haves:', ' - AC1', '---', '', '# Test Plan', '', '', 'Task A: factory refactor', `${taskFile}`, `${taskAction}`, `${taskGate}`, 'Factory is synchronous.', '', ].join('\n'); } // ─── Group 1: pure-function unit tests ──────────────────────────────────────── describe('scanFileWideNegativeGateConflict — pure unit tests', () => { let scan; before(() => { const verify = require(VERIFY_CJS); scan = verify.scanFileWideNegativeGateConflict; assert.ok(typeof scan === 'function', 'scanFileWideNegativeGateConflict must be exported'); }); // Case 1: basic WARN path — Task A bans PAT file-wide, Task B requires it in same file test('case 1 — file-wide ban + sibling requires → WARN', () => { const content = makeTwoTaskPlan({ taskAGate: "! grep -Eq 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }'); assert.ok( result.warnings.length >= 1, `expected at least 1 warning, got: ${JSON.stringify(result.warnings)}`, ); assert.ok( result.warnings[0].includes('Region-scope conflict (#968)'), `warning must mention Region-scope conflict (#968), got: ${result.warnings[0]}`, ); assert.ok( result.warnings[0].includes('await .*refresh'), `warning must mention the PAT, got: ${result.warnings[0]}`, ); assert.ok( result.warnings[0].includes('app/page.py'), `warning must mention the file, got: ${result.warnings[0]}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 2: region-scoped via sed → NO warn test('case 2 — region-scoped via sed pipe → NO warn', () => { const content = makeTwoTaskPlan({ taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `sed-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 2b: region-scoped via awk → NO warn test('case 2b — region-scoped via awk pipe → NO warn', () => { const content = makeTwoTaskPlan({ taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `awk-piped grep is region-scoped — must not warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 3: single task file-wide ban, no sibling → NO warn test('case 3 — single task, no sibling → NO warn', () => { const content = makeSingleTaskPlan({ taskGate: "! grep -Eq 'await .*refresh' app/page.py", }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `single task (no sibling) must not warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 4: sibling requires PAT but lists a different file → NO warn test('case 4 — sibling lists different file → NO warn', () => { const content = makeTwoTaskPlan({ taskAFile: 'app/page.py', taskAGate: "! grep -Eq 'await .*refresh' app/page.py", taskBFile: 'app/other.py', taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `sibling with different file must not warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 5: sibling lists same file but action lacks PAT → NO warn test('case 5 — sibling lists same file but action lacks PAT → NO warn', () => { const content = makeTwoTaskPlan({ taskAGate: "! grep -Eq 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls bridge.sync() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `sibling with no PAT in action must not warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 6: positive grep (no !) + sibling → NO warn (positive requirement, not a ban) test('case 6 — positive grep (no !) + sibling → NO warn', () => { const content = makeTwoTaskPlan({ taskAGate: "grep -q 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `positive grep must not warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 7: inverted grep -v with ! + sibling → NO warn test('case 7 — inverted grep -vq with ! + sibling → NO warn', () => { const content = makeTwoTaskPlan({ taskAGate: "! grep -vq 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `inverted grep (-v) must not warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 8: allowlist marker present → NO warn test('case 8 — allowlist marker suppresses warn', () => { const content = makeTwoTaskPlan({ taskAGate: "! grep -Eq 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', allowlistMarker: '', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `allowlist marker must suppress warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 9: one task both bans and requires same PAT in same file (no second task) → NO warn test('case 9 — one task bans and requires PAT (no sibling) → NO warn', () => { const content = makeSingleTaskPlan({ taskGate: "! grep -Eq 'await .*refresh' app/page.py", taskAction: 'Refactor to avoid await refresh, but note that bridge.refresh() is used later.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `single task (no sibling B) must not warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 10: count form `grep -c 'PAT' FILE == 0` + sibling → WARN test('case 10 — count form (grep -c PAT FILE == 0) + sibling → WARN', () => { const content = makeTwoTaskPlan({ taskAGate: "grep -c 'await .*refresh' app/page.py == 0", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `count form (grep -c ... == 0) must warn, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 11: bracket form `[ $(grep -c PAT FILE) -eq 0 ]` + sibling → WARN test('case 11 — bracket form ([ $(grep -c PAT FILE) -eq 0 ]) + sibling → WARN', () => { const content = makeTwoTaskPlan({ taskAGate: "[ $(grep -c 'await .*refresh' app/page.py) -eq 0 ]", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `bracket form must warn, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 12: CRLF variant of case 1 → WARN test('case 12 — CRLF line endings → WARN', () => { const content = makeTwoTaskPlan({ taskAGate: "! grep -Eq 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const crlfContent = content.split('\n').join('\r\n'); const result = scan(crlfContent); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `CRLF content must still warn, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 13: backslash line-continuation variant → WARN test('case 13 — backslash line continuation → WARN', () => { // Build manually to control exact line continuation const lines = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: [app/page.py]', 'autonomous: true', 'must_haves:', ' - AC1', '---', '', '', 'Task A: factory refactor', 'app/page.py', 'Refactor the factory to be synchronous.', // Gate split across lines with backslash continuation "! grep -Eq 'await .*refresh' \\\napp/page.py", 'Factory is synchronous.', '', '', '', 'Task B: reindex handler', 'app/page.py', 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', 'npm test', 'Handler is in place.', '', ].join('\n'); const result = scan(lines); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `backslash continuation must still warn, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 14: mixed line with positive gate AND a negative gate, sibling → WARN (on the negative only) test('case 14 — mixed positive+negative on one segment + sibling → WARN for negative', () => { const content = makeTwoTaskPlan({ taskAGate: "grep -c 'X' app/page.py == 1 && ! grep -Eq 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `mixed line with negative gate + sibling must warn, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 15: glob file arg `app/*.py` → NO warn (unresolvable path) test('case 15 — glob file arg → NO warn (unresolvable)', () => { const content = makeTwoTaskPlan({ taskAGate: "! grep -Eq 'await .*refresh' app/*.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `glob file arg must not warn (unresolvable), got: ${JSON.stringify(result.warnings)}`, ); }); // Case 16: invalid-regex PAT literal fallback → WARN, no exception test('case 16 — invalid-regex PAT → literal fallback, WARN, no exception', () => { // "await (refresh" — unbalanced paren, invalid regex const content = makeTwoTaskPlan({ taskAGate: "! grep -Eq 'await (refresh' app/page.py", taskBAction: 'The handler calls await (refresh on bridge to repopulate state.', }); let result; assert.doesNotThrow(() => { result = scan(content); }, 'scan must not throw on invalid regex PAT'); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `invalid-regex PAT with literal match must warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 17: ReDoS-ish PAT (catastrophic backtracking) → no hang, no false warn. // The sibling action is 5000 'a's — classic ReDoS trigger if we call new RegExp('(a+)+$'). // Proof-of-no-hang: the test runner's own timeout catches it; a hanging test fails here. // No timing assertion (flaky) — the linear patternRequiredIn implementation is microsecond-fast. test('case 17 — catastrophic ReDoS pattern is instant, no hang, no false warn', () => { const longAs = 'a'.repeat(5000); const content = makeTwoTaskPlan({ taskAGate: "! grep -Eq '(a+)+$' app/page.py", taskBAction: `Reindex handler that processes ${longAs} records and calls bridge.refresh().`, }); let result; assert.doesNotThrow(() => { result = scan(content); }, 'scan must not throw on ReDoS-ish PAT'); // The literal '(a+)+$' is not present in the action text as a substring → no warn. // (If new RegExp were used, this test would hang before reaching this assertion.) assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `catastrophic PAT '(a+)+$' not literally in action — must not warn, got: ${JSON.stringify(result.warnings)}`, ); assert.ok(Array.isArray(result.warnings), 'valid result shape'); }); // Case 23 (mutation-catching): cat producer = file-wide → WARN; sed producer = region-scoped → NO warn test('case 23a — cat pipe: ! cat app/page.py | grep -Eq PAT + sibling → WARN (file-wide via cat)', () => { const content = makeTwoTaskPlan({ taskAGate: "! cat app/page.py | grep -Eq 'await .*refresh'", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `cat-piped grep is file-wide — must warn, got: ${JSON.stringify(result.warnings)}`, ); assert.ok(result.valid !== false, 'valid must remain true even when #968 warns'); }); test('case 23b — sed pipe: ! sed -n "12,40p" app/page.py | grep -Eq PAT + sibling → NO warn (region-scoped)', () => { const content = makeTwoTaskPlan({ taskAGate: "! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `sed-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 24: awk region → NO warn test('case 24 — awk region pipe: ! awk \'/^def make_page/,/^def /\' app/page.py | grep -Eq PAT + sibling → NO warn', () => { const content = makeTwoTaskPlan({ taskAGate: "! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `awk-piped grep is region-scoped — must NOT warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 25: basename non-over-match — different dirs, same basename → NO warn test('case 25 — basename non-over-match: different dirs same filename → NO warn', () => { // Task A bans on apps/web/config.py; Task B lists apps/admin/config.py // Same basename "config.py" but different dirs → must NOT warn const content = makeTwoTaskPlan({ taskAFile: 'apps/web/config.py', taskAGate: "! grep -Eq 'await .*refresh' apps/web/config.py", taskBFile: 'apps/admin/config.py', taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `different dirs (apps/web/config.py vs apps/admin/config.py) — same basename but must NOT warn, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 26: extensionless known file (Dockerfile) recognized via knownFiles → WARN test('case 26 — extensionless known file (Dockerfile) via knownFiles → WARN', () => { // Task A has ! grep -Eq 'FROM scratch' Dockerfile // Dockerfile has no extension, so looksLikePath would miss it — but knownFiles should catch it // Task B lists Dockerfile in and action requires 'FROM scratch' const content = makeTwoTaskPlan({ taskAFile: 'Dockerfile', taskAGate: "! grep -Eq 'FROM scratch' Dockerfile", taskBFile: 'Dockerfile', taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `Dockerfile (extensionless, known via ) should be recognized — must warn, got: ${JSON.stringify(result.warnings)}`, ); assert.ok(result.valid !== false, 'valid must remain true'); }); // Case 27: wildcard semantic match — patternRequiredIn handles .* correctly test('case 27 — wildcard semantic match: "await .*refresh" (gate) warns when action has "await bridge.refresh()"', () => { const content = makeTwoTaskPlan({ taskAGate: "! grep -Eq 'await .*refresh' app/page.py", taskBAction: 'Add handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `patternRequiredIn must match "await .*refresh" against "await bridge.refresh()" — must warn, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 4b: same-file positive control — sibling lists the SAME banned file + requires PAT → WARN // Paired with case 4: proves the no-warn in case 4 is due to the file mismatch, not a dead detector. test('case 4b — same-file positive control: sibling lists same file → WARN (proves case 4 no-warn is file-mismatch)', () => { const content = makeTwoTaskPlan({ taskAFile: 'app/page.py', taskAGate: "! grep -Eq 'await .*refresh' app/page.py", taskBFile: 'app/page.py', taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `same-file sibling must warn — proves case 4's no-warn is due to file mismatch, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 7b: non-inverted positive control — without -v the ban IS detected → WARN // Paired with case 7: proves the -v skip is what suppresses case 7. test('case 7b — non-inverted positive control: ! grep -q (no -v) + sibling → WARN (proves case 7 no-warn is -v skip)', () => { const content = makeTwoTaskPlan({ taskAGate: "! grep -q 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `non-inverted ! grep -q must warn — proves the -v flag is what suppresses case 7, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 25b: basename-fallback positive — bare unqualified filename matches sibling's qualified path → WARN // Paired with case 25: proves the bare-name basename fallback at src ~line 525 actually fires. // Case 25 only proves qualified paths don't over-match; this proves the bare fallback does fire. test('case 25b — basename-fallback positive: bare gate file matches sibling qualified path → WARN (proves basename fallback fires)', () => { // Task A gate uses bare "config.py" (no directory prefix — unqualified). // Task B lists "apps/admin/config.py" (qualified). basename("apps/admin/config.py") === "config.py". // The basename fallback (line 525) should match → WARN. const content = makeTwoTaskPlan({ taskAFile: 'config.py', taskAGate: "! grep -Eq 'await .*refresh' config.py", taskBFile: 'apps/admin/config.py', taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `bare gate file "config.py" must match sibling "apps/admin/config.py" via basename fallback — must warn, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 28: anchored pattern warns after ^ strip — proves anchor stripping works // Gate: ! grep -Eq '^FROM scratch' Dockerfile // Sibling B lists Dockerfile, action requires 'FROM scratch' (no anchor in prose). // Without anchor stripping, "^FROM scratch" would be treated as containing metacharacters // and fall back to literal-substring: "^FROM scratch" not in B's prose → no warn. // With anchor stripping, "FROM scratch" is the effective literal → found in B's prose → WARN. test('case 28 — anchored pattern warns: ! grep -Eq \'^FROM scratch\' Dockerfile + sibling → WARN (proves ^ strip)', () => { const content = makeTwoTaskPlan({ taskAFile: 'Dockerfile', taskAGate: "! grep -Eq '^FROM scratch' Dockerfile", taskBFile: 'Dockerfile', taskBAction: 'Update the image base: FROM scratch ensures minimal surface area.', }); const result = scan(content); assert.ok( result.warnings.filter(w => w.includes('#968')).length >= 1, `anchored pattern "^FROM scratch" must warn after ^ is stripped — "FROM scratch" is in sibling action, got: ${JSON.stringify(result.warnings)}`, ); assert.strictEqual(result.valid, true, '#968 is warn-only: valid must be true'); }); // Case 29: alternation falls back conservatively — documents the known limitation. // Gate: ! grep -Eq 'debug|trace' src/logger.ts // Sibling B lists src/logger.ts, action says "remove debug calls" (contains "debug" but NOT "debug|trace"). // patternRequiredIn sees unhandled `|` in joined frags → literal-substring fallback on raw pattern. // "debug|trace" is NOT literally in B's prose → conservative NO warn. // This is intentional: false-negative is the safe direction for a warn-only advisory. test('case 29 — alternation conservative fallback: "debug|trace" → NO warn (documents alternation limitation)', () => { // NOTE: This is intended conservative behavior, not a bug. // patternRequiredIn falls back to literal-substring for patterns containing `|` (alternation), // because safely expanding alternation without new RegExp would require a mini-parser. // The literal "debug|trace" is not present verbatim in the action, so no warn fires. // A planner who writes `debug|trace` gets no advisory — acceptable, since a false-negative // is always safer than a false-positive for a warn-only gate. const content = makeTwoTaskPlan({ taskAFile: 'src/logger.ts', taskAGate: "! grep -Eq 'debug|trace' src/logger.ts", taskBFile: 'src/logger.ts', taskBAction: 'Remove debug calls from the logger module to reduce noise.', }); const result = scan(content); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, `alternation pattern "debug|trace" must conservatively NOT warn — literal "debug|trace" not in action, got: ${JSON.stringify(result.warnings)}`, ); }); // Case 18: empty content → no crash, no #968 warn test('case 18 — empty content → no crash', () => { let result; assert.doesNotThrow(() => { result = scan(''); }); assert.ok(Array.isArray(result.warnings), 'must return { warnings: [] }'); assert.strictEqual( result.warnings.filter(w => w.includes('#968')).length, 0, 'empty content must produce no #968 warn', ); }); // Case 18b: no-task plan → no crash test('case 18b — no-task plan → no crash', () => { const content = [ '---', 'phase: 01-test', 'plan: 01', 'type: execute', 'wave: 1', 'depends_on: []', 'files_modified: []', 'autonomous: true', 'must_haves:', ' - AC1', '---', '', '# No tasks here', ].join('\n'); let result; assert.doesNotThrow(() => { result = scan(content); }); assert.strictEqual(result.warnings.filter(w => w.includes('#968')).length, 0); }); }); // ─── Group 2: end-to-end via runGsdTools ────────────────────────────────────── describe('scanFileWideNegativeGateConflict — end-to-end via verify plan-structure', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); // Case 19: integration — valid stays true despite warning (warn-only) test('case 19 — integration: valid===true despite #968 warning', () => { const planContent = makeTwoTaskPlan({ taskAGate: "! grep -Eq 'await .*refresh' app/page.py", taskBAction: 'Add a post-reindex handler that calls await bridge.refresh() to repopulate state.', }); 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 parsed = JSON.parse(result.output); assert.strictEqual( parsed.valid, true, `#968 is warn-only: valid must be true, got: ${JSON.stringify(parsed)}`, ); assert.ok( parsed.warnings.some(w => w.includes('#968')), `must have a #968 warning, got: ${JSON.stringify(parsed.warnings)}`, ); }); }); // ─── Group 3: doc-contract ──────────────────────────────────────────────────── describe('doc-contract: guidance prose is in place', () => { // Case 20: gsd-planner.md has the new guidance test('case 20 — gsd-planner.md contains Region-scoped negative gates + reference', () => { const content = fs.readFileSync(PLANNER_MD, 'utf8'); assert.ok( content.includes('Region-scoped negative gates'), 'gsd-planner.md must include "Region-scoped negative gates"', ); assert.ok( content.includes('planner-antipatterns.md'), 'gsd-planner.md must reference planner-antipatterns.md', ); }); // Case 21: planner-antipatterns.md has the new section test('case 21 — planner-antipatterns.md has ## Region-Scoped Negative Gates + examples', () => { const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8'); assert.ok( content.includes('## Region-Scoped Negative Gates'), 'planner-antipatterns.md must include "## Region-Scoped Negative Gates"', ); assert.ok( content.includes('await .*refresh'), 'planner-antipatterns.md must include the worked example pattern "await .*refresh"', ); // Verify sed or awk region example is present const hasSedOrAwk = content.includes('sed -n') || content.includes('awk '); assert.ok( hasSedOrAwk, 'planner-antipatterns.md must include sed-n or awk region example', ); }); }); // ─── Group 4: AC3 executable proof ─────────────────────────────────────────── describe('AC3: executable proof — file-wide ban vs region-scoped simultaneously satisfiable', () => { test('case 22 — grep/sed proof: both gates simultaneously satisfiable', () => { // Check if grep and sed are available const grepAvail = runHook('--version', [], { interpreter: 'grep', timeoutMs: TEXT_TOOL_TIMEOUT_MS }).exitCode === 0; const sedAvail = runHook('--version', [], { interpreter: 'sed', timeoutMs: TEXT_TOOL_TIMEOUT_MS }).exitCode === 0 || runHook('-n', ['1p', '/dev/null'], { interpreter: 'sed', timeoutMs: TEXT_TOOL_TIMEOUT_MS }).exitCode === 0; if (!grepAvail || !sedAvail) { // Skip gracefully if tools are unavailable return; } // Write a temp Python file with: // def make_page(): — no await refresh // async def reindex_handler(): — awaits bridge.refresh() const tmpFile = path.join(os.tmpdir(), `gsd-968-proof-${process.pid}.py`); const pyContent = [ 'def make_page():', ' """Synchronous factory — must not block on a refresh."""', ' return {"title": "My Page"}', '', '', 'async def reindex_handler():', ' """Post-reindex callback — must await bridge.refresh() to repopulate state."""', ' await bridge.refresh()', ' return True', ].join('\n'); fs.writeFileSync(tmpFile, pyContent); try { // (a) File-wide: grep -Eq 'await .*refresh' — should EXIT 0 (pattern found) // This means a file-wide ban (! grep -Eq ...) WOULD FAIL const fileWide = runHook('-Eq', ['await .*refresh', tmpFile], { interpreter: 'grep', timeoutMs: TEXT_TOOL_TIMEOUT_MS }); assert.strictEqual( fileWide.exitCode, 0, 'grep file-wide should find the pattern (exits 0) — proving the file-wide ban would fail', ); // (b) Region-scoped (make_page only): sed extracts lines 1-3, piped to grep → pattern NOT found // The factory region is clean: ban PASSES const makePageLines = runHook('-n', ['1,3p', tmpFile], { interpreter: 'sed', timeoutMs: TEXT_TOOL_TIMEOUT_MS }); assert.strictEqual(makePageLines.exitCode, 0, 'sed should succeed'); const makePageRegion = makePageLines.stdout.toString(); // Write to a temp file and grep it const regionFile = path.join(os.tmpdir(), `gsd-968-region-${process.pid}.py`); fs.writeFileSync(regionFile, makePageRegion); try { const regionBan = runHook('-Eq', ['await .*refresh', regionFile], { interpreter: 'grep', timeoutMs: TEXT_TOOL_TIMEOUT_MS }); assert.strictEqual( regionBan.exitCode, 1, 'grep in make_page region should NOT find pattern (exits 1) — ban PASSES in factory region', ); // (c) Region-scoped (reindex_handler): grep should FIND the pattern → requirement met const reindexLines = runHook('-n', ['6,9p', tmpFile], { interpreter: 'sed', timeoutMs: TEXT_TOOL_TIMEOUT_MS }); const reindexRegion = reindexLines.stdout.toString(); const reindexFile = path.join(os.tmpdir(), `gsd-968-reindex-${process.pid}.py`); fs.writeFileSync(reindexFile, reindexRegion); try { const reindexCheck = runHook('-Eq', ['await .*refresh', reindexFile], { interpreter: 'grep', timeoutMs: TEXT_TOOL_TIMEOUT_MS }); assert.strictEqual( reindexCheck.exitCode, 0, 'grep in reindex_handler region MUST find pattern (exits 0) — requirement met', ); } finally { try { fs.unlinkSync(reindexFile); } catch { /* ignore */ } } } finally { try { fs.unlinkSync(regionFile); } catch { /* ignore */ } } } finally { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } } }); }); }); } // ─── #2572: verify-summary pure core, callable without the CLI wrapper ─────── describe('verifySummaryCore — reusable structured contract (#2572)', () => { const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { after } = require('node:test'); const { cleanup } = require('./helpers.cjs'); const { verifySummaryCore } = require('../gsd-core/bin/lib/verify.cjs'); const dirs = []; after(() => { while (dirs.length) cleanup(dirs.pop()); }); function repo(summaryBody, extraFiles = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2572-')); dirs.push(dir); gitOrThrow(['init', '-q'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }); gitOrThrow(['config', 'user.email', 't@t.com'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }); gitOrThrow(['config', 'user.name', 'T'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }); gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }); for (const [rel, body] of Object.entries(extraFiles)) { fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); fs.writeFileSync(path.join(dir, rel), body); } fs.writeFileSync(path.join(dir, 'SUMMARY.md'), summaryBody); gitOrThrow(['add', '-A'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }); gitOrThrow(['commit', '-q', '-m', 'seed'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }); return dir; } test('returns the structured contract without writing to stdout', () => { const dir = repo('# Summary\n\nCreated: `src/a.ts`\n', { 'src/a.ts': 'x\n' }); const r = verifySummaryCore(dir, 'SUMMARY.md', 2); assert.strictEqual(typeof r, 'object'); assert.deepStrictEqual(Object.keys(r).sort(), ['checks', 'errors', 'passed']); assert.strictEqual(r.checks.summary_exists, true); assert.strictEqual(r.passed, true); }); test('reports a missing referenced file as a structured check, not a throw', () => { const dir = repo('# Summary\n\nCreated: `src/gone.ts`\n'); const r = verifySummaryCore(dir, 'SUMMARY.md', 2); assert.strictEqual(r.passed, false); assert.ok(r.checks.files_created.missing.includes('src/gone.ts'), `expected src/gone.ts in missing, got ${JSON.stringify(r.checks.files_created)}`); }); test('absent SUMMARY yields summary_exists false — never throws', () => { const dir = repo('# Summary\n'); let r; assert.doesNotThrow(() => { r = verifySummaryCore(dir, 'nope/SUMMARY.md', 2); }); assert.strictEqual(r.checks.summary_exists, false); assert.strictEqual(r.passed, false); }); test('unresolvable commit hash is reported via commits_exist', () => { const dir = repo('# Summary\n\nCommit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n'); const r = verifySummaryCore(dir, 'SUMMARY.md', 2); assert.strictEqual(r.checks.commits_exist, false); assert.ok(r.errors.some((e) => /commit/i.test(e)), `expected a commit error, got ${JSON.stringify(r.errors)}`); }); // ── Blocker 1 (#2685 review): frontmatter must be stripped before extraction ── // // All three SUMMARY templates prescribe a YAML flow sequence for key-files: // key-files: // created: [src/auth/login.ts, src/auth/session.ts] // The prose pattern would otherwise capture the literal '[' as part of the // first path, producing a candidate that can never exist on disk — firing on // a healthy project built from GSD's own shipped template. test('#2685 B1: a template-shaped frontmatter flow sequence yields no phantom "[path" candidate', () => { const body = [ '---', 'phase: 04-auth', 'key-files:', ' created: [src/auth/login.ts, src/auth/session.ts]', ' modified: [src/auth/session.ts]', 'status: complete', '---', '', '# Phase 4 Summary', '', 'Built the login flow in `src/auth/login.ts`.', '', ].join('\n'); const dir = repo(body, { 'src/auth/login.ts': 'x\n', 'src/auth/session.ts': 'x\n' }); const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false }); assert.deepStrictEqual( r.checks.files_created.missing, [], `#2685 B1 FAILED: every named file exists on disk, so nothing may be reported missing. ` + `Got: ${JSON.stringify(r.checks.files_created)}`, ); assert.ok( !r.checks.files_created.missing.some((f) => f.includes('[')), 'a bracket-prefixed candidate must never reach the missing list', ); }); test('#2685 B1: the check is still ABSENT for a clean phase and PRESENT for a dirty one', () => { const clean = repo('# Summary\n\nBuilt `src/kept.ts` here.\n', { 'src/kept.ts': 'x\n' }); const rc = verifySummaryCore(clean, 'SUMMARY.md', Infinity, { checkCommits: false }); assert.strictEqual(rc.checks.files_created.checked, 1, 'the clean fixture must actually extract a candidate (not vacuously pass)'); assert.deepStrictEqual(rc.checks.files_created.missing, [], 'clean phase must not warn'); const dirty = repo('# Summary\n\nBuilt `src/kept.ts` and `src/never-landed.ts`.\n', { 'src/kept.ts': 'x\n' }); const rd = verifySummaryCore(dirty, 'SUMMARY.md', Infinity, { checkCommits: false }); assert.deepStrictEqual( rd.checks.files_created.missing, ['src/never-landed.ts'], `dirty phase must name exactly the file that never landed, got ${JSON.stringify(rd.checks.files_created.missing)}`, ); }); // ── Major 1: checkCount is the single most behavior-defining constant here ── test('#2685 M1: checkCount boundary — 1, 2, 3 extractable paths against the default cap', () => { const mk = (n) => repo('# Summary\n\n' + Array.from({ length: n }, (_, i) => `- \`src/m${i}.ts\``).join('\n') + '\n'); assert.strictEqual(verifySummaryCore(mk(1), 'SUMMARY.md', undefined, { checkCommits: false }).checks.files_created.checked, 1); assert.strictEqual(verifySummaryCore(mk(2), 'SUMMARY.md', undefined, { checkCommits: false }).checks.files_created.checked, 2); assert.strictEqual( verifySummaryCore(mk(3), 'SUMMARY.md', undefined, { checkCommits: false }).checks.files_created.checked, 2, 'the CLI default must remain capped at 2 — unchanged from before #2572', ); assert.strictEqual( verifySummaryCore(mk(3), 'SUMMARY.md', Infinity, { checkCommits: false }).checks.files_created.checked, 3, 'Infinity must lift the cap so an interrupted phase reports every missing file', ); }); test('#2685 M1: an interrupted phase listing 12 files of which 9 are missing reports all 9', () => { const refs = Array.from({ length: 12 }, (_, i) => `src/f${i}.ts`); const present = Object.fromEntries(refs.slice(0, 3).map((f) => [f, 'x\n'])); const dir = repo('# Summary\n\n' + refs.map((f) => `- built \`${f}\``).join('\n') + '\n', present); const capped = verifySummaryCore(dir, 'SUMMARY.md', undefined, { checkCommits: false }); assert.strictEqual( capped.checks.files_created.missing.length, 0, 'precondition: at the 2-file default this real defect is invisible — that is exactly Major 1', ); const all = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false }); assert.strictEqual(all.checks.files_created.missing.length, 9, `expected all 9 missing, got ${JSON.stringify(all.checks.files_created.missing)}`); }); // ── Major 3: the advisory path must spawn no git subprocesses ── test('#2685 M3: checkCommits:false skips hash resolution entirely', () => { const body = '# Summary\n\n## Task Commits\n- deadbeefdeadbeefdeadbeefdeadbeefdeadbeef initial\n'; const dir = repo(body); const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false }); assert.strictEqual(r.checks.commits_exist, false, 'commits_exist is false meaning NOT CHECKED'); assert.deepStrictEqual( r.errors.filter((e) => /commit/i.test(e)), [], 'with commit checking off, an unresolvable hash must not manufacture an error', ); }); // ── Major 4 residue: confirmed false-positive classes stay filtered ── test('#2685 M4: globs, bare hostnames, and traversal references are never probed', () => { const body = [ '# Summary', '', // Each noise class is BACKTICKED on purpose: pattern 1 extracts any // backticked `.`, so these genuinely reach the candidate // filter. An un-backticked fixture would pass vacuously. 'Touched `src/**/*.ts` across the tree.', 'See `docs.example.com/guide.html` for background.', 'Also `../../../../etc/passwd` and `https://example.com/x.html`.', 'Real file: `src/real.ts`.', '', ].join('\n'); const dir = repo(body, { 'src/real.ts': 'x\n' }); const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false }); assert.strictEqual( r.checks.files_created.checked, 1, `only src/real.ts is a probeable candidate, got checked=${r.checks.files_created.checked}`, ); assert.deepStrictEqual(r.checks.files_created.missing, [], 'no noise class may be reported missing'); }); test('#2685 minor: a dotfile directory is still a valid first segment', () => { const dir = repo('# Summary\n\nAdded `.github/workflows/ci.yml`.\n', { '.github/workflows/ci.yml': 'x\n' }); const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false }); assert.strictEqual(r.checks.files_created.checked, 1, '.github/... must not be mistaken for a hostname'); assert.deepStrictEqual(r.checks.files_created.missing, []); }); // ── Minor: property coverage over the extractor (it is a parser) ── test('#2685 property: no synthesized SUMMARY body yields a malformed candidate', () => { const fc = require('./helpers/fast-check-setup.cjs'); const dir = repo('# seed\n'); const summaryPath = path.join(dir, 'SUMMARY.md'); fc.assert( fc.property(fc.string(), fc.array(fc.string(), { maxLength: 8 }), (prose, paths) => { const body = [ '---', 'key-files:', ` created: [${paths.join(', ')}]`, '---', '', prose, ...paths.map((p) => `- built \`${p}\``), ].join('\n'); fs.writeFileSync(summaryPath, body); const r = verifySummaryCore(dir, 'SUMMARY.md', Infinity, { checkCommits: false }); for (const c of r.checks.files_created.missing) { assert.ok(!c.includes('['), `bracket artifact leaked: ${JSON.stringify(c)}`); assert.ok(!c.includes('*') && !c.includes('?'), `glob leaked: ${JSON.stringify(c)}`); assert.ok( path.resolve(dir, c).startsWith(path.resolve(dir) + path.sep), `candidate escaped the project root: ${JSON.stringify(c)}`, ); } return true; }), { numRuns: 200 }, ); }); }); // ─── bug #1883: listMilestoneArchiveDirs must not swallow permission/I-O errors ── // The private helper catch-alled every readdirSync error into [], so an unreadable // milestones/ dir was silently reported as "no archives" (active-milestone // resolution / archived-phase filtering misbehaved). The narrowed catch re-throws // every non-ENOENT error and keeps [] only for genuine absence. Tested in-process // via the _listMilestoneArchiveDirs test seam (the validate command runs in a // subprocess, so an fs monkeypatch in the test process cannot reach it). describe('bug #1883 — listMilestoneArchiveDirs distinguishes a permission error from emptiness', () => { const verifyLib = require('../gsd-core/bin/lib/verify.cjs'); const listMilestoneArchiveDirs = verifyLib._listMilestoneArchiveDirs; const os = require('os'); function fsError(code, targetPath) { const err = new Error(`${code}: operation failed, scandir '${targetPath}'`); err.code = code; err.syscall = 'scandir'; err.path = targetPath; return err; } // Inject a readdirSync fault scoped to the milestones/ path under test. // t.mock auto-restores after each test — no chmod 0o000 (root bypasses mode bits). function injectMilestonesFault(t, code, targetPath) { const originalReaddirSync = fs.readdirSync; t.mock.method(fs, 'readdirSync', function (p, ...rest) { if (typeof p === 'string' && p.endsWith(path.join('milestones'))) { throw fsError(code, targetPath); } return originalReaddirSync.call(this, p, ...rest); }); } test('listMilestoneArchiveDirs re-throws a permission (EACCES) error instead of returning []', (t) => { const planBase = path.join(os.tmpdir(), 'gsd-1883-eacces-' + process.pid); injectMilestonesFault(t, 'EACCES', path.join(planBase, 'milestones')); assert.throws( () => listMilestoneArchiveDirs(planBase), (err) => err.code === 'EACCES', 'an unreadable milestones/ dir must propagate EACCES, not return [] as if empty', ); }); test('listMilestoneArchiveDirs re-throws any non-ENOENT error (EIO)', (t) => { const planBase = path.join(os.tmpdir(), 'gsd-1883-eio-' + process.pid); injectMilestonesFault(t, 'EIO', path.join(planBase, 'milestones')); assert.throws( () => listMilestoneArchiveDirs(planBase), (err) => err.code === 'EIO', 'every non-ENOENT error must propagate', ); }); test('listMilestoneArchiveDirs returns [] for an absent milestones/ dir (ENOENT) — empty path unchanged', () => { const planBase = path.join(os.tmpdir(), 'gsd-1883-absent-' + process.pid); // No milestones/ dir created → real OS readdirSync throws ENOENT. assert.deepStrictEqual(listMilestoneArchiveDirs(planBase), [], 'an absent milestones/ dir (ENOENT) must still return [] — Hyrum: empty path unchanged'); }); });