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

@@ -738,6 +738,103 @@ describe('bug #2836: audit-open quick-task summary filename + UAT terminal statu
}
});
test('#3511: scanUatGaps excludes a cross-phase stray UAT file sitting in the same phase dir; this phase\'s own open gap still reports', () => {
const cwd = mkTmp();
try {
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
fs.mkdirSync(phaseDir, { recursive: true });
// This phase's own open UAT gap.
fs.writeFileSync(
path.join(phaseDir, '03-UAT.md'),
'---\nstatus: pending\n---\nresult: pending\n',
'utf-8',
);
// Cross-phase stray in the SAME directory — token "04", not "03".
fs.writeFileSync(
path.join(phaseDir, '04-UAT.md'),
'---\nstatus: pending\n---\nresult: pending\n',
'utf-8',
);
const result = auditOpenArtifacts(cwd);
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
assert.equal(realUatGaps.length, 1,
`only this phase's own gap must report; got: ${JSON.stringify(realUatGaps)}`);
assert.equal(realUatGaps[0].file, '03-UAT.md');
assert.equal(realUatGaps[0].phase, '03');
assert.ok(!realUatGaps.some(i => i.file === '04-UAT.md'),
'the cross-phase stray must not appear in uat_gaps');
assert.equal(result.counts.uat_gaps, 1);
} finally {
cleanup(cwd);
}
});
test('#3511: scanVerificationGaps excludes a cross-phase stray VERIFICATION file sitting in the same phase dir; this phase\'s own open gap still reports', () => {
const cwd = mkTmp();
try {
const phaseDir = path.join(cwd, '.planning', 'phases', '03-test');
fs.mkdirSync(phaseDir, { recursive: true });
// This phase's own open VERIFICATION gap.
fs.writeFileSync(
path.join(phaseDir, '03-VERIFICATION.md'),
'---\nstatus: gaps_found\n---\n# Verification\n',
'utf-8',
);
// Cross-phase stray in the SAME directory — token "04", not "03".
fs.writeFileSync(
path.join(phaseDir, '04-VERIFICATION.md'),
'---\nstatus: human_needed\n---\n# Verification\n',
'utf-8',
);
const result = auditOpenArtifacts(cwd);
const realVerificationGaps = result.items.verification_gaps.filter(i => !i.scan_error);
assert.equal(realVerificationGaps.length, 1,
`only this phase's own gap must report; got: ${JSON.stringify(realVerificationGaps)}`);
assert.equal(realVerificationGaps[0].file, '03-VERIFICATION.md');
assert.equal(realVerificationGaps[0].phase, '03');
assert.ok(!realVerificationGaps.some(i => i.file === '04-VERIFICATION.md'),
'the cross-phase stray must not appear in verification_gaps');
assert.equal(result.counts.verification_gaps, 1);
} finally {
cleanup(cwd);
}
});
test('#3511 follow-up: own gap still reports from a NON-canonical dir shape (over-exclusion check)', () => {
const cwd = mkTmp();
try {
// "1-unpadded" tokenizes to literal "1", but scaffold writes the PADDED
// "01-…" form — a literal token compare excluded the phase's own file.
const phaseDir = path.join(cwd, '.planning', 'phases', '1-unpadded');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-UAT.md'),
'---\nstatus: partial\n---\n\n## Tests\n\n### 1. Test\nexpected: works\nresult: pending\n',
'utf-8',
);
fs.writeFileSync(
path.join(phaseDir, '01-VERIFICATION.md'),
'---\nstatus: gaps_found\n---\n# Verification\n',
'utf-8',
);
const result = auditOpenArtifacts(cwd);
const realUatGaps = result.items.uat_gaps.filter(i => !i.scan_error);
const realVerificationGaps = result.items.verification_gaps.filter(i => !i.scan_error);
assert.equal(realUatGaps.length, 1,
`own UAT gap in an unpadded-dir phase must still report; got: ${JSON.stringify(realUatGaps)}`);
assert.equal(realVerificationGaps.length, 1,
`own VERIFICATION gap in an unpadded-dir phase must still report; got: ${JSON.stringify(realVerificationGaps)}`);
} finally {
cleanup(cwd);
}
});
test('quick task without any SUMMARY file is still flagged as missing', () => {
const cwd = mkTmp();
try {

View File

@@ -2240,6 +2240,27 @@ describe('stats command', () => {
assert.strictEqual(phase.status, 'Complete', 'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet');
});
// #3511 BLOCKER-2 regression: a cross-phase stray VERIFICATION.md must not
// resolve as THIS phase's report. Phase 03's directory holds only a
// '04-VERIFICATION.md' (belongs to phase 04); an unscoped resolver would
// pick it up as phase 03's own report and read 'Complete'.
test('#3511: phase status is not Complete off a cross-phase stray VERIFICATION.md', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '03-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '03-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '03-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, '04-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
const phase = stats.phases.find(p => p.number === '03');
assert.ok(phase, 'phase 03 must be present in stats output');
assert.notStrictEqual(phase.status, 'Complete',
`phase 03 must not report Complete off phase 04's report; got: ${phase.status}`);
});
test('counts requirements from REQUIREMENTS.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),

View File

@@ -485,6 +485,22 @@ describe('getPhaseFileStats', () => {
const stats = coreUtils.getPhaseFileStats(tmpDir);
assert.deepEqual(stats.plans, ['plans/PLAN-01.md']);
});
test('#3511 BLOCKER-2 regression: a cross-phase stray VERIFICATION.md is scoped out of hasVerification', () => {
// Dir is phase 03's own directory ("03-test" → token "03"); the only
// VERIFICATION.md present belongs to phase 04. scopeToPhase must exclude
// it, so hasVerification reads false — an unscoped implementation would
// wrongly report phase 03 as verified off phase 04's report.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-'));
const phaseDir = path.join(tmpDir, '03-test');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), '---\nstatus: passed\n---\n');
const stats = coreUtils.getPhaseFileStats(phaseDir);
assert.strictEqual(stats.hasVerification, false,
`hasVerification must be false — the only VERIFICATION.md belongs to phase 04, not 03; got scopedFiles-derived: ${stats.hasVerification}`);
});
});
// ─── extractOneLinerFromBody ──────────────────────────────────────────────────

View File

