fix(#3511): scope every phase-directory scan to the phase it belongs to (#3535)

This commit is contained in:
Tom Boucher
2026-08-15 07:02:33 -04:00
committed by GitHub
parent 3d17569d5b
commit 59e7a677fe
30 changed files with 1903 additions and 146 deletions

View File

@@ -4135,6 +4135,122 @@ describe('#3310 state validate — S0NN coded diagnostics', () => {
assertNoDriftKey(output);
});
test('#3511: S006 does not fire from a cross-phase stray VERIFICATION file; this phase\'s own file still fires it', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
// No summary — isolates this test from the S007 "all plans summarized" path.
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
// Cross-phase stray sitting in phase 01's directory — token "02", not "01".
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
assert.strictEqual(output.valid, false);
const s006Warnings = (output.warnings || []).filter((w) => w.code === 'S006');
assert.strictEqual(s006Warnings.length, 1,
`exactly one S006 must fire (this phase's own file only); got: ${JSON.stringify(s006Warnings)}`);
assert.ok(s006Warnings[0].message.includes('01-VERIFICATION.md'),
`S006 must name this phase's own file; got: ${s006Warnings[0].message}`);
assert.ok(!s006Warnings[0].message.includes('02-VERIFICATION.md'),
`S006 must not name the cross-phase stray; got: ${s006Warnings[0].message}`);
assertNoDriftKey(output);
});
test('#3511: S007 fires when the only VERIFICATION.md present is a cross-phase stray (unscoped counting would have wrongly suppressed it)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n`,
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
// No own VERIFICATION.md — only a cross-phase stray (token "02"). Scoped
// counting must treat this phase as having NO verification file, so S007
// ("all plans summarized but still executing") must still fire.
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
assert.strictEqual(output.valid, false);
const s007 = findWarning(output, 'S007');
assert.ok(s007,
'S007 must fire: the only VERIFICATION.md present belongs to a different phase, so this phase has none of its own');
assert.ok(!findWarning(output, 'S006'), 'the cross-phase stray must not fire S006 either');
assertNoDriftKey(output);
});
test('#3511 follow-up: S006 still fires from a NON-canonical dir shape "1-unpadded" (over-exclusion check)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
);
// "1-unpadded" tokenizes to literal "1"; scaffold writes the PADDED
// "01-VERIFICATION.md" form. A literal token compare excluded it.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '1-unpadded');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
assert.strictEqual(output.valid, false);
const s006 = findWarning(output, 'S006');
assert.ok(s006, `S006 must still fire for the phase's own file in a non-canonical dir; got: ${JSON.stringify(output.warnings)}`);
assert.ok(s006.message.includes('01-VERIFICATION.md'));
assertNoDriftKey(output);
});
test('#3511 Fix 2: S006 fires from a bare "VERIFICATION.md" (no dash, no token of its own)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
// Bare form (no dash) — core-utils.cts, init.cts and verification.cts all
// treat this as a valid report; a literal `<token>-` prefix check can
// never match it, silently losing S006 drift detection.
fs.writeFileSync(path.join(phaseDir, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
assert.strictEqual(output.valid, false);
const s006 = findWarning(output, 'S006');
assert.ok(s006, `S006 must fire for a bare VERIFICATION.md; got: ${JSON.stringify(output.warnings)}`);
assert.ok(s006.message.includes('VERIFICATION.md'));
assertNoDriftKey(output);
});
test('#3511 WARNING-4: an underscore-separated "03_VERIFICATION.md" (broader .includes(\'VERIFICATION\') grammar) still fires S006 in its own phase dir', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Status:** Executing Phase 3\n**Current Phase:** 3\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
);
// state.cts's own pre-filter (`.includes('VERIFICATION')`, no dash) is
// deliberately broader than the `-VERIFICATION.md` grammar every other
// #3511 site uses, so this underscore-separated name passes it.
// `scopeToPhase`/`isPhaseArtifact` (`phase-id.cts`) accept `_` alongside
// `-` and `.` as a candidate-boundary separator specifically so this
// broader pre-filter's own-phase matches aren't dropped after the fact —
// "03_VERIFICATION.md" in phase 03's directory IS this phase's own file,
// and the aggregate scan must not report it as absent. S006 fires here.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-foo');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '03_VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
const s006 = findWarning(output, 'S006');
assert.ok(s006, `S006 must fire for "03_VERIFICATION.md" in its own phase dir; got: ${JSON.stringify(output.warnings)}`);
const s007 = findWarning(output, 'S007');
assert.ok(!s007, `S007 must not fire once S006 covers the passed own-phase verification; got: ${JSON.stringify(output.warnings)}`);
});
test('output never carries a drift key, across clean/warning/error shapes (breaking-change proof, test matrix row 18)', () => {
// Clean shape.
fs.writeFileSync(