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

@@ -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 ───────