@@ -19,6 +19,8 @@ const {
parseMustHavesBlock,
} = require('../gsd-core/bin/lib/frontmatter.cjs');
const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
// ─── extractFrontmatter ─────────────────────────────────────────────────────
describe('extractFrontmatter', () => {
@@ -1236,11 +1238,15 @@ function buildRoadmap(numPhases) {
*/
function createPhaseDirs(phasesDir, count) {
for (let i = 1; i <= count; i++) {
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
const dirName = String(i).padStart(2, '0');
const dir = path.join(phasesDir, dirName);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`);
fs.writeFileSync(path.join(dir, `01-SUMMARY.md`), `# Summary\n`);
fs.writeFileSync(path.join(dir, `01-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n');
fs.writeFileSync(
path.join(dir, `${normalizePhaseName(dirName)}-VERIFICATION.md`),
'---\nstatus: passed\n---\n# Verification\n',
);
}
}

View File

@@ -120,6 +120,91 @@ describe('init commands', () => {
assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'));
});
test('#3511-class: init manager has_context/has_research ignore another phase\'s misplaced artifact', () => {
writePlanningDocs(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Progress',
'',
'- [ ] **Phase 1: Setup**',
'- [ ] **Phase 2: API**',
'',
'### Phase 1: Setup',
'',
'**Goal:** Build the foundation.',
'',
'### Phase 2: API',
'',
'**Goal:** Build the API.',
'',
].join('\n'),
);
seedPhase(tmpDir, '01-setup', {});
// Phase 02's directory holds ONLY a stray artifact whose filename token
// ("01-") belongs to phase 01, not to this directory's own phase (02).
seedPhase(tmpDir, '02-api', {
'01-RESEARCH.md': '# Research for phase 01',
'01-CONTEXT.md': '# Context for phase 01',
});
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const p2 = output.phases.find((p) => p.number === '2');
assert.strictEqual(p2.has_research, false,
'phase 2 must not report has_research from a file that belongs to phase 1');
assert.strictEqual(p2.has_context, false,
'phase 2 must not report has_context from a file that belongs to phase 1');
});
test('#3511-class: init verify-work ui_phase_active ignores another phase\'s misplaced UI-SPEC file', () => {
writePlanningDocs(tmpDir);
// ui_phase_active is `hasActiveUiStep || hasUiSpecFile` (detectUiPhaseActive,
// src/init.cts) — the `ui` capability's `workflow.ui_phase` config key
// defaults to `true` (capabilities/ui/capability.json), which alone would
// make `hasActiveUiStep` (and therefore the whole OR) true regardless of
// which file the phase directory holds. Disabling it here isolates the
// signal this test actually exercises: the misplaced-file half of the OR.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { ui_phase: false } }),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'### Phase 1: Setup',
'',
'**Goal:** Build the foundation.',
'',
'### Phase 2: API',
'',
'**Goal:** Build the API.',
'',
].join('\n'),
);
seedPhase(tmpDir, '01-setup', {});
// Phase 02's directory holds ONLY a stray artifact whose filename token
// ("01-") belongs to phase 01, not to this directory's own phase (02).
seedPhase(tmpDir, '02-api', {
'01-UI-SPEC.md': '# UI Spec for phase 01',
});
const result = runGsdTools('init verify-work 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.ui_phase_active, false,
'phase 2 must not report ui_phase_active from a UI-SPEC file that belongs to phase 1');
});
// #3473 F2 (companion to #3357): init plan-phase's verification_path
// projector now resolves via the shared resolveVerificationFile resolver
// instead of a hand-rolled `.find()` over unsorted readdir() order. The
@@ -1717,6 +1802,27 @@ describe('cmdInitProgress', () => {
assert.strictEqual(output.next_phase, null);
});
test('#3511-class: has_research ignores a misplaced RESEARCH.md that belongs to another phase', () => {
// Phase 01 has no artifacts of its own.
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
// Phase 02's directory holds ONLY a stray artifact whose filename token
// ("01-") belongs to phase 01, not to this directory's own phase (02).
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase2, { recursive: true });
fs.writeFileSync(path.join(phase2, '01-RESEARCH.md'), '# Research for phase 01');
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const p2 = output.phases.find(p => p.number === '02');
assert.strictEqual(p2.has_research, false,
'phase 02 must not report has_research from a file that belongs to phase 01');
assert.strictEqual(p2.status, 'pending');
});
test('implementation-complete phase without passed verification remains current work', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
@@ -3465,6 +3571,25 @@ describe('init section manifest', () => {
'gsd-core/workflows/execute-phase/steps/partial-wave.md',
]);
});
test('#3511-class: regression-gate (state:has-prior-phases) ignores a misplaced VERIFICATION.md that belongs to another phase', (t) => {
const dir = createTempProject('gsd-3511-hasprior-');
t.after(() => cleanup(dir));
// Phase 01 is the one being executed.
seedPhase(dir, '01-widgets', {});
// Phase 02's directory holds ONLY a stray artifact whose filename
// token ("05-") belongs to phase 05, not to this directory's own
// phase (02) — it must not make phase 02 look like it has its own
// verification report.
seedPhase(dir, '02-other', { '05-VERIFICATION.md': '# Verification for phase 05' });
const body = parseOkJson(runExecutePhase(['1'], dir), 'stray-verification');
assert.ok(body.section_manifest, 'section_manifest must be present');
assert.ok(!body.section_manifest.included.includes('regression-gate'),
'regression-gate must not be included when no OTHER phase has its own verification');
assert.ok(body.section_manifest.excluded.includes('regression-gate'));
});
});
// ── E44-46: phase argument boundary ────────────────────────────────────

View File

@@ -263,6 +263,268 @@ describe('extractPhaseToken', () => {
});
});
// ─── isPhaseArtifact (#3511) ───────────────────────────────────────────────────
//
// Predicate for AGGREGATE phase-directory scans (uat-predicate.cts, phase.cts,
// state.cts, uat.cts, audit.cts) — answers "does fileName belong to THIS
// phase" so a stray, cross-phase, or ad-hoc file cannot contribute its status
// to a phase it does not belong to. See src/phase-id.cts's isPhaseArtifact
// docblock for the full contract and fail-safe rationale.
describe('isPhaseArtifact (#3511)', () => {
test('plain: file matches its own phase dir, not a cross-phase dir', () => {
assert.strictEqual(phaseId.isPhaseArtifact('03-VERIFICATION.md', '03-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('04-VERIFICATION.md', '03-foo'), false);
});
test('ad-hoc worksheet: 03-CORRECTION-VERIFICATION.md belongs to 03-foo (it is the phase\'s own file)', () => {
// Over-exclusion here would be a worse bug than #3511 itself — a phase's
// own ad-hoc worksheet must never be treated as a stray.
assert.strictEqual(phaseId.isPhaseArtifact('03-CORRECTION-VERIFICATION.md', '03-foo'), true);
});
test('letter suffix: token must match exactly, in both directions', () => {
assert.strictEqual(phaseId.isPhaseArtifact('03A-VERIFICATION.md', '03A-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('03-VERIFICATION.md', '03A-foo'), false,
'a bare "03-" file must not belong to letter-suffixed phase dir "03A-foo"');
assert.strictEqual(phaseId.isPhaseArtifact('03A-VERIFICATION.md', '03-foo'), false,
'a letter-suffixed "03A-" file must not belong to bare phase dir "03-foo"');
});
test('decimal sub-phase: token must match exactly, not as a numeric prefix', () => {
assert.strictEqual(phaseId.isPhaseArtifact('35.1-VERIFICATION.md', '35.1-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('35.10-VERIFICATION.md', '35.1-foo'), false,
'35.10-… must not match phase dir 35.1-foo despite the shared numeric prefix');
});
test('plan/summary shapes belong to their phase the same way VERIFICATION/UAT do', () => {
assert.strictEqual(phaseId.isPhaseArtifact('03-01-SUMMARY.md', '03-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('03-01-PLAN.md', '03-foo'), true);
});
test('project-code-prefixed dirs: both the prefixed and the project-code-STRIPPED reading belong (#3511 blocker 1)', () => {
// Files are named by normalizePhaseName, which STRIPS the project code
// (cmdScaffold, src/commands.cts) — files never carry the code, only the
// directory does. `01-VERIFICATION.md` IS the real report cmdScaffold
// writes into `CK-01-foundation`; excluding it locked in the #3511
// follow-up defect (this assertion's polarity was inverted pre-fix).
assert.strictEqual(phaseId.isPhaseArtifact('CK-01-VERIFICATION.md', 'CK-01-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('01-VERIFICATION.md', 'CK-01-foo'), true,
'the project-code-STRIPPED file is the real scaffold output and must belong to its own project-code-prefixed dir');
assert.strictEqual(phaseId.isPhaseArtifact('02-VERIFICATION.md', 'CK-01-foo'), false,
'a different phase number must still be excluded, project code aside');
});
test('#3511 blocker 2: unpadded dir "1-unpadded" — its own padded file belongs', () => {
// #3511 blocker: dir "1-unpadded" has literal token "1"; cmdScaffold
// writes the PADDED "01-VERIFICATION.md" into it via normalizePhaseName.
assert.strictEqual(phaseId.isPhaseArtifact('01-VERIFICATION.md', '1-unpadded'), true);
assert.strictEqual(phaseId.isPhaseArtifact('02-VERIFICATION.md', '1-unpadded'), false);
});
test('#3511 blocker 3: digit-leading-slug family — own file uses the LEADING digit run, not the mis-absorbed token', () => {
// "05-80-20-cleanup" tokenizes to "05-80-20" (#2528 mis-absorption), but
// cmdScaffold writes "05-UAT.md"/"05-VERIFICATION.md" (leading digit run
// only) — the same reading matchPhaseDirs' bare-integer fallback uses.
assert.strictEqual(phaseId.isPhaseArtifact('05-UAT.md', '05-80-20-cleanup'), true);
assert.strictEqual(phaseId.isPhaseArtifact('05-VERIFICATION.md', '05-80-20-cleanup'), true);
assert.strictEqual(phaseId.isPhaseArtifact('80-VERIFICATION.md', '05-80-20-cleanup'), false,
'the slug word "80" must not be mistaken for a real phase number');
assert.strictEqual(phaseId.isPhaseArtifact('10-UAT.md', '10-24-7-autonomy'), true);
assert.strictEqual(phaseId.isPhaseArtifact('24-UAT.md', '10-24-7-autonomy'), false);
});
test('case-insensitive letter suffix (review item 8): "03A-VERIFICATION.md" belongs to lowercase-suffixed "03a-foo"', () => {
assert.strictEqual(phaseId.isPhaseArtifact('03A-VERIFICATION.md', '03a-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('03a-VERIFICATION.md', '03A-foo'), true);
});
// WARNING-2 (#3511 review): the `firstLetterPrefixed` include-everything
// branch (bracket-convention ambiguity fail-safe, docblock "BRACKET
// CONVENTION" section) was untested on its own — only the ZERO-SEGMENT
// fail-safe below had direct coverage. Pinned here so a later flip to
// `false` for this family is caught rather than silently shipped.
test('#3511 WARNING-2: bracket-ambiguous letter-prefixed-decimal dirs (firstLetterPrefixed) are the deliberate include-everything fail-safe', () => {
assert.strictEqual(phaseId.isPhaseArtifact('99-VERIFICATION.md', 'P0.3-2-slug'), true);
assert.strictEqual(phaseId.isPhaseArtifact('07-UAT.md', 'v2-migration'), true);
});
// WARNING-5 (#3511 review): pin actual behavior for a decimal sub-phase
// token compared against a same-leading-number-but-different-sub-phase
// artifact — the exact-token-match rule (not a numeric-prefix match).
test('#3511 WARNING-5: "35-VERIFICATION.md" (bare, no sub-phase) does not belong to decimal sub-phase dir "35.1-slug"', () => {
assert.strictEqual(phaseId.isPhaseArtifact('35-VERIFICATION.md', '35.1-slug'), false);
});
test('fail-safe: a dir name with no derivable token includes EVERY file (never exclude)', () => {
// derivePhaseTokenSegments finds zero segments for these dir names (same
// condition extractPhaseToken treats as "return dirName unchanged" —
// see the 'returns the full dirName when no numeric token found' test
// above). Excluding on an unreliable token would make an aggregate gate
// silently permissive in the wrong direction (dropping the phase's own
// real blockers) — worse than the cross-phase-contamination bug #3511
// fixes. Every file must be treated as belonging to the phase instead.
assert.strictEqual(phaseId.isPhaseArtifact('04-VERIFICATION.md', 'no-numeric'), true);
assert.strictEqual(phaseId.isPhaseArtifact('anything-at-all.md', 'alpha'), true);
assert.strictEqual(phaseId.isPhaseArtifact('99-UAT.md', 'phase-name-01'), true);
});
test('#3511 Fix 2: a bare "VERIFICATION.md"/"UAT.md" (no dash, no token of its own) belongs by containment', () => {
// src/state.cts:3740's S006 filter matches `f.includes('VERIFICATION')`
// with no dash requirement, so a bare `VERIFICATION.md` (a form
// core-utils.cts, init.cts and verification.cts all treat as valid) was
// silently excluded pre-fix — S006 drift detection lost, S007 wrongly
// flipped on. Directory containment is the only signal for a token-less
// file, and it is sufficient.
assert.strictEqual(phaseId.isPhaseArtifact('VERIFICATION.md', '03-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('UAT.md', '03-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('VERIFICATION.md', 'CK-01-foo'), true);
assert.strictEqual(phaseId.isPhaseArtifact('VERIFICATION.md', '1-unpadded'), true);
});
// ── Property: over-exclusion (#3511 follow-up) ───────────────────────────────
//
// Every "own file still contributes" assertion above uses a HAND-PICKED
// fixture. This property generates across the real dirName shapes (padded /
// unpadded, project-code-prefixed, digit-leading slugs, decimals, letter
// suffixes) and asserts, for EACH, that the artifact name `cmdScaffold`
// (src/commands.cts, via `normalizePhaseName`) would actually write into
// that directory is a member — the exact invariant all three #3511
// follow-up blockers violated, and the one no hand-picked fixture pins on
// its own.
test('property: the file cmdScaffold would write into a phase dir is always isPhaseArtifact-true for that dir', () => {
const artifactType = fc.constantFrom('UAT', 'VERIFICATION', 'CONTEXT');
// dirName shape generators mirroring the real on-disk families this
// module's own docblocks (extractPhaseToken, matchPhaseDirs) enumerate.
const letterSuffix = fc.constantFrom('', 'A', 'B', 'C');
const projectCode = fc.constantFrom(null, 'CK', 'PROJ', 'APP1');
const slugWord = fc.constantFrom('foo', 'cleanup', 'autonomy', 'follow-up');
// Digit-leading-slug family (#2528): a second all-digit slug SEGMENT that
// is NOT a genuine sub-phase — 2-3 digit words like "80", "100".
const digitSlugSegment = fc.constantFrom(null, '80', '20', '100');
const plainDirNameGen = fc.record({
code: projectCode,
padded: fc.boolean(),
num: fc.integer({ min: 1, max: 99 }),
letter: letterSuffix,
digitSlug: digitSlugSegment,
slug: slugWord,
}).map(({ code, padded, num, letter, digitSlug, slug }) => {
const numStr = padded ? String(num).padStart(2, '0') : String(num);
const token = `${numStr}${letter}`;
const codePrefix = code ? `${code}-` : '';
const digitTail = digitSlug ? `-${digitSlug}` : '';
return { dirName: `${codePrefix}${token}${digitTail}-${slug}`, phase: token };
});
// INFO-2 (#3511 review): a genuine decimal sub-phase dir (`05.3-slug`) —
// the exact-token-match branch, not the digit-leading-slug fallback.
const decimalDirNameGen = fc.record({
code: projectCode,
padded: fc.boolean(),
major: fc.integer({ min: 1, max: 99 }),
sub: fc.integer({ min: 1, max: 99 }),
slug: slugWord,
}).map(({ code, padded, major, sub, slug }) => {
const majorStr = padded ? String(major).padStart(2, '0') : String(major);
const token = `${majorStr}.${sub}`;
const codePrefix = code ? `${code}-` : '';
return { dirName: `${codePrefix}${token}-${slug}`, phase: token };
});
// INFO-2 (#3511 review): the letter-prefixed-decimal family (`P0.3-2-slug`)
// — string-indistinguishable from a bracket-dir token without an explicit
// convention signal, so `isPhaseArtifact` treats it as the deliberate
// `firstLetterPrefixed` include-everything fail-safe (see WARNING-2 test
// above): every candidate file belongs, by construction.
const letterPrefixedDecimalDirNameGen = fc.record({
prefix: fc.constantFrom('P0', 'M1', 'A2'),
major: fc.integer({ min: 1, max: 20 }),
sub: fc.integer({ min: 1, max: 20 }),
slug: slugWord,
}).map(({ prefix, major, sub, slug }) => ({
dirName: `${prefix}.${major}-${sub}-${slug}`,
phase: `${major}-${sub}`,
}));
const dirNameGen = fc.oneof(plainDirNameGen, decimalDirNameGen, letterPrefixedDecimalDirNameGen);
fc.assert(
fc.property(dirNameGen, artifactType, ({ dirName, phase }, type) => {
const padded = phaseId.normalizePhaseName(phase);
const writtenFile = `${padded}-${type}.md`;
return phaseId.isPhaseArtifact(writtenFile, dirName);
}),
{ numRuns: 200 },
);
});
});
// ─── scopeToPhase (#3511) ───────────────────────────────────────────────────
//
// scopeToPhase is a plain filter over isPhaseArtifact: `fileNames.filter(f =>
// isPhaseArtifact(f, phaseDirName))`. An earlier follow-up ("WARNING 4") added
// a rule that fell back to the unfiltered input whenever scoping would empty
// a non-empty candidate set — but that defeated the actual #3511 fix: a phase
// dir holding only a misfiled cross-phase report would publish that other
// phase's status as its own. The fallback rule has been REMOVED; an empty
// result is now the honest answer for "this phase has none of its own".
describe('scopeToPhase (#3511)', () => {
test('mixed set: own file kept, stray dropped', () => {
assert.deepStrictEqual(
phaseId.scopeToPhase(['03-VERIFICATION.md', '04-VERIFICATION.md'], '03-foo'),
['03-VERIFICATION.md'],
);
});
test('empty input: returns empty', () => {
assert.deepStrictEqual(phaseId.scopeToPhase([], '03-foo'), []);
});
test('underivable dir token: every file passes through unchanged (isPhaseArtifact fail-safe)', () => {
assert.deepStrictEqual(
phaseId.scopeToPhase(['04-VERIFICATION.md', 'anything-at-all.md'], 'no-numeric'),
['04-VERIFICATION.md', 'anything-at-all.md'],
);
});
test('#3511: a phase dir holding ONLY another phase\'s report scopes to empty — ' +
'an empty result is the honest answer, not a reason to fall back to the stray', () => {
// Removing this behavior is the whole point of #3511: returning
// 04-VERIFICATION.md here would publish phase 04's status as phase 03's.
assert.deepStrictEqual(
phaseId.scopeToPhase(['04-VERIFICATION.md'], '03-foo'),
[],
);
});
test('property: a phase dir never scopes away its own canonically-named artifact', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 999 }),
fc.stringMatching(/^[a-z]{1,8}$/),
(num, slug) => {
const padded = String(num).padStart(2, '0');
for (const dirNum of new Set([padded, String(num)])) {
const dirName = `${dirNum}-${slug}`;
for (const fileNum of new Set([padded, String(num)])) {
const own = `${fileNum}-VERIFICATION.md`;
assert.deepStrictEqual(
phaseId.scopeToPhase([own], dirName),
[own],
`${own} is phase ${dirNum}'s own artifact in ${dirName} and must survive scoping`,
);
}
}
},
),
{ numRuns: 300 },
);
});
});
// ─── phaseTokenMatches ────────────────────────────────────────────────────────
describe('phaseTokenMatches', () => {

View File

@@ -2473,6 +2473,119 @@ describe('phase remove command', () => {
assert.ok(roadmap.includes('Phase 2: Features'), 'phase 3 should be renumbered to 2');
});
// WARNING-3 (#3511 review): renameIntegerPhases renames a phase directory
// whose leading number is UNPADDED (dir regex accepts bare `\d+`), but
// renamed its artifact files only against the 2-PADDED prefix, so an
// unpadded-numbered artifact desynced from its now-renamed directory and
// the phase read `missing` afterward.
test('#3511 WARNING-3: renames an unpadded-numbered artifact alongside its unpadded dir', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 9: B\n**Goal:** B\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const p9 = path.join(tmpDir, '.planning', 'phases', '9-slug');
fs.mkdirSync(p9, { recursive: true });
// Unpadded artifact filename, paired with the unpadded dir number.
fs.writeFileSync(path.join(p9, '9-VERIFICATION.md'), '---\nstatus: passed\n---\n\nVerified OK.');
const result = runGsdTools('phase remove 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const newDir = path.join(tmpDir, '.planning', 'phases', '08-slug');
assert.ok(fs.existsSync(newDir), 'phase 9 should be renumbered to 08-slug');
assert.ok(
fs.existsSync(path.join(newDir, '08-VERIFICATION.md')),
'the unpadded 9-VERIFICATION.md should be renamed to 08-VERIFICATION.md alongside the dir'
);
assert.ok(
!fs.existsSync(path.join(newDir, '9-VERIFICATION.md')),
'the stale unpadded-prefix file should no longer exist'
);
const findResult = runGsdTools('find-phase 8', tmpDir);
assert.ok(findResult.success, `find-phase failed: ${findResult.error}`);
const findOutput = JSON.parse(findResult.output);
assert.strictEqual(findOutput.found, true, 'renumbered phase 8 should resolve');
});
// #3511 BLOCKER-2 follow-up (security-review regression): the collision
// guard added to stop the original overwrite-and-lose-data bug (BLOCKER-1)
// introduced a NEW wrong-answer regression — skipping the rename let a
// STRAY cross-phase file outrank the phase's own report at the canonical
// name. Phase 9's directory holds its OWN `09-VERIFICATION.md` (gaps_found)
// and a stray `08-VERIFICATION.md` (passed) that actually belongs to phase
// 8. Removing phase 8 renumbers phase 9 -> phase 8 and must displace the
// stray (never overwrite it, never let it win) so the phase's own report
// lands at the canonical name.
test('#3511 BLOCKER-2 follow-up: collision displaces the occupying file instead of skip-or-overwrite', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n### Phase 8: A\n**Goal:** A\n### Phase 9: B\n**Goal:** B\n`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '08-a'), { recursive: true });
const p9 = path.join(tmpDir, '.planning', 'phases', '09-foo');
fs.mkdirSync(p9, { recursive: true });
// The phase's OWN report.
fs.writeFileSync(
path.join(p9, '09-VERIFICATION.md'),
'---\nstatus: gaps_found\n---\n\nOwn report for phase 9.'
);
// A STRAY report belonging to phase 8, sitting inside phase 9's directory.
fs.writeFileSync(
path.join(p9, '08-VERIFICATION.md'),
'---\nstatus: passed\n---\n\nStray report belonging to phase 8.'
);
const result = runGsdTools('phase remove 8', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const newDir = path.join(tmpDir, '.planning', 'phases', '08-foo');
assert.ok(fs.existsSync(newDir), 'phase 9 should be renumbered to 08-foo');
// (a) no file is lost — both original contents still exist on disk.
const canonicalPath = path.join(newDir, '08-VERIFICATION.md');
const displacedPath = path.join(newDir, '08-VERIFICATION.md.orphaned');
assert.ok(fs.existsSync(canonicalPath), 'canonical 08-VERIFICATION.md must exist');
assert.ok(fs.existsSync(displacedPath), 'the displaced stray file must still exist on disk');
const canonical = fs.readFileSync(canonicalPath, 'utf-8');
const displaced = fs.readFileSync(displacedPath, 'utf-8');
assert.ok(
displaced.includes('Stray report belonging to phase 8'),
'displaced file must retain the stray content'
);
// (b) 08-VERIFICATION.md in the renamed dir is the phase's OWN former
// 09-VERIFICATION.md — assert on its body/status, not just its name.
assert.ok(
canonical.includes('status: gaps_found'),
`canonical 08-VERIFICATION.md must be the phase's own report (gaps_found), ` +
`not the stray (passed). Got: ${canonical}`
);
assert.ok(
canonical.includes('Own report for phase 9'),
'canonical file body must be the phase\'s own former 09-VERIFICATION.md content'
);
assert.ok(
!fs.existsSync(path.join(newDir, '09-VERIFICATION.md')),
'the stale 09-VERIFICATION.md name should no longer exist'
);
// (c) the displacement is reported in the command output.
assert.ok(
Array.isArray(output.renamed_file_collisions),
'renamed_file_collisions must be present in the output'
);
const entry = output.renamed_file_collisions.find((c) => c.to === '08-VERIFICATION.md');
assert.ok(
entry,
`expected a collision entry for 08-VERIFICATION.md, got: ${JSON.stringify(output.renamed_file_collisions)}`
);
assert.strictEqual(entry.from, '09-VERIFICATION.md');
assert.strictEqual(entry.displaced_to, '08-VERIFICATION.md.orphaned');
});
test('rejects removal of phase with summaries unless --force', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
@@ -8777,7 +8890,7 @@ function writePassedVerificationFile(phaseDir, phase = '01') {
* - Phase 01 directory with one plan+summary (to satisfy phase complete guard)
* - Phase 02 directory (next phase)
*/
function createFixture(prefix = 'gsd-4-regression-') {
function createFixture(prefix = 'gsd-4-regression-', phase01DirName = '01-foundation') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
@@ -8845,7 +8958,7 @@ function createFixture(prefix = 'gsd-4-regression-') {
fs.writeFileSync(path.join(planningDir, 'STATE.md'), state);
// Phase 01 directory with a PLAN and SUMMARY so phase complete guard passes
const phase01Dir = path.join(phasesDir, '01-foundation');
const phase01Dir = path.join(phasesDir, phase01DirName);
fs.mkdirSync(phase01Dir, { recursive: true });
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
@@ -9231,6 +9344,87 @@ describe('#3057 B3: cmdPhaseComplete — verification staleness-check indetermin
);
});
// ─────────────────────────────────────────────────────────────────────────────
// #3511: cmdPhaseComplete — UAT/VERIFICATION advisory pre-scan is phase-scoped
//
// A cross-phase, stray, or ad-hoc file sitting in this phase's directory must
// not name an advisory warning against this phase — and this phase's own
// UAT/VERIFICATION files must keep warning exactly as before (non-stray case
// unchanged). Covers BOTH loops scoped by #3511 (the UAT loop and the
// VERIFICATION loop).
// ─────────────────────────────────────────────────────────────────────────────
describe('#3511: cmdPhaseComplete — advisory pre-scan warnings are phase-scoped', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
test('a cross-phase stray UAT/VERIFICATION file does not contribute a warning; this phase\'s own files still do', (t) => {
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
// This phase's own UAT file — must still produce its usual warning.
fs.writeFileSync(path.join(phase01Dir, '01-UAT.md'), [
'---', 'status: partial', '---', '',
'### 1. Test A', 'expected: A', 'result: pending', '',
].join('\n'));
// Cross-phase strays sitting in phase 01's directory — token "02", not
// "01" — must NOT contribute a warning to phase 01's completion.
fs.writeFileSync(path.join(phase01Dir, '02-UAT.md'), [
'---', 'status: partial', '---', '',
'### 1. Test B', 'expected: B', 'result: blocked', '',
].join('\n'));
fs.writeFileSync(path.join(phase01Dir, '02-VERIFICATION.md'), [
'---', 'status: human_needed', '---', '',
'# Verification', '',
].join('\n'));
const output = JSON.parse(capturePhaseComplete(t, tmpDir, '1'));
assert.strictEqual(output.completed_phase, '1');
assert.ok(
output.warnings.some((w) => w.includes('01-UAT.md') && w.includes('has pending tests')),
`own UAT file must still warn; got: ${JSON.stringify(output.warnings)}`,
);
assert.ok(
!output.warnings.some((w) => w.includes('02-UAT.md')),
`stray UAT file must not contribute a warning; got: ${JSON.stringify(output.warnings)}`,
);
assert.ok(
!output.warnings.some((w) => w.includes('02-VERIFICATION.md') || /needs human verification/.test(w)),
`stray VERIFICATION file must not contribute a warning; got: ${JSON.stringify(output.warnings)}`,
);
});
test('#3511 follow-up: own UAT file still warns from a NON-canonical dir shape "1-unpadded" (over-exclusion check)', (t) => {
const unpaddedTmpDir = createFixture('gsd-4-regression-unpadded-', '1-unpadded');
try {
const phaseDir = path.join(unpaddedTmpDir, '.planning', 'phases', '1-unpadded');
// "1-unpadded" tokenizes to literal "1"; scaffold writes the PADDED
// "01-…" form. A literal token compare excluded the phase's own file.
fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [
'---', 'status: partial', '---', '',
'### 1. Test A', 'expected: A', 'result: pending', '',
].join('\n'));
const output = JSON.parse(capturePhaseComplete(t, unpaddedTmpDir, '1'));
assert.ok(
output.warnings.some((w) => w.includes('01-UAT.md') && w.includes('has pending tests')),
`own UAT file in an unpadded-dir phase must still warn; got: ${JSON.stringify(output.warnings)}`,
);
} finally {
cleanup(unpaddedTmpDir);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Regressions: phase complete preserves completion date (#1161)
// Tests drive the REAL handler (cmdPhaseComplete) via the CLI entry point

View File

@@ -43,6 +43,7 @@ const { worstScope } = planningSnapshotLib;
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
const { _unusableInputEmissionCountForTests } = require('../gsd-core/bin/lib/unusable-input.cjs');
const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
// Phase 11 (#3309) additions — agent-install fixture helper mirrors
// tests/agent-install-check.test.cjs's own EXPECTED_AGENTS/createCompleteAgents
@@ -107,9 +108,10 @@ function makeDirUnreadableAsFile(fullPath) {
// on a "healthy" phase (Phase 12, #3310) get a genuinely clean baseline
// rather than a false positive from a plan that predates the `wave:` field.
function makeCompletePhaseDir(cwd, relPhaseDir) {
const phaseNum = normalizePhaseName(path.basename(relPhaseDir));
writeFile(cwd, `${relPhaseDir}/01-01-PLAN.md`, '---\nwave: 1\n---\n\n# Plan\n');
writeFile(cwd, `${relPhaseDir}/01-01-SUMMARY.md`, '# Summary\n');
writeFile(cwd, `${relPhaseDir}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n');
writeFile(cwd, `${relPhaseDir}/${phaseNum}-VERIFICATION.md`, '---\nstatus: passed\n---\n');
}
function buildHealthyTwoPhaseFixture(cwd) {
@@ -955,6 +957,24 @@ describe('researchValidationStatus field (Phase 11, #3309)', () => {
assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: false, hasValidationMd: false });
});
test('#3511-class: a phase dir holding only ANOTHER phase\'s -RESEARCH.md/-VALIDATION.md does not set this phase\'s flags', (t) => {
const cwd = createTempDir('gsd-3511-rvs4-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'));
// Phase 01's directory is empty. Phase 02's directory holds ONLY stray
// artifacts whose filename token ("01-") belongs to phase 01, not to
// this directory's own phase (02).
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '01-foo'), { recursive: true });
writeFile(cwd, '.planning/phases/02-bar/01-RESEARCH.md', '# Research\n\n## Validation Architecture\n\ntext\n');
writeFile(cwd, '.planning/phases/02-bar/01-VALIDATION.md', '# Validation\n');
const snap = buildPlanningSnapshot(cwd);
const entry = snap.researchValidationStatus.value.find((r) => r.dir === '02-bar');
assert.deepStrictEqual(entry, { dir: '02-bar', hasValidationArchitecture: false, hasValidationMd: false },
'phase 2 must not report validation status from a file that belongs to phase 1');
});
test('hostile: an unreadable phase directory degrades that entry to false/false without throwing', (t) => {
const cwd = createTempDir('gsd-3309-rvs3-');
t.after(() => cleanup(cwd));

View File

@@ -370,7 +370,7 @@ describe('withPlanningLock PID-liveness staleness + EEXIST safety (audit M1+M2)'
__foldDescribe("folded:bug-3739-gap-checker-padded-prefix-context (consolidation epic #1969 B3 #1972)", () => {
/**
* Bug #3739: gap-analysis silently skips CONTEXT.md decisions when the file
* uses the padded-prefix convention (e.g. 01-CONTEXT.md, 02.1-CONTEXT.md).
* uses the padded-prefix convention (e.g. 01-CONTEXT.md, 01.1-CONTEXT.md).
*
* Verifies:
* 1. Padded-prefix CONTEXT.md (NN-CONTEXT.md) decisions ARE included in the
@@ -481,10 +481,10 @@ describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => {
assert.strictEqual(d05.status, 'Covered', 'D-05 must be Covered');
});
// ── Test 4: deeper padded prefix (02.1-CONTEXT.md) ───────────────────────
// ── Test 4: deeper padded prefix (01.1-CONTEXT.md) ───────────────────────
test('multi-segment padded prefix (02.1-CONTEXT.md) decisions appear in gap report', () => {
writeContextAs('02.1-CONTEXT.md', [
test('multi-segment padded prefix (01.1-CONTEXT.md) decisions appear in gap report', () => {
writeContextAs('01.1-CONTEXT.md', [
{ id: 'D-03', text: 'Use postgres' },
]);
writePlan('01', '# Plan\n\nImplements D-03.\n');
@@ -494,7 +494,7 @@ describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => {
const out = JSON.parse(r.output);
const d03 = out.rows.find(x => x.item === 'D-03');
assert.ok(d03, 'D-03 must appear from 02.1-CONTEXT.md');
assert.ok(d03, 'D-03 must appear from 01.1-CONTEXT.md');
assert.strictEqual(d03.status, 'Covered');
});

View File

@@ -312,6 +312,26 @@ describe('gap-analysis CLI (#2493)', () => {
assert.strictEqual(out.rows[0].source, 'REQUIREMENTS.md');
});
test('#3511-class: a phase dir holding only ANOTHER phase\'s -CONTEXT.md is treated as CONTEXT-missing', () => {
// phaseDir is '01-test' for this describe block's own scenarios; use a
// SEPARATE phase 02 directory that holds ONLY a stray artifact whose
// filename token ("01-") belongs to phase 01, not to this directory's
// own phase (02).
const otherPhaseDir = path.join(tmpDir, '.planning', 'phases', '02-other');
fs.mkdirSync(otherPhaseDir, { recursive: true });
fs.writeFileSync(
path.join(otherPhaseDir, '01-CONTEXT.md'),
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n- **D-01:** foo\n</decisions>\n',
);
fs.writeFileSync(path.join(otherPhaseDir, '02-PLAN.md'), '# Plan mentioning D-01\n');
const r = runGsdTools(['gap-analysis', '--phase-dir', otherPhaseDir], tmpDir);
assert.ok(r.success, r.error);
const out = JSON.parse(r.output);
assert.deepStrictEqual(out.rows, [],
'phase 02 must not report a CONTEXT.md decision that belongs to phase 01\'s file');
});
test('both REQUIREMENTS.md and CONTEXT.md missing → no error, empty rows', () => {
writePlan('01', '# Plan\n');
const r = runGsdTools(['gap-analysis', '--phase-dir', phaseDir], tmpDir);

View File

@@ -401,6 +401,39 @@ describe('roadmap analyze disk status variants', () => {
assert.strictEqual(output.phases[0].has_research, true, 'has_research should be true');
});
test('#3511-class: roadmap analyze has_research/has_context ignore another phase\'s misplaced artifact', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Setup
**Goal:** Build the foundation
### Phase 2: API
**Goal:** Build the API
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(p1, { recursive: true });
// Phase 02's directory holds ONLY a stray artifact whose filename token
// ("01-") belongs to phase 01, not to this directory's own phase (02).
const p2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '01-RESEARCH.md'), '# Research for phase 01');
fs.writeFileSync(path.join(p2, '01-CONTEXT.md'), '# Context for phase 01');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const phase2 = output.phases.find((p) => p.number === '2');
assert.strictEqual(phase2.has_research, false,
'phase 2 must not report has_research from a file that belongs to phase 1');
assert.strictEqual(phase2.has_context, false,
'phase 2 must not report has_context from a file that belongs to phase 1');
});
test('returns discussed status for phase dir with only CONTEXT.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),

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(

View File

@@ -1011,6 +1011,83 @@ describe('evaluateUatPassed — output shape (Hyrum\'s Law contract)', () => {
});
});
// ─── #3511: phase-scoped UAT/VERIFICATION scanning — the transition gate ─────
//
// evaluateUatPassed is the CRITICAL anchor for #3511: its `passed`/`blockers`
// fields directly gate a phase transition. A cross-phase stray file sitting
// in a phase directory must never contribute a blocker to a phase it does not
// belong to, and the phase's own artifacts must keep behaving exactly as
// before.
describe('#3511: evaluateUatPassed — cross-phase stray files do not contribute blockers', () => {
let baseDir;
let phaseDir;
beforeEach(() => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-uat-pred-'));
// Phase-shaped basename ("03-…") so isPhaseArtifact actually scopes —
// extractPhaseToken('03-uat-predicate') derives token "03".
phaseDir = path.join(baseDir, '03-uat-predicate');
fs.mkdirSync(phaseDir);
});
afterEach(() => {
rmDir(baseDir);
});
test('passed:true with a passing own UAT + own VERIFICATION, despite a blocking cross-phase stray VERIFICATION', () => {
writeFile(phaseDir, '03-UAT.md', makePassingUat(1));
writeFile(phaseDir, '03-VERIFICATION.md', '---\nstatus: passed\n---\n\nOK.');
// Cross-phase stray: a "04" VERIFICATION file sitting in phase 03's
// directory, with a BLOCKING status. Pre-#3511, this unscoped scan would
// have picked it up and blocked phase 03's transition.
writeFile(phaseDir, '04-VERIFICATION.md', '---\nstatus: human_needed\n---\n\nNeeds human check.');
const report = evaluateUatPassed(phaseDir);
assert.strictEqual(report.passed, true,
'a cross-phase stray VERIFICATION file must not block this phase\'s transition');
assert.ok(
!report.blockers.some(b => /04-VERIFICATION\.md/.test(b) || /human_needed/i.test(b)),
`blockers must not name the stray file; got: ${JSON.stringify(report.blockers)}`,
);
assert.strictEqual(report.verification_files.includes('04-VERIFICATION.md'), false,
'the stray must not even be counted as a verification_files entry for this phase');
});
test('passed:false with own report still failing, unaffected by an unrelated passing cross-phase stray (non-stray case unchanged)', () => {
writeFile(phaseDir, '03-UAT.md', makePassingUat(1));
// This phase's own VERIFICATION is blocking.
writeFile(phaseDir, '03-VERIFICATION.md', '---\nstatus: gaps_found\n---\n\nHas gaps.');
// A cross-phase stray that is itself passing must not paper over the
// phase's own real failure either — over-exclusion is as dangerous as
// under-exclusion here.
writeFile(phaseDir, '99-VERIFICATION.md', '---\nstatus: passed\n---\n\nOK.');
const report = evaluateUatPassed(phaseDir);
assert.strictEqual(report.passed, false,
'the phase\'s own gaps_found VERIFICATION must still block, exactly as before #3511');
assert.ok(report.blockers.some(b => /gaps_found/i.test(b)),
`blockers must still name this phase's own gaps_found status; got: ${JSON.stringify(report.blockers)}`);
});
test('#3511 follow-up: passed:true from a NON-canonical dir shape "1-unpadded" (over-exclusion / no_uat_artifacts check)', () => {
// "1-unpadded" tokenizes to literal "1"; scaffold writes the PADDED
// "01-…" form (normalizePhaseName). A literal token compare excluded the
// phase's own artifacts here, flipping `no_uat_artifacts: true` and
// false-blocking the transition gate this predicate feeds.
const unpaddedDir = path.join(baseDir, '1-unpadded');
fs.mkdirSync(unpaddedDir);
writeFile(unpaddedDir, '01-UAT.md', makePassingUat(1));
writeFile(unpaddedDir, '01-VERIFICATION.md', '---\nstatus: passed\n---\n\nOK.');
const report = evaluateUatPassed(unpaddedDir);
assert.strictEqual(report.no_uat_artifacts, false,
`own UAT/VERIFICATION files in an unpadded-dir phase must be found; got: ${JSON.stringify(report)}`);
assert.strictEqual(report.passed, true,
`the phase's own passing files in a non-canonical dir must pass the gate; got: ${JSON.stringify(report)}`);
});
});
// ─── FIX A regression: nested-fence (~~~ inside ```) ─────────────────────────
describe('FIX A — nested fence: ~~~ inside ``` does not prematurely close outer fence', () => {

View File

@@ -433,6 +433,78 @@ All checks passed.
assert.strictEqual(output.summary.total_files, 0);
});
// #3511: a cross-phase, stray, or ad-hoc UAT/VERIFICATION file sitting in
// this phase's directory must not surface under this phase's audit-uat
// entry; this phase's own UAT/VERIFICATION artifacts must keep reporting
// exactly as before (non-stray case unchanged).
test('#3511: cross-phase stray UAT/VERIFICATION files in the same dir do not surface; own artifacts still do', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-foo');
fs.mkdirSync(phaseDir, { recursive: true });
// This phase's own UAT — must still report its pending item.
fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), [
'---', 'status: partial', '---', '',
'## Tests', '',
'### 1. Own Test', 'expected: Works', 'result: pending', '',
].join('\n'));
// This phase's own VERIFICATION — must still report its human-needed item.
fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), [
'---', 'status: human_needed', 'phase: 03-foo', '---', '',
'## Human Verification', '',
'1. Own human check',
].join('\n'));
// Cross-phase strays sitting in the SAME directory — token "04", not "03".
fs.writeFileSync(path.join(phaseDir, '04-UAT.md'), [
'---', 'status: partial', '---', '',
'## Tests', '',
'### 1. Stray Test', 'expected: Works', 'result: pending', '',
].join('\n'));
fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), [
'---', 'status: human_needed', 'phase: 04-bar', '---', '',
'## Human Verification', '',
'1. Stray human check',
].join('\n'));
const result = runGsdTools('audit-uat --raw', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.summary.total_files, 2,
`only this phase's own 2 files must be scanned; got: ${JSON.stringify(output.results.map(r => r.file))}`);
assert.strictEqual(output.summary.total_items, 2,
`1 own UAT item + 1 own VERIFICATION item, strays excluded; got: ${output.summary.total_items}`);
assert.strictEqual(output.summary.by_phase['03'], 2, 'own phase must be credited both items');
assert.ok(!('04' in output.summary.by_phase), 'the cross-phase stray must not appear in by_phase at all');
assert.ok(!result.output.includes('04-UAT.md'), 'stray UAT filename must never surface in the output');
assert.ok(!result.output.includes('04-VERIFICATION.md'), 'stray VERIFICATION filename must never surface in the output');
assert.ok(output.results.some(r => r.file === '03-UAT.md' && r.items.some(i => i.name === 'Own Test')));
assert.ok(output.results.some(r => r.file === '03-VERIFICATION.md' && r.items.some(i => i.name === 'Own human check')));
});
// #3511 follow-up: over-exclusion check on the #2528 digit-leading-slug
// family. "05-80-20-cleanup" tokenizes to "05-80-20" (mis-absorbed past
// the digit run scaffold actually writes into), so a literal token compare
// excluded the phase's own report — audit-uat reported total_files: 0.
test('#3511 follow-up: own UAT file still surfaces from the digit-leading-slug dir "05-80-20-cleanup" (over-exclusion check)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-80-20-cleanup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '05-UAT.md'), [
'---', 'status: partial', '---', '',
'## Tests', '',
'### 1. Own Test', 'expected: Works', 'result: pending', '',
].join('\n'));
const result = runGsdTools('audit-uat --raw', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.summary.total_files, 1,
`own UAT file in a digit-leading-slug dir must still surface; got: ${JSON.stringify(output)}`);
assert.strictEqual(output.summary.by_phase['05'], 1);
});
// Regression: #2286 — parseUatItems never scanned a `## Gaps` section, so a
// *-UAT.md file recording its only outstanding findings there returned
// total_items: 0 (false-clean). Boundary: 0 / 1 / 2+ unresolved entries.

