fix(#3357): one phase-pinned resolver for verification-report discovery (#3513)

A phase directory can hold more than one `*-VERIFICATION.md` — an ad-hoc `03-CORRECTION-VERIFICATION.md` worksheet beside the real `03-VERIFICATION.md`. Discovery took the alphabetically-first match, so the worksheet won and the phase could report `missing` while a passing report sat next to it.

The issue named two copies. There were seven, in four grammars: two `.sort()[0]` sites in the verification module, three `.find()` over UNSORTED readdir order (phase status, and `verification_path` twice — filesystem-dependent, so two machines on one commit could disagree), and two in shell. All seven now route through one exported `resolveVerificationFile`; the shell copies via a new `verification resolve-file` verb rather than hand-rolling the rule an eighth time.

Fixing five of seven would have been worse than fixing none: the verify-work workflow is a WRITER that stamps `status: passed` onto the file it picks, so canonical-aware readers plus an alphabetical writer means the human_needed→passed canonicalization silently no-ops forever while the worksheet gets stamped. That divergence did not exist on next.

The first resolver was itself a regression — it preferred ANY canonically-shaped name over the phase's own report, so a stray cross-phase or sentinel-numbered file outranked it. The global-canonical preference was removed rather than narrowed; the rule is pinned to the phase token via `PHASE_NUMBER_TOKEN_SOURCE`, its existing owner.

Fixed in passing: the transition workflow's awk guarded on `NR==1` instead of `FNR==1`, so across a multi-file glob it armed only on the first file — a leading worksheet with no frontmatter blocked transition even when the canonical report passed. Also removed a U+00AD soft hyphen introduced earlier on this branch.

Five broader-grammar AGGREGATE scans are deliberately out of scope — a different defect class (phase-unscoped scanning), tracked as #3511.

Closes #3357

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-14 19:18:29 -04:00
committed by GitHub
parent c66b010052
commit ddf852873c
13 changed files with 597 additions and 65 deletions

View File

@@ -2215,6 +2215,31 @@ describe('stats command', () => {
assert.strictEqual(stats.plan_percent, 67);
});
// #3473 F2 (companion to #3357): determinePhaseStatus now resolves its
// *-VERIFICATION.md via the shared resolveVerificationFile resolver instead
// of a hand-rolled `.find()` over unsorted readdir() order. Before this fix,
// which of a canonical report and an ad-hoc `-CORRECTION-VERIFICATION.md`
// worksheet "won" was filesystem-dependent; the canonical report must now
// win deterministically regardless of directory-listing order.
test('#3473 F2: phase status resolves the canonical report over a -CORRECTION- worksheet, not readdir order', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '03-api');
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');
// The ad-hoc worksheet reports gaps_found; if it won the pick, the phase
// would read 'Executed', not 'Complete'.
fs.writeFileSync(path.join(p1, '03-CORRECTION-VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Correction worksheet');
fs.writeFileSync(path.join(p1, '03-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.strictEqual(phase.status, 'Complete', 'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet');
});
test('counts requirements from REQUIREMENTS.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),

View File

@@ -1,8 +0,0 @@
{
"version": 1,
"paths": {
"transition.md": {
"reason": "#1526 (delegate, user decision 2026-08-13): added a `post_completion_mode` step at the top of <process> so transition.md is reusable when invoked by execute-phase AFTER phase.complete + verification already ran. The mode documents two invocations — standalone (run all steps) and post-completion delegation (SKIP verify_completion + update_roadmap_and_state to avoid a double phase.complete; RUN cleanup_handoff; BEGIN at evolve_project). Growth (~1.4 KB) is this one new step; no other step changed. (execute-phase.md, also touched by this PR, shrank slightly — its inline update_project_md + offer_next were replaced by a shorter delegation step — so it needs no ack.)"
}
}
}

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"paths": {
"transition.md": {
"reason": "#3357: verify_completion now resolves THIS phase's own verification report through the shared `verification.resolve-file` seam (src/verification.cts resolveVerificationFile) instead of a blind `*-VERIFICATION.md` glob feeding awk directly, so a stray ad-hoc worksheet (e.g. `03-CORRECTION-VERIFICATION.md`) can no longer alphabetically outrank the real report. Growth is +632 bytes (23,247 -> 23,879): the `gsd_run query verification.resolve-file` call plus its comment, and the FNR-vs-NR frontmatter re-arm fix (with rationale comment) for correctness across the resolver's single-path result. The canonical runtime-launcher preamble was relocated (not duplicated) from `update_roadmap_and_state` up to `verify_completion` since that step now needs `gsd_run` earlier in document order; net preamble count is unchanged at exactly one."
},
"verify-work.md": {
"reason": "#3357: the `03-VERIFICATION.md` staleness check now resolves the phase's own report via `gsd_run query verification.resolve-file \"$PHASE_DIR\" --raw` instead of `ls \"${PHASE_DIR}\"/*-VERIFICATION.md | head -1`, routing through the same shared resolver seam as transition.md so both call sites agree on which file is canonical. Growth is +13 bytes (38,983 -> 38,996), the delta between the old ls/head-1 pipeline and the gsd_run call."
}
}
}

View File

@@ -1,28 +0,0 @@
{
"version": 1,
"paths": {
"ai-integration-phase.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"diagnose-issues.md": "#3423 (epic #1891 F8): +6 bytes are the <files_to_read> -> <required_reading> tag rename (2 tag tokens, +3 bytes each); no prose added or rewritten. No tier crossed.",
"eval-review.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"new-milestone.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"quick.md": "#3423 (epic #1891 F8): +12 bytes are the <files_to_read> -> <required_reading> tag rename (4 tag tokens, +3 bytes each); no prose added or rewritten. No tier crossed.",
"secure-phase.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"ui-review.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"validate-phase.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
},
"verify-work.md": {
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
}
}
}

View File

@@ -120,6 +120,29 @@ describe('init commands', () => {
assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'));
});
// #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
// canonical report must win over an ad-hoc -CORRECTION- worksheet
// deterministically, regardless of directory-listing order.
test('#3473 F2: init plan-phase resolves the canonical report over a -CORRECTION- worksheet', () => {
seedPhase(tmpDir, '03-api', {
'03-CORRECTION-VERIFICATION.md': '# Ad-hoc correction worksheet',
'03-VERIFICATION.md': '# Verification',
});
writePlanningDocs(tmpDir);
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.verification_path,
absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'),
'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet',
);
});
// #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project
// code, so a foreign-prefixed workstream/task id like "MEM-01" collapsed to
// "01" and resolved to the unrelated numeric Phase 01. init plan-phase must
@@ -362,6 +385,27 @@ describe('init commands', () => {
assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md'));
});
// #3473 F2 (companion to #3357): init phase-op's verification_path projector
// — the second of the two now-fixed init.cts sites — same regression as
// the plan-phase test above.
test('#3473 F2: init phase-op resolves the canonical report over a -CORRECTION- worksheet', () => {
seedPhase(tmpDir, '03-api', {
'03-CORRECTION-VERIFICATION.md': '# Ad-hoc correction worksheet',
'03-VERIFICATION.md': '# Verification',
});
writePlanningDocs(tmpDir);
const result = runGsdTools('init phase-op 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.verification_path,
absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'),
'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet',
);
});
test('init plan-phase detects has_reviews and reviews_path when REVIEWS.md exists', () => {
seedPhase(tmpDir, '03-api', {
'03-REVIEWS.md': '# Cross-AI Reviews',

View File

@@ -14,8 +14,20 @@
* 8. CRLF line endings in frontmatter
* 9. Body-only file (no frontmatter block) → missing
* 10. Nonexistent phase directory → missing
* 11. Multiple *-VERIFICATION.md files → first by sort
* 11. Multiple *-VERIFICATION.md files, none matching the phase's own token →
* alphabetically-first FALLBACK wins (the phase-pinned rule's #2 tier —
* see #3492 below for the primary, phase-pinned tier)
* 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation)
* 13. #3357/#3492: `<phase-token>-VERIFICATION.md` resolution — resolveVerificationFile
* unit coverage plus behavioral tests through readVerificationStatus and
* findStaleVerificationSummary. THE CONTRACT (#3492): a candidate whose
* name exactly matches THIS phase's own token always wins, even over a
* different phase's canonically-shaped file; alphabetical-first among all
* dashed candidates is only the fallback when no exact match exists. The
* resolveVerificationFile unit tests are the reliable anchors for this —
* the readVerificationStatus/findStaleVerificationSummary behavioral tests
* are illustrative (their outcome also depends on directory-basename
* token derivation, not exercised in isolation there).
*
* PORTABILITY: pure JS — no shell-outs, no bash fences.
* Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.
@@ -35,7 +47,9 @@ const {
VERIFIER_STATUSES,
VERIFICATION_ROUTING_TABLE,
defaultPhaseCleanCommitTimesMs,
resolveVerificationFile,
readVerificationStatus,
findStaleVerificationSummary,
} = require('../gsd-core/bin/lib/verification.cjs');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
@@ -313,8 +327,14 @@ describe('verification-status', () => {
assert.equal(result.next_command, '/gsd-execute-phase');
});
// Multiple *-VERIFICATION.md files → deterministic pick (first by sort)
test('multiple *-VERIFICATION.md files in dir → first by sort order wins', () => {
// Multiple *-VERIFICATION.md files, NEITHER matching the phase dir's own
// token → deterministic FALLBACK pick (first by sort). This is the #2 tier
// 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.
test('multiple *-VERIFICATION.md files, none matching the phase token → alphabetically-first FALLBACK wins', () => {
const dir = mkPhaseDir('multi');
try {
// Write two files: alphabetically "01-a" comes before "02-b"
@@ -326,7 +346,7 @@ describe('verification-status', () => {
assert.equal(
result.status,
'passed',
'When multiple *-VERIFICATION.md files exist, the first by lexicographic sort must be used',
'With no exact phase-token match, the first by lexicographic sort must be used',
);
} finally {
cleanup(dir);
@@ -811,6 +831,299 @@ describe('verification-status', () => {
});
// ─── #3357/#3492: phase-pinned *-VERIFICATION.md resolution ──────────────────
//
// A phase dir can legitimately hold more than one `*-VERIFICATION.md` — the
// real per-phase report (`03-VERIFICATION.md`) alongside an ad-hoc plan
// worksheet (`03-CORRECTION-VERIFICATION.md`). The original "alphabetically
// first" pick chose the worksheet ('C' < 'V'), and a worksheet with no
// frontmatter `status:` made the whole phase read `missing` even though a
// passing report sat right next to it (#3357).
//
// #3492 REGRESSION this block anchors: the #3357 fix's first cut preferred
// ANY canonically-shaped `<token>-VERIFICATION.md`, regardless of WHOSE token
// it carried — so a stray cross-phase or sentinel-numbered canonical file
// (`999-VERIFICATION.md`) could outrank the querying phase's own (possibly
// non-canonical) report. THE CONTRACT (verified against the built lib):
// ['12-review-VERIFICATION.md', '999-VERIFICATION.md'] resolves to
// '12-review-VERIFICATION.md' for phase token '12' (was '999-…').
// ['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'] resolves to
// '04-VERIFICATION.md' for phase token '04' (was '03-CORRECTION-…').
// resolveVerificationFile is the single resolver findStaleVerificationSummary,
// readVerificationStatus, commands.cts's determinePhaseStatus, and both
// init.cts verification_path projectors all call, every one pinned to its own
// phaseDir's token (#3473 F2 / #3492).
//
// These resolveVerificationFile unit tests are the RELIABLE ANCHORS for the
// phase-pinned rule (a real `phaseToken` string, no filesystem/readdir order
// involved). The readVerificationStatus/findStaleVerificationSummary
// behavioral tests further down are illustrative only — their outcome
// additionally depends on the temp directory's basename tokenizing the way
// the test expects.
describe('#3357/#3492: phase-pinned *-VERIFICATION.md resolution when multiple candidates exist', () => {
test('#3492 regression counterexample 1: a sentinel-numbered stray file does not outrank this phase\'s own non-canonical report', () => {
assert.equal(
resolveVerificationFile(
['12-review-VERIFICATION.md', '999-VERIFICATION.md'],
{ phaseToken: '12-review' },
),
'12-review-VERIFICATION.md',
'this phase (token "12-review") owns 12-review-VERIFICATION.md; 999-VERIFICATION.md is a different phase and must not win',
);
});
test('#3492 regression counterexample 2: a cross-phase CORRECTION worksheet does not outrank this phase\'s own canonical report', () => {
assert.equal(
resolveVerificationFile(
['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'],
{ phaseToken: '04' },
),
'04-VERIFICATION.md',
'this phase (token "04") owns 04-VERIFICATION.md; the 03-CORRECTION worksheet belongs to a different phase',
);
});
test('exact phase-token match wins over an ad-hoc -CORRECTION- worksheet for the SAME phase', () => {
assert.equal(
resolveVerificationFile(
['03-CORRECTION-VERIFICATION.md', '03-VERIFICATION.md'],
{ phaseToken: '03' },
),
'03-VERIFICATION.md',
'the phase\'s own 03-VERIFICATION.md must win over its CORRECTION worksheet, not lose alphabetically',
);
});
test('order-independence: same candidates reversed → same answer', () => {
assert.equal(
resolveVerificationFile(
['03-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'],
{ phaseToken: '03' },
),
'03-VERIFICATION.md',
'input order must not change which file is selected',
);
});
test('decimal phase token: 35.1-VERIFICATION.md wins over a -CORRECTION- sibling', () => {
assert.equal(
resolveVerificationFile(
['35.1-CORRECTION-VERIFICATION.md', '35.1-VERIFICATION.md'],
{ phaseToken: '35.1' },
),
'35.1-VERIFICATION.md',
);
});
test('letter-suffixed phase token: 03A-VERIFICATION.md wins over a -CORRECTION- sibling', () => {
assert.equal(
resolveVerificationFile(
['03A-CORRECTION-VERIFICATION.md', '03A-VERIFICATION.md'],
{ phaseToken: '03A' },
),
'03A-VERIFICATION.md',
);
});
test('multi-canonical tiebreak: no exact phase-token match among several canonically-shaped candidates → alphabetically first', () => {
// Neither candidate's token is "50" — this is the (b) fallback tier, and
// it must stay a plain alphabetical pick (not a second, separate
// "canonical-shaped" preference — that concept no longer exists; #3492
// removed it because it was exactly the regression mechanism above).
assert.equal(
resolveVerificationFile(
['12-VERIFICATION.md', '999-VERIFICATION.md'],
{ phaseToken: '50' },
),
'12-VERIFICATION.md',
'"12-VERIFICATION.md" sorts before "999-VERIFICATION.md" and neither matches phase token "50"',
);
});
test('fallback: only a non-canonical file present → that file is still returned', () => {
// Load-bearing: a phase whose only report is non-canonically named must
// keep resolving to it, not to null — even when the phase token is known
// and does not exactly match.
assert.equal(
resolveVerificationFile(['01-review-VERIFICATION.md'], { phaseToken: '01' }),
'01-review-VERIFICATION.md',
);
});
test('fallback determinism: several non-canonical files, no phase token given → alphabetically first (unchanged)', () => {
assert.equal(
resolveVerificationFile(['02-b-VERIFICATION.md', '01-a-VERIFICATION.md']),
'01-a-VERIFICATION.md',
);
});
test('no phaseToken and no exact match → falls back to alphabetically-first, never null, when candidates exist', () => {
// #3492: an undeliverable/absent phase token must degrade to the original
// pre-#3357 behavior (alphabetically-first), not to null.
assert.equal(
resolveVerificationFile(['999-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md']),
'03-CORRECTION-VERIFICATION.md',
'with no phaseToken, plain alphabetical order decides — "03-…" sorts before "999-…"',
);
});
test('no matches → null', () => {
assert.equal(resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md'], { phaseToken: '03' }), null);
});
test('unrelated files are not miscounted as candidates', () => {
// 03-PLAN.md / 03-SUMMARY.md never end in "-VERIFICATION.md". A bare
// "VERIFICATION.md" (no leading phase-token dash) is also never a
// candidate — it fails the very `.endsWith('-VERIFICATION.md')` filter
// that builds the candidate list in the first place (the string is one
// character too short to end with a leading-dash suffix).
assert.equal(
resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md', 'VERIFICATION.md'], { phaseToken: '03' }),
null,
'a bare VERIFICATION.md is never a dashed candidate',
);
});
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
// performs internally, so this exercises the real production call path
// (not just the pure resolver), pinned to counterexample 2's shape.
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-parent-'));
const dir = path.join(baseDir, '03-canonical-test');
fs.mkdirSync(dir);
try {
// A stray cross-phase canonical file with a DIFFERENT status — must not
// be picked for THIS (token "03") phase.
writeVerificationMd(dir, '99-VERIFICATION.md', 'gaps_found');
// This phase's own (non-canonical, ad-hoc) report — must win.
writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'passed',
'the phase must report its OWN report\'s status, not a cross-phase stray\'s',
);
} finally {
cleanup(baseDir);
}
});
test('behavioral (readVerificationStatus): a phase with both its own canonical report and an ad-hoc worksheet reports the canonical report\'s status, not missing (#3357 original regression)', () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3357-parent-'));
const dir = path.join(baseDir, '03-canonical-test');
fs.mkdirSync(dir);
try {
// The ad-hoc worksheet has no frontmatter `status:` at all — this is
// the exact original #3357 failure mode: 'C' < 'V' picked this file
// first and the phase read 'missing' despite the passing report sitting
// right next to it.
fs.writeFileSync(
path.join(dir, '03-CORRECTION-VERIFICATION.md'),
'# Ad-hoc correction worksheet\n\nNo frontmatter status here.\n',
);
writeVerificationMd(dir, '03-VERIFICATION.md', 'passed');
const result = readVerificationStatus(dir);
assert.equal(
result.status,
'passed',
'the phase must report the canonical report\'s status, not missing',
);
} finally {
cleanup(baseDir);
}
});
test('behavioral (findStaleVerificationSummary): staleness is checked against THIS phase\'s own report, not a cross-phase stray', () => {
// A stray cross-phase file ("99-VERIFICATION.md") is alphabetically AFTER
// this phase's own "03-VERIFICATION.md", so this also demonstrates the
// pin is not merely riding on alphabetical luck.
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-stale-parent-'));
const dir = path.join(baseDir, '03-stale-pin-test');
fs.mkdirSync(dir);
try {
writeVerificationMd(dir, '03-VERIFICATION.md', 'passed');
writeVerificationMd(dir, '99-VERIFICATION.md', 'passed');
setMtime(path.join(dir, '03-VERIFICATION.md'), '2020-01-01T00:00:00Z');
setMtime(path.join(dir, '99-VERIFICATION.md'), '2020-01-01T00:00:00Z');
// Root-style summary placement (mirrors the #2348 fixtures above) —
// scanPhasePlans's nested-layout matcher requires `SUMMARY-<NN>...md`
// inside a `plans/` subdir; a root-named `03-01-SUMMARY.md` dropped into
// `plans/` matches neither isRootSummaryFile (wrong directory) nor
// isNestedSummaryFile (wrong filename shape), so summaryFiles reads
// empty and the phase is never stale — not what this test means to
// exercise.
const summaryPath = path.join(dir, '03-01-SUMMARY.md');
fs.writeFileSync(summaryPath, '# summary\n');
setMtime(summaryPath, '2021-01-01T00:00:00Z');
// Force the mtime path (no git clock) by injecting an empty resolver —
// mirrors the existing #2348 test pattern elsewhere in this file.
const result = findStaleVerificationSummary(dir, fs, () => new Map());
assert.equal(result.determined, true);
assert.equal(result.stale, true, 'the phase\'s own 03-VERIFICATION.md is older than its summary');
assert.equal(
result.verificationFile,
'03-VERIFICATION.md',
'staleness must be computed against the phase\'s own report, not the cross-phase 99-VERIFICATION.md stray',
);
} finally {
cleanup(baseDir);
}
});
});
// ─── #3473 F2: resolveVerificationFile allowBare option ──────────────────────
//
// 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.
describe('#3473 F2: resolveVerificationFile allowBare option', () => {
test('allowBare defaults to false — a bare-only list returns null without the option', () => {
assert.equal(resolveVerificationFile(['VERIFICATION.md']), null);
});
test('allowBare:true, bare-only candidate → bare file returned', () => {
assert.equal(
resolveVerificationFile(['VERIFICATION.md'], { allowBare: true }),
'VERIFICATION.md',
);
});
test('allowBare:true, bare + non-canonical dashed → the dashed fallback wins', () => {
assert.equal(
resolveVerificationFile(
['VERIFICATION.md', '01-review-VERIFICATION.md'],
{ allowBare: true },
),
'01-review-VERIFICATION.md',
'a dashed non-canonical file names its phase and must win over a bare match',
);
});
test('allowBare:true, bare + canonical → the canonical file wins', () => {
assert.equal(
resolveVerificationFile(
['VERIFICATION.md', '03-VERIFICATION.md'],
{ allowBare: true },
),
'03-VERIFICATION.md',
);
});
});
// ─── #3057 B3: findStaleVerificationSummary — indeterminate vs not-stale ─────
//
// The pre-fix catch-all returned `null` on ANY fs / scanPhasePlans / clock