fix(#4187): status reader resolves a bare VERIFICATION.md like resolve-file (#4388)

* test(#4187): bare VERIFICATION.md regression matrix for the status surface

Both query verbs must agree on every row: bare file, suffixed variants,
missing file, other-dir placement, and the staleness seam. Row 1 is the
failing-first regression from the issue repro.

* fix(#4187): status reader resolves a bare VERIFICATION.md like resolve-file

readVerificationStatus and its internal staleness check
(findStaleVerificationSummary) called the shared resolver without
allowBare, so a phase whose only report was a bare VERIFICATION.md read
as missing and was told to re-run execute-phase while
verification.resolve-file, determinePhaseStatus, and both init
verification_path projectors all resolved the same file. Both call
sites now pass allowBare: true, matching the other five; tier order
(dashed > bare) is unchanged, so only bare-only directories change
behavior.

* fix(#4187): correct call-site counts in allowBare docblocks

Adversarial review caught the comments claiming five of six call sites
opted in; the current tree has six call sites with four previously
passing allowBare — the two module-internal status-path sites were both
holdouts, not one.

* chore(#4187): changeset for the bare VERIFICATION.md status fix

* chore(#4187): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-06 11:54:03 -04:00
committed by GitHub
parent fd4aac5670
commit 708d9a0b82
3 changed files with 251 additions and 11 deletions

View File

@@ -1209,11 +1209,15 @@ describe('#3357/#3492: phase-pinned *-VERIFICATION.md resolution when multiple c
// commands.cts (determinePhaseStatus) and two verification_path projectors in
// init.cts each hand-rolled a fourth variant of this same selection: they
// additionally accept a BARE `VERIFICATION.md`, which this module's own two
// callers (findStaleVerificationSummary, readVerificationStatus) never have.
// `allowBare` threads that one behavioral difference through the single
// resolver instead of leaving a fourth hand-rolled implementation behind
// (#3473 F2). A bare match is ranked BELOW any dashed candidate — canonical
// or not — because a dashed file names its phase and a bare one does not.
// callers (findStaleVerificationSummary, readVerificationStatus) originally
// did not. `allowBare` threads that one behavioral difference through the
// single resolver instead of leaving a fourth hand-rolled implementation
// behind (#3473 F2). A bare match is ranked BELOW any dashed candidate —
// canonical or not — because a dashed file names its phase and a bare one
// does not. Since #4187 the two module-internal call sites pass `allowBare:
// true` as well (see the #4187 describe below), so every reader of the report
// set now agrees; the option's default remains `false` for callers that have
// not opted in.
describe('#3473 F2: resolveVerificationFile allowBare option', () => {
test('allowBare defaults to false — a bare-only list returns null without the option', () => {
@@ -1265,6 +1269,210 @@ describe('#3473 F2: resolveVerificationFile allowBare option', () => {
});
// ─── #4187: a bare VERIFICATION.md is a first-class report on the status surface ──
//
// `query verification.resolve-file` (cmdVerificationResolveFile), the phase-status
// surface (commands.cts determinePhaseStatus) and both init verification_path
// projectors all resolve a BARE `VERIFICATION.md` — but readVerificationStatus
// (the reader behind `query verification.status`, isPhaseComplete, and the state/
// roadmap completion projections) called the same shared resolver WITHOUT
// `allowBare`, so a phase whose only report was bare read as `missing` and was
// told to re-run execute-phase even when the report said `status: passed`.
// Two read-only verbs disagreed about the same directory at the same instant
// (#4187). The fix opts the status reader — and findStaleVerificationSummary,
// its internal legacy staleness check, whose only production caller is
// readVerificationStatus — into the same `allowBare: true` the other four call
// sites already pass. Tier order is unchanged: a dashed candidate (canonical or
// not) still outranks the bare name, so only bare-ONLY directories change
// behavior, from `missing` to the report's actual frontmatter status.
describe('#4187: status surface recognizes a bare VERIFICATION.md', () => {
test('#4187 regression: bare VERIFICATION.md with status: passed reads as passed, not missing', (t) => {
const dir = mkPhaseDir('bare-passed', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
assert.equal(result.status, 'passed', 'a bare report is a report — the phase is verified');
assert.equal(result.next_command, '', 'passed must route to no next command');
assert.equal(result.staleCheckIndeterminate, undefined, 'the staleness check must run to completion');
});
test('#4187: bare report with status: human_needed routes to verify-work', (t) => {
const dir = mkPhaseDir('bare-human', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'human_needed');
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
assert.equal(result.status, 'human_needed');
assert.equal(result.next_command, '/gsd-verify-work 99');
});
test('#4187: bare report with status: gaps_found routes to plan-phase --gaps', (t) => {
const dir = mkPhaseDir('bare-gaps', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'gaps_found');
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
assert.equal(result.status, 'gaps_found');
assert.equal(result.next_command, '/gsd-plan-phase 99 --gaps');
});
test('#4187 negative space: no verification file at all still reads missing with the execute-phase recommendation', (t) => {
const dir = mkPhaseDir('bare-none', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
const result = readVerificationStatus(dir);
assert.equal(result.status, 'missing', 'a genuinely absent report must stay missing');
assert.equal(result.next_command, '/gsd-execute-phase 99', 'the missing recommendation is correct here and must not change');
});
test('#4187 parity: bare + canonical dashed report — the dashed file wins on the status surface too', (t) => {
const dir = mkPhaseDir('bare-vs-dashed', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'gaps_found');
writeVerificationMd(dir, '99-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
assert.equal(result.status, 'passed', 'a dashed file names its phase and must outrank the bare match');
});
test('#4187 parity: bare + cross-phase dashed stray — #3511 scoping falls through to the bare report', (t) => {
const dir = mkPhaseDir('bare-vs-stray', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'passed');
writeVerificationMd(dir, '04-VERIFICATION.md', 'gaps_found');
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
assert.equal(result.status, 'passed', 'the stray belongs to phase 04; the bare file is this phase\'s only own report');
});
test('#4187 parity: same-dir non-canonical dashed report only — unchanged behavior', (t) => {
const dir = mkPhaseDir('dashed-noncanon', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, '99-CORRECTION-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
assert.equal(result.status, 'passed');
});
test('#4187: directory scoping — a bare report in a DIFFERENT directory does not leak into the queried one', (t) => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4187-otherdir-'));
t.after(() => cleanup(baseDir));
const queried = path.join(baseDir, '99-probe');
const neighbor = path.join(baseDir, '98-other');
fs.mkdirSync(queried);
fs.mkdirSync(neighbor);
writeVerificationMd(neighbor, 'VERIFICATION.md', 'passed');
const result = readVerificationStatus(queried);
assert.equal(result.status, 'missing', 'the neighbor directory\'s report must not answer for the queried one');
});
test('#4187: bare report with no frontmatter block resolves but reads missing', (t) => {
const dir = mkPhaseDir('bare-no-fm', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
fs.writeFileSync(path.join(dir, 'VERIFICATION.md'), '# Verification\n');
const result = readVerificationStatus(dir);
assert.equal(result.status, 'missing', 'a resolved file with no frontmatter status is the pre-existing missing path');
});
test('#4187 staleness: a bare report older than its summary reads stale (legacy mtime path)', (t) => {
const dir = mkPhaseDir('bare-stale', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'passed');
setMtime(path.join(dir, 'VERIFICATION.md'), '2020-01-01T00:00:00Z');
// Root-style summary placement — mirrors the #3492 fixture above.
const summaryPath = path.join(dir, '99-01-SUMMARY.md');
fs.writeFileSync(summaryPath, '# summary\n');
setMtime(summaryPath, '2021-01-01T00:00:00Z');
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
assert.equal(result.status, 'stale', 'a bare report must be staleness-checked like a dashed one');
assert.equal(result.next_command, '/gsd-verify-work 99');
});
test('#4187 unit (findStaleVerificationSummary): staleness is computed against the bare report', (t) => {
const dir = mkPhaseDir('bare-stale-unit', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'passed');
setMtime(path.join(dir, 'VERIFICATION.md'), '2020-01-01T00:00:00Z');
const summaryPath = path.join(dir, '99-01-SUMMARY.md');
fs.writeFileSync(summaryPath, '# summary\n');
setMtime(summaryPath, '2021-01-01T00:00:00Z');
const result = findStaleVerificationSummary(dir, fs, () => new Map());
assert.equal(result.determined, true);
assert.equal(result.stale, true);
assert.equal(result.verificationFile, 'VERIFICATION.md', 'the staleness seam must see the bare report');
});
});
// ─── #4187 CLI parity: the two query verbs must agree on the same directory ───
//
// The issue's repro shape: run `query verification.resolve-file` and
// `query verification.status` back to back against ONE fixture directory and
// assert they never disagree about whether a report exists (and, when one does,
// about WHICH file is the report — enforced by giving the candidates distinct
// frontmatter statuses and asserting the routed status matches the expected
// winner).
describe('#4187 CLI parity: verification.resolve-file and verification.status agree', () => {
const { runGsdTools } = require('./helpers.cjs');
function runBothVerbs(dir) {
const resolveRes = runGsdTools(['query', 'verification.resolve-file', dir, '--raw'], dir);
const statusRes = runGsdTools(['query', 'verification.status', dir, '--raw'], dir);
assert.equal(resolveRes.success, true, `resolve-file failed: ${resolveRes.error}`);
assert.equal(statusRes.success, true, `status failed: ${statusRes.error}`);
return { resolvedPath: resolveRes.output.trimEnd(), status: JSON.parse(statusRes.output).status, nextCommand: JSON.parse(statusRes.output).next_command };
}
test('bare report: resolve-file finds it and status reads passed (the #4187 repro)', (t) => {
const dir = mkPhaseDir('cli-bare', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'passed');
const { resolvedPath, status, nextCommand } = runBothVerbs(dir);
assert.equal(resolvedPath, path.join(dir, 'VERIFICATION.md'));
assert.equal(status, 'passed', 'status must not report missing for a file resolve-file resolves');
assert.equal(nextCommand, '');
});
test('no report: resolve-file is empty and status is missing (agreement in the other direction)', (t) => {
const dir = mkPhaseDir('cli-none', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
const { resolvedPath, status } = runBothVerbs(dir);
assert.equal(resolvedPath, '', 'resolve-file must report no file');
assert.equal(status, 'missing');
});
test('bare + canonical dashed: both verbs pick the dashed file', (t) => {
const dir = mkPhaseDir('cli-both', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'gaps_found');
writeVerificationMd(dir, '99-VERIFICATION.md', 'passed');
const { resolvedPath, status } = runBothVerbs(dir);
assert.equal(resolvedPath, path.join(dir, '99-VERIFICATION.md'));
assert.equal(status, 'passed', 'status must read the same winner resolve-file picked');
});
test('bare + cross-phase stray: both verbs pick the bare file', (t) => {
const dir = mkPhaseDir('cli-stray', '99-probe');
t.after(() => cleanup(path.dirname(dir)));
writeVerificationMd(dir, 'VERIFICATION.md', 'passed');
writeVerificationMd(dir, '04-VERIFICATION.md', 'gaps_found');
const { resolvedPath, status } = runBothVerbs(dir);
assert.equal(resolvedPath, path.join(dir, 'VERIFICATION.md'));
assert.equal(status, 'passed', 'status must read the same winner resolve-file picked');
});
});
// ─── #3518: resolveUatFile — phase-pinned, deterministic *-UAT.md pick ───────
//
// Both uat_path projectors in src/init.cts picked the phase's UAT artifact