View File

@@ -59,11 +59,18 @@ const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Create a temporary phase directory under os.tmpdir().
* Returns the absolute path; caller must clean up.
* Create a temporary phase directory named like a real one (`NN-slug`),
* inside a throwaway parent. #3511: a phase directory's own name determines
* which files count as ITS artifacts, so a fixture whose basename does not
* name the same phase as the files written into it is not a valid phase dir.
* @param {string} suffix - test-distinguishing suffix for the parent
* @param {string} phaseDirName - basename of the phase dir (default '01-foo')
*/
function mkPhaseDir(suffix) {
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`));
function mkPhaseDir(suffix, phaseDirName = '01-foo') {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`));
const phaseDir = path.join(parent, phaseDirName);
fs.mkdirSync(phaseDir);
return phaseDir;
}
/**
@@ -99,7 +106,7 @@ describe('verification-status', () => {
assert.equal(result.next_command, '', 'next_command must be empty for passed');
assert.ok(result.next_action.length > 0, 'next_action must be non-empty');
} finally {
cleanup(dir);
cleanup(path.dirname(dir));
}
});
@@ -129,7 +136,14 @@ describe('verification-status', () => {
// ── Case 3: human_needed ──────────────────────────────────────────────────
test('status: human_needed → status human_needed, next_command is empty', () => {
const dir = mkPhaseDir('human-needed');
// Deliberately non-numeric dir basename ("human-needed" has no digits at
// all) — extractPhaseToken has no derivable token, so (a) isPhaseArtifact's
// fail-safe still includes 01-hn-VERIFICATION.md as this "phase"'s own
// report, and (b) the next_command number-append check (which requires a
// PURELY numeric token) never fires. This is what this test is actually
// pinning — see the comment below — so the dir name must stay non-numeric,
// not the realistic 'NN-slug' default.
const dir = mkPhaseDir('human-needed', 'human-needed');
try {
writeVerificationMd(dir, '01-hn-VERIFICATION.md', 'human_needed');
const result = readVerificationStatus(dir);
@@ -139,13 +153,16 @@ describe('verification-status', () => {
assert.equal(result.next_command, '/gsd-verify-work');
assert.ok(result.next_action.length > 0);
} finally {
cleanup(dir);
cleanup(path.dirname(dir));
}
});
// ── Case 4: no *-VERIFICATION.md → missing ────────────────────────────────
test('no *-VERIFICATION.md file → status missing, next_command execute-phase', () => {
const dir = mkPhaseDir('missing');
// Non-numeric dir basename: next_command asserts no phase-number argument
// is appended, which requires extractPhaseToken(dirName) to not be purely
// numeric — see the human_needed test above for the same rationale.
const dir = mkPhaseDir('missing', 'missing');
try {
// write a non-matching file to confirm it is ignored
fs.writeFileSync(path.join(dir, 'README.md'), '# phase');
@@ -158,13 +175,15 @@ describe('verification-status', () => {
`next_action must reassure the user execute-phase will not redo work (#1762); got: ${result.next_action}`,
);
} finally {
cleanup(dir);
cleanup(path.dirname(dir));
}
});
// ── Case 5: unknown frontmatter status value ──────────────────────────────
test("frontmatter status 'bogus' → status unknown, next_command execute-phase", () => {
const dir = mkPhaseDir('unknown');
// Non-numeric dir basename: next_command asserts no phase-number argument
// is appended — see the human_needed test above for the same rationale.
const dir = mkPhaseDir('unknown', 'unknown');
try {
writeVerificationMd(dir, '01-u-VERIFICATION.md', 'bogus');
const result = readVerificationStatus(dir);
@@ -179,7 +198,7 @@ describe('verification-status', () => {
`next_action must acknowledge an unrecognized status may be an intentional marker (#1762); got: ${result.next_action}`,
);
} finally {
cleanup(dir);
cleanup(path.dirname(dir));
}
});
@@ -225,7 +244,7 @@ describe('verification-status', () => {
);
assert.equal(result.next_command, '', 'next_command must be empty for passed');
} finally {
cleanup(dir);
cleanup(path.dirname(dir));
}
});
@@ -298,7 +317,7 @@ describe('verification-status', () => {
assert.equal(result.status, 'passed', 'CRLF frontmatter must parse to passed');
assert.equal(result.next_command, '');
} finally {
cleanup(dir);
cleanup(path.dirname(dir));
}
});
@@ -316,7 +335,7 @@ describe('verification-status', () => {
"A body-only status: line must NOT be read — result should be 'missing'",
);
} finally {
cleanup(dir);
cleanup(path.dirname(dir));
}
});
@@ -333,10 +352,14 @@ describe('verification-status', () => {
// of the #3492 phase-pinned rule, not the contract itself — see the
// `#3357/#3492` describe block below for the primary, phase-pinned tier
// (resolveVerificationFile unit tests are the reliable anchors there).
// mkPhaseDir's random-suffixed basename never matches "01"/"02", so this
// exercises the fallback by construction.
// The dir basename ('multi', no digits) has no derivable phase token, so
// scopeToPhase's isPhaseArtifact fail-safe passes BOTH candidates through
// unfiltered (#3511: scopeToPhase is a plain filter with no other
// fallback — a derivable token that matched neither file would empty the
// set and this test would read 'missing', not exercise the alphabetical
// tiebreak at all).
test('multiple *-VERIFICATION.md files, none matching the phase token → alphabetically-first FALLBACK wins', () => {
const dir = mkPhaseDir('multi');
const dir = mkPhaseDir('multi', 'multi');
try {
// Write two files: alphabetically "01-a" comes before "02-b"
// "01-a" has passed; "02-b" has gaps_found — first by sort must win
@@ -350,7 +373,7 @@ describe('verification-status', () => {
'With no exact phase-token match, the first by lexicographic sort must be used',
);
} finally {
cleanup(dir);
cleanup(path.dirname(dir));
}
});
@@ -986,6 +1009,106 @@ describe('#3357/#3492: phase-pinned *-VERIFICATION.md resolution when multiple c
);
});
// #3511 reconciliation: resolveVerificationFile's fallback now scopes to
// isPhaseArtifact(fileName, phaseDirName), so a stray cross-phase file can
// no longer win the alphabetical-first fallback tier either — closing the
// gap isPhaseArtifact's own docblock (src/phase-id.cts) used to flag as
// open. The two pure cases below are the reliable anchors; the behavioral
// test after them pins the same contract through the real CLI-facing
// readVerificationStatus call path.
test('#3511: a cross-phase stray is excluded from the fallback → null, not the stray', () => {
assert.equal(
resolveVerificationFile(['04-VERIFICATION.md'], { phaseDirName: '03-foo' }),
null,
'04-VERIFICATION.md belongs to phase 04, not the "03-foo" directory\'s phase 03 — must not be returned',
);
});
test('#3511: a non-canonically-named report OF THIS phase still wins the fallback (the #3357 guarantee survives)', () => {
// The more important of the two #3511 cases: isPhaseArtifact scopes by
// phase-number membership, not by canonical shape, so this file still
// passes and the #3357 "non-canonical report still resolves" guarantee
// is not disturbed by the #3511 scoping.
assert.equal(
resolveVerificationFile(['03-CORRECTION-VERIFICATION.md'], { phaseDirName: '03-foo' }),
'03-CORRECTION-VERIFICATION.md',
'03-CORRECTION-VERIFICATION.md names phase 03, same as directory "03-foo" — must still resolve',
);
});
test('#3511: cross-phase stray alongside this phase\'s own non-canonical report → own report wins, stray excluded (not merely outsorted)', () => {
// Distinguishes "excluded from the fallback" from "just happens to sort
// after" — candidates are sorted at verification.cts's own call site
// before reaching resolveVerificationFile, and '01-VERIFICATION.md'
// sorts BEFORE '03-CORRECTION-VERIFICATION.md' alphabetically, so an
// UNSCOPED (alphabetical-first) fallback would wrongly pick the stray
// here. Scoping must actively exclude it for '03-CORRECTION-…' to win.
assert.equal(
resolveVerificationFile(
['01-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'],
{ phaseDirName: '03-foo' },
),
'03-CORRECTION-VERIFICATION.md',
);
});
// WARNING-2/5/INFO-2 note (#3511 review): the only fallback test above uses
// a token-LESS dir ("03-foo" isn't token-less — this refers to the earlier
// `multiple *-VERIFICATION.md files, none matching the phase token` test,
// which passes no derivable-token distinguishing fixture and so passes
// identically pre-#3511-fix). This test uses a dir WITH a derivable token
// (`03-foo` → token "03") and TWO candidates that BOTH belong to that same
// phase (`03-a-…`/`03-b-…`, no exact `03-VERIFICATION.md`), so scoping
// excludes nothing and the alphabetical-first tie-break still decides —
// pinning that scoping does not disturb the ordinary same-phase-multi-file
// case.
test('#3511: alphabetical fallback when BOTH candidates are this phase\'s own (derivable token, no exact match)', () => {
assert.equal(
resolveVerificationFile(['03-a-VERIFICATION.md', '03-b-VERIFICATION.md'], { phaseDirName: '03-foo' }),
'03-a-VERIFICATION.md',
'both candidates belong to phase 03 (same as dir "03-foo"); alphabetically-first must still win',
);
});
test('behavioral (readVerificationStatus): a phase dir holding only a cross-phase stray reports missing, not the stray\'s status (#3511)', () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-stray-only-'));
const dir = path.join(baseDir, '03-foo');
fs.mkdirSync(dir);
try {
// Only a stray belonging to phase 04 sits in phase 03's directory. Give
// it a status that would NOT read as missing if it were (wrongly) picked,
// so a regression here is loud rather than accidentally matching.
writeVerificationMd(dir, '04-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'missing',
'a phase dir holding only another phase\'s report must report missing, not passed',
);
} finally {
cleanup(baseDir);
}
});
test('behavioral (readVerificationStatus): a phase dir holding only its own non-canonically-named report still resolves it (#3357 guarantee survives #3511 scoping)', () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3511-own-noncanon-'));
const dir = path.join(baseDir, '03-foo');
fs.mkdirSync(dir);
try {
writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'passed',
'the phase\'s own non-canonically-named report must still resolve, not read as missing',
);
} finally {
cleanup(baseDir);
}
});
test('behavioral (readVerificationStatus): a phase with both its own report and a cross-phase stray reports the OWN report\'s status, not the stray\'s', () => {
// The directory basename is "03-canonical-test" so extractPhaseToken
// derives token "03" — the exact same derivation readVerificationStatus
@@ -1123,6 +1246,21 @@ describe('#3473 F2: resolveVerificationFile allowBare option', () => {
);
});
// #3511: allowBare must still fall through to the bare match when the ONLY
// dashed candidate is excluded by phaseDirName scoping (a cross-phase
// stray) — the fallback tier finding nothing phase-owned is the same
// "no dashed candidate at all" case allowBare was always reached from.
test('#3511: allowBare:true, bare + a cross-phase dashed stray scoped out by phaseDirName → the bare file wins', () => {
assert.equal(
resolveVerificationFile(
['VERIFICATION.md', '04-VERIFICATION.md'],
{ allowBare: true, phaseDirName: '03-foo' },
),
'VERIFICATION.md',
'04-VERIFICATION.md belongs to a different phase and is excluded, so bare VERIFICATION.md is the only remaining candidate',
);
});
});
// ─── #3518: resolveUatFile — phase-pinned, deterministic *-UAT.md pick ───────

View File

@@ -14,7 +14,7 @@ const { createFixture, seedWorkstream } = require('./fixtures/index.cjs');
const { buildWorkstreamInventory, isCompletedInventory, pickRollupWinners } = require('../gsd-core/bin/lib/workstream-inventory-builder.cjs');
const { inspectWorkstream } = require('../gsd-core/bin/lib/workstream-inventory.cjs');
const { VERIFIER_STATUSES } = require('../gsd-core/bin/lib/verification.cjs');
const { phaseKeyFromDir, phaseKeyFromProse, phaseKeyFromToken } = require('../gsd-core/bin/lib/phase-id.cjs');
const { phaseKeyFromDir, phaseKeyFromProse, phaseKeyFromToken, normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
const fc = require('fast-check');
const STALE_STATE = 'status: executing\n';
@@ -235,7 +235,7 @@ describe('#2562 — progress/status scoped to the current milestone (derived fro
fs.mkdirSync(dir, { recursive: true });
for (let i = 1; i <= plans; i++) fs.writeFileSync(path.join(dir, `0${i}-PLAN.md`), '# plan\n');
for (let i = 1; i <= summaries; i++) fs.writeFileSync(path.join(dir, `0${i}-SUMMARY.md`), '# summary\n');
if (verification) fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), `---\nstatus: ${verification}\n---\n`);
if (verification) fs.writeFileSync(path.join(dir, `${normalizePhaseName(slug)}-VERIFICATION.md`), `---\nstatus: ${verification}\n---\n`);
}
const MS_STATE = 'milestone: v2.0\nstatus: executing\n';
@@ -361,7 +361,7 @@ describe('#2562 — milestone scoping boundaries (one phase-key derivation)', ()
fs.mkdirSync(dir, { recursive: true });
for (let i = 1; i <= plans; i++) fs.writeFileSync(path.join(dir, `0${i}-PLAN.md`), '# plan\n');
for (let i = 1; i <= summaries; i++) fs.writeFileSync(path.join(dir, `0${i}-SUMMARY.md`), '# summary\n');
if (verification) fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), `---\nstatus: ${verification}\n---\n`);
if (verification) fs.writeFileSync(path.join(dir, `${normalizePhaseName(slug)}-VERIFICATION.md`), `---\nstatus: ${verification}\n---\n`);
}
function roadmapWithRows(rows) {
@@ -926,7 +926,7 @@ describe('#2562 — a shipped marker its own artifacts contradict is not asserte
fs.mkdirSync(dir, { recursive: true });
for (let i = 1; i <= plans; i++) fs.writeFileSync(path.join(dir, `0${i}-PLAN.md`), '# plan\n');
for (let i = 1; i <= summaries; i++) fs.writeFileSync(path.join(dir, `0${i}-SUMMARY.md`), '# summary\n');
if (verification) fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), `---\nstatus: ${verification}\n---\n`);
if (verification) fs.writeFileSync(path.join(dir, `${normalizePhaseName(slug)}-VERIFICATION.md`), `---\nstatus: ${verification}\n---\n`);
}
function writeSnapshot(wsDir) {
@@ -1054,7 +1054,7 @@ describe('#2645 — deleting a verification report must not raise completeness',
fs.mkdirSync(dir, { recursive: true });
for (let i = 1; i <= plans; i++) fs.writeFileSync(path.join(dir, `0${i}-PLAN.md`), '# plan\n');
for (let i = 1; i <= summaries; i++) fs.writeFileSync(path.join(dir, `0${i}-SUMMARY.md`), '# summary\n');
if (verification) fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), `---\nstatus: ${verification}\n---\n`);
if (verification) fs.writeFileSync(path.join(dir, `${normalizePhaseName(slug)}-VERIFICATION.md`), `---\nstatus: ${verification}\n---\n`);
return dir;
}