refactor(#3186): one shared phase-completion predicate, disk-strict (#3306)

* docs(#3186): record the disk-strict completion decision in ADR-3180 7.4

The maintainer decided #2957 on 2026-08-08: disk state is authoritative and a
ROADMAP checkbox is a human annotation with no machine authority. Section 7.4
still carried the OPEN QUESTION and was marked blocked, so the contract said one
thing and the tracker another.

Recorded per section 7's own rule - a behavior not stated there is not decided,
and amending a rule is an ADR amendment rather than a code change with a comment.
The decision comment names Phase 4's PR as the carrier of this edit and makes it
an acceptance criterion that the text be in the tree before implementation
begins, so this lands first, alone, ahead of any code.

Also clears the stale blocked-on-2957 row in the guard roster.

* refactor(#3186): one shared phase-completion predicate, disk-strict

isPhaseComplete in verification.cts becomes the single owner. It calls
readVerificationStatus UNCONDITIONALLY - plan count is not a precondition - so a
zero-plan phase with a passing VERIFICATION.md is complete. That is #3168: init
gated the read on a plan count and synthesized a not_required sentinel, so
phase.complete succeeded while init.manager reported incomplete for the same
phase.

The guard, built and run before scope was fixed per Amendment 3, found 9
re-derivations where the ADR named 3. Four were unnamed, including one in the
prompt layer: mvp-phase.md ORed a ticked checkbox with disk status, which under
disk-strict is the divergence itself.

Per the #2957 decision, a ticked ROADMAP checkbox is a human annotation with no
machine authority. The overrides in roadmap analyze and init manager are deleted
rather than generalized; the user's checkbox stays in ROADMAP.md, only its
authority goes.

scanPhasePlans.completed and buildWorkstreamInventory are deliberately NOT folded
- they answer 'are all plans summarized', which is a different question, and
folding them would either over-report completion or invert the dependency
direction between Phase 1's owner and this one.

Verified on the remote runner.

* fix(#3186): close seven review findings and record the missing-verdict rule

The isolated review reproduced a write-path regression I introduced: migrating
cmdRoadmapUpdatePlanProgress dropped its summaryCount>=planCount gate, so a phase
with a fresh passing verification plus a newly-added unsummarized plan reported
complete AND wrote a checkbox into ROADMAP.md while phase complete refused. The
owner stays right per 7.4 - plan count is not a completion precondition - so the
gate is restored at the write site as an explicit composition, mirroring the
separate 2648 unexecuted-plan gate cmdPhaseComplete already carries.

The spec axis was right that my 0.x-split reasoning was too permissive. The 2957
decision names buildStateFrontmatter as one of the three that must converge, and
buildWorkstreamInventory combined a summaries-met local with verification data to
decide the same verdict - Decision 4(c)'s named bypass, and it reproduced 3168 in
a third surface. Both now route through the owner. The raw scanPhasePlans helper
stays: it answers are-plans-summarized, which genuinely is a different question.

Maintainer decision recorded in 7.4: a missing verdict is not a passing one, so
an absent VERIFICATION.md means not complete everywhere. That retires 2645's
verifier-disabled tolerance and inverts its Goodhart incentive - deleting the
evidence now lowers completion instead of raising it.

Guard hardened: block-form count gates and algebraic restatements are caught, and
the header now discloses its remaining limits instead of overclaiming.

Verified on the remote runner.

* fix(#3186): route state sync through the owner and catch bare completed reads

The matrix found 52 failures. 51 were fixtures asserting the old semantics: a
phase with plans and summaries but no VERIFICATION.md used to count complete and
correctly no longer does. Each fixture now carries a passing verification where
that is what the test was actually about, rather than having its assertion
weakened.

The 52nd was a real 10th re-derivation the guard could not see. cmdStateSync
destructured scanPhasePlans().completed directly - a bare field read, not a
comparison - and used it as a completion verdict, so state sync and state json
disagreed on completed_phases for identical disk state. Routed through the owner.

Guard gains shape (d): any read of .completed off a scanPhasePlans() result
outside plan-scan.cts, in chained, destructured and indirect forms, function
scoped with no line window. It cannot tell a summaries-met read from a completion
read - that is data flow - so it flags every one and requires a written-reason
exemption, which is the same discipline shapes a-c already use. The blind spot is
disclosed in the header rather than overclaimed.

The emitted-attribution failure was also mine, not pre-existing: the mvp-phase.md
checkbox-OR removal moves emitted bytes, acknowledged in tests/emitted-drift-acks.

Verified on the remote runner.

* test(#3186): give the nested-plans sync fixture a passing verification

Last 3 matrix failures were one failure echoing up two describe levels. Phase
01-alpha had plans and summaries but no VERIFICATION.md, so under disk-strict
completed stayed 0 and no Progress change was emitted - correct new behavior, not
a regression.

Added the passing verification rather than dropping the Progress expectation, so
the test still covers what #3257 is about: that a nested plans/ layout is counted
and not undercounted. Probe against the built lib confirms
Progress: 0% -> 50% alongside Total Plans in Phase: 0 -> 3.

* chore(#3186): backfill changeset PR number

pr:0 placeholder replaced with the real number now that #3306 exists.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-10 10:18:29 -04:00
committed by GitHub
parent 96a82bbffb
commit e201cde73c
25 changed files with 2849 additions and 207 deletions

View File

@@ -1115,6 +1115,9 @@ describe('roadmap analyze behavioral correctness (50-phase)', () => {
fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Phase ${i} Plan 1\n`);
if (i <= completedCount) {
fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Phase ${i} Summary\n`);
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes a phase count as complete now.
fs.writeFileSync(path.join(phaseDir, `${pad}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n');
}
}
}

View File

@@ -0,0 +1,627 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
// allow-test-rule: structural-regression-guard, see #3186
// D3 below reads src/plan-scan.cts / gsd-core/bin/lib/plan-scan.cjs and
// src/verification.cts and regex-tests them for require/import statements.
// This asserts a DEPENDENCY-DIRECTION invariant (the owner consumes plan
// counts, never the reverse — ADR-3180 §7.4 HARD CONSTRAINT) that has no
// behavioral/runtime surface: `require`-ing plan-scan.cjs in-process cannot
// distinguish "verification.cjs is absent from its dependency graph" from
// "verification.cjs happens to already be in require.cache because an
// earlier test in this same file required it directly" (line 36 above does
// exactly that) — only source inspection can tell which import edge exists.
// #3186 review finding 6(a).
/**
* Unit + whole-repo coverage for the PHASE-COMPLETION drift guard
* (scripts/lint-completion-predicate-drift.cjs, epic #3180, issue #3186,
* ADR-3180 §7.4, Decision 4). Modelled on tests/milestone-window-drift-guard.test.cjs
* / tests/completion-ratio-single-owner.test.cjs's guard sections: behavioral
* throughout — every assertion drives the guard's exported pure functions
* directly, never `readFileSync().includes()`.
*
* Covers 50-test-matrix.md section E (the guard) plus D3 (the dependency-
* direction guard: plan-scan.cts does not import verification.cts).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const drift = require('../scripts/lint-completion-predicate-drift.cjs');
const { sanitizeForReport } = require('../scripts/lib/drift-scan.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..');
const OWNER_RELPATH = drift.OWNER_FILE; // path.join('src', 'verification.cts')
// ═════════════════════════════════════════════════════════════════════════
// E1 — the real repo tree, post-migration: 0 violations, earned per shape.
// ═════════════════════════════════════════════════════════════════════════
describe('E1 — scanRepo(repoRoot) against the real repo: earned zero', () => {
test('zero violations across the whole scan surface (src/ + prompt layer)', () => {
const violations = drift.scanRepo(ROOT);
assert.deepStrictEqual(
violations,
[],
'unsanctioned phase-completion re-derivation(s) — route through src/verification.cts '
+ '`isPhaseComplete` (issue #3186, ADR-3180 §7.4):\n'
+ violations.map((d) => ` ${d.file}:${d.line} [shape ${d.shape}] ${d.found}`).join('\n'),
);
});
test('per-shape proof: a deliberate (a)/(b)/(c) fixture is NOT silently swallowed by scanRepo', (t) => {
// Distinguishes "0 because nothing to find" from "0 because the detector
// is broken" — scanRepo on a synthetic tree carrying all three shapes
// must report exactly 3, proving the same code path scanRepo(ROOT) took
// is capable of finding violations at all.
const root = createTempDir('gsd-completion-predicate-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(
path.join(root, 'src', 'fake.cts'),
[
'function fakeConsumer(roadmapComplete, planCount, summaryCount) {',
" let status = 'pending';",
' if (roadmapComplete && status !== \'complete\') {',
" status = 'complete';",
' }',
' const verificationStatus = planCount > 0',
' ? readVerificationStatus(phaseDir)',
" : { status: 'not_required' };",
' const done = summaryCount >= planCount && planCount > 0;',
' return { status, verificationStatus, done };',
'}',
].join('\n'),
);
const violations = drift.scanRepo(root);
const shapes = violations.map((v) => v.shape).sort();
assert.deepStrictEqual(shapes, ['a', 'b', 'c']);
});
});
// ═════════════════════════════════════════════════════════════════════════
// E2 — fixture reintroducing shapes (a), (b), (c): each flagged, one hit
// apiece.
// ═════════════════════════════════════════════════════════════════════════
describe('E2 — each shape flagged in isolation, exactly one hit apiece', () => {
test('shape (a): checkbox-derived completion override', () => {
const text = [
'function fakeConsumer(roadmapComplete) {',
" let diskStatus = 'planned';",
' if (roadmapComplete && diskStatus !== \'complete\') {',
" diskStatus = 'complete';",
' }',
' return diskStatus;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'a');
});
test('shape (b): plan-count precondition gating a verification read', () => {
const text = [
'function fakeConsumer(planCount, phaseDir) {',
' return planCount > 0',
' ? readVerificationStatus(phaseDir)',
" : { status: 'not_required' };",
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'b');
});
test('shape (c): local re-implementation of "complete" from counts', () => {
const text = [
'function fakeConsumer(summaryCount, planCount) {',
' return summaryCount >= planCount && planCount > 0;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'c');
});
test('shape (c): the reversed operand order (planCount <= summaryCount) is also flagged', () => {
const text = [
'function fakeConsumer(summaryCount, planCount) {',
' return planCount <= summaryCount;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'c');
});
});
// ═════════════════════════════════════════════════════════════════════════
// E3 — shape (a) with a nested-paren if-condition: flagged. (The first draft
// missed exactly this on cmdInitManager's `(completion.phase_complete ||
// planCount === 0)` nested group.)
// ═════════════════════════════════════════════════════════════════════════
describe('E3 — shape (a): nested-paren if-condition', () => {
test('a second parenthesised group inside the if-condition is still detected', () => {
const text = [
'function fakeConsumer(roadmapComplete, completion, planCount) {',
" let diskStatus = 'planned';",
' if (roadmapComplete && (completion.phase_complete || planCount === 0) && diskStatus !== \'complete\') {',
" diskStatus = 'complete';",
' }',
' return diskStatus;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'a');
});
});
// ═════════════════════════════════════════════════════════════════════════
// E4 — an unconditional readVerificationStatus( call (the cmdPhaseComplete
// shape): NOT flagged.
// ═════════════════════════════════════════════════════════════════════════
describe('E4 — an unconditional readVerificationStatus( call is not flagged', () => {
test('no ternary gate on the call, no count-gate in the function: clean', () => {
const text = [
'function fakeConsumer(phaseDir) {',
' const verificationStatus = readVerificationStatus(phaseDir, { runtime: \'claude\' });',
' return verificationStatus;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.deepStrictEqual(out, []);
});
test('a count-gate present elsewhere in the SAME function but the read is unconditional: still clean', () => {
// Shape (b) requires the readVerificationStatus( call ITSELF to be
// ternary-gated on the same line — a count-gate merely coexisting with
// an unconditional call must not fire.
const text = [
'function fakeConsumer(phaseDir, retryCount) {',
' if (retryCount > 0) { /* retry bookkeeping, unrelated */ }',
' const verificationStatus = readVerificationStatus(phaseDir);',
' return verificationStatus;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.deepStrictEqual(out, []);
});
});
// ═════════════════════════════════════════════════════════════════════════
// E5 — all three shapes inside `//` and `/* */` comments: NOT flagged.
// ═════════════════════════════════════════════════════════════════════════
describe('E5 — commented-out shapes are not flagged', () => {
test('shape (a) inside a // line comment', () => {
const text = [
"// if (roadmapComplete && diskStatus !== 'complete') diskStatus = 'complete';",
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
test('shape (b) inside a /* */ block comment', () => {
const text = [
'/*',
' const v = planCount > 0 ? readVerificationStatus(phaseDir) : { status: "not_required" };',
'*/',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
test('shape (c) inside a JSDoc continuation comment', () => {
const text = [
'/**',
' * e.g. `summaryCount >= planCount && planCount > 0` is the old shape.',
' */',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
});
// ═════════════════════════════════════════════════════════════════════════
// E6 — retryCount > 0 and other unrelated count gates: NOT flagged.
// ═════════════════════════════════════════════════════════════════════════
describe('E6 — unrelated count gates alone are not flagged', () => {
test('retryCount > 0 with no readVerificationStatus( call anywhere in the function', () => {
const text = [
'function fakeRetry(retryCount) {',
' if (retryCount > 0) return doRetry();',
' return doOnce();',
'}',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
test('itemCount > 0 gating an unrelated ternary (no verification call at all)', () => {
const text = [
'function fakeList(itemCount) {',
" return itemCount > 0 ? 'has items' : 'empty';",
'}',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
});
// ═════════════════════════════════════════════════════════════════════════
// E7 — owner file: `isPhaseComplete` exempt BY FUNCTION NAME, never whole-file.
// ═════════════════════════════════════════════════════════════════════════
describe('E7 — owner-file exemption is function-scoped, not file-scoped', () => {
test('the canonical isPhaseComplete body is exempt in the owner file', () => {
const text = [
'function isPhaseComplete(phaseDir, deps) {',
' const verification = readVerificationStatus(phaseDir, deps);',
" return { value: { complete: verification.status === 'passed', verification }, scope: SCOPE.COMPLETE };",
'}',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, OWNER_RELPATH), []);
});
test('a differently-named function in the SAME owner file carrying shape (c) IS flagged', () => {
const text = [
'function isPhaseComplete(phaseDir) { return true; }',
'',
'function someOtherHelper(summaryCount, planCount) {',
' return summaryCount >= planCount && planCount > 0;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, OWNER_RELPATH);
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].fn, 'someOtherHelper');
});
});
// ═════════════════════════════════════════════════════════════════════════
// E8 — a SECOND, unrelated predicate added elsewhere IN THE OWNER FILE:
// flagged (the Amendment-4 blind spot — a whole-file exemption on the owner
// is precisely how a prior guard's owner grew an invisible second copy).
// ═════════════════════════════════════════════════════════════════════════
describe('E8 — a second predicate elsewhere in the owner file is flagged (Amendment-4 blind spot)', () => {
test('shape (a) added to a non-canonical function in src/verification.cts is caught', () => {
const text = [
'function isPhaseComplete(phaseDir) { return true; }',
'',
'function cmdSomeNewVerb(roadmapComplete) {',
" let status = 'pending';",
' if (roadmapComplete && status !== \'complete\') {',
" status = 'complete';",
' }',
' return status;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, OWNER_RELPATH);
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'a');
assert.strictEqual(out[0].fn, 'cmdSomeNewVerb');
});
});
// ═════════════════════════════════════════════════════════════════════════
// E9 — prompt-layer fixture reintroducing the checkbox OR: flagged (the
// scan surface really covers gsd-core/workflows, not just src/).
// ═════════════════════════════════════════════════════════════════════════
describe('E9 — prompt-layer checkbox-OR re-derivation is flagged', () => {
test('findPromptCompletionDrift flags the two-line assign/test pairing', () => {
const lines = [
'PHASE_COMPLETE=$(echo "$PHASE_INFO" | jq -r \'.roadmap_complete // false\')',
'DISK_STATUS=$(echo "$ANALYZE" | jq -r \'.disk_status\')',
'if [[ "$DISK_STATUS" == "complete" || "$PHASE_COMPLETE" == "true" ]]; then',
' STATUS="completed"',
'fi',
].join('\n');
const out = drift.findPromptCompletionDrift(lines, path.join('gsd-core', 'workflows', 'fake.md'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'a');
});
test('scanRepo finds the same fixture when it is a real .md file under gsd-core/workflows', (t) => {
const root = createTempDir('gsd-completion-predicate-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
fs.writeFileSync(
path.join(root, 'gsd-core', 'workflows', 'fake.md'),
[
'PHASE_COMPLETE=$(echo "$PHASE_INFO" | jq -r \'.roadmap_complete // false\')',
'if [[ "$DISK_STATUS" == "complete" || "$PHASE_COMPLETE" == "true" ]]; then',
' STATUS="completed"',
'fi',
].join('\n'),
);
const violations = drift.scanRepo(root);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].shape, 'a');
});
test('a DISK_STATUS-only check (no checkbox OR) is not flagged', () => {
const lines = [
'if [[ "$DISK_STATUS" == "complete" ]]; then',
' STATUS="completed"',
'fi',
].join('\n');
assert.deepStrictEqual(drift.findPromptCompletionDrift(lines, path.join('gsd-core', 'workflows', 'fake.md')), []);
});
});
// ═════════════════════════════════════════════════════════════════════════
// E10 — filename with control bytes / bidi: sanitized in report output.
// ═════════════════════════════════════════════════════════════════════════
describe('E10 — sanitizeForReport neutralizes control bytes and bidi overrides', () => {
test('a C0 control byte in a violation "found" fragment is escaped, not passed through raw', () => {
const raw = 'diskStatus = \x1b[31m\'complete\'\x1b[0m;';
const sanitized = sanitizeForReport(raw);
assert.ok(!sanitized.includes('\x1b'), 'raw ESC byte must not survive sanitization');
assert.ok(sanitized.includes('\\x1b'), 'ESC byte must be rendered as a visible \\xNN escape');
});
test('a bidi right-to-left override codepoint in a reported file path is escaped', () => {
const raw = 'src/‮evil.cts';
const sanitized = sanitizeForReport(raw);
assert.ok(!sanitized.includes('‮'), 'raw RLO codepoint must not survive sanitization');
assert.ok(sanitized.includes('\\u202e'), 'RLO codepoint must be rendered as a visible \\uNNNN escape');
});
});
// ═════════════════════════════════════════════════════════════════════════
// E11 — #3186 review finding 4: two evasion shapes the pre-review guard
// produced ZERO hits on, now caught.
// ═════════════════════════════════════════════════════════════════════════
describe('E11 — finding 4(a): the BLOCK form of shape (b) is now caught', () => {
test('if (planCount > 0) { … readVerificationStatus(…) … } is flagged (was zero hits before)', () => {
const text = [
'function fakeConsumer(planCount, phaseDir) {',
' let verificationStatus = { status: \'not_required\' };',
' if (planCount > 0) {',
' verificationStatus = readVerificationStatus(phaseDir);',
' }',
' return verificationStatus;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'b');
});
test('an unrelated if-block (no count-gate condition) wrapping an unconditional call stays clean', () => {
// A generic nested-brace check (not "is this specifically an if-block
// whose OWN condition is a count-gate") would false-positive here.
const text = [
'function fakeConsumer(phaseDir, flag) {',
' let verificationStatus = null;',
' if (flag) {',
' verificationStatus = readVerificationStatus(phaseDir);',
' }',
' return verificationStatus;',
'}',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
test('a non-conditional wrapper (a callback passed to another function) is NOT treated as gating', () => {
// Regression guard for the naive "any brace nesting deeper than the
// function's own top level = gated" approach, which false-positived on
// cmdPhaseComplete's real `withPlanningLock(cwd, () => { … })` shape:
// an UNCONDITIONAL readVerificationStatus( call wrapped only in a
// callback, with an unrelated count-gate elsewhere in the function.
const text = [
'function fakeConsumer(phaseDir, retryCount) {',
' if (retryCount > 0) { /* unrelated */ }',
' return withPlanningLock(phaseDir, () => {',
' return readVerificationStatus(phaseDir);',
' });',
'}',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
});
describe('E11 — finding 4(b): the algebraic-restatement evasion of shape (c) is now caught', () => {
test('summaryCount - planCount >= 0 is flagged (was zero hits before)', () => {
const text = [
'function fakeConsumer(summaryCount, planCount) {',
' return summaryCount - planCount >= 0;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'c');
});
test('the mirrored form planCount - summaryCount <= 0 is also flagged', () => {
const text = [
'function fakeConsumer(summaryCount, planCount) {',
' return planCount - summaryCount <= 0;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'c');
});
test('an unrelated count-difference comparison (not summary/plan) stays clean', () => {
const text = [
'function fakeConsumer(retryCount, maxCount) {',
' return retryCount - maxCount >= 0;',
'}',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
});
// ═════════════════════════════════════════════════════════════════════════
// E12 — shape (d): a bare `.completed` read off a `scanPhasePlans(` result,
// used as a completion verdict outside the owner (src/plan-scan.cts). The
// #3186 remote-matrix finding: cmdStateSync (src/state.cts, now fixed)
// destructured `scanPhasePlans(dirPath).completed` directly with no
// comparison for shapes (a)/(b)/(c) to catch.
// ═════════════════════════════════════════════════════════════════════════
describe('E12 — shape (d): scanPhasePlans(...).completed read as a completion verdict', () => {
test('direct chained form: scanPhasePlans(dir).completed is flagged', () => {
const text = [
'function cmdSomeVerb(dirPath) {',
' return scanPhasePlans(dirPath).completed;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'd');
assert.strictEqual(out[0].fn, 'cmdSomeVerb');
});
test('direct chained form with a nested-paren call argument is still flagged (path.join(...) inside the call)', () => {
// A naive `[^)]*` regex would stop at path.join(...)'s OWN closing paren
// and miss the `.completed` that follows the call's TRUE closing paren —
// the real shape most scanPhasePlans( call sites in this tree use.
const text = [
'function cmdSomeVerb(phasesDir, dir) {',
' return scanPhasePlans(path.join(phasesDir, dir)).completed;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'd');
});
test('destructured form: const { completed } = scanPhasePlans(dirPath) is flagged (the exact #3186 cmdStateSync shape)', () => {
const text = [
'function cmdStateSyncLike(dirPath) {',
' const { completed } = scanPhasePlans(dirPath);',
' return completed;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'd');
assert.strictEqual(out[0].fn, 'cmdStateSyncLike');
});
test('destructured renamed-alias form: const { completed: isDone } = scanPhasePlans(dirPath) is flagged', () => {
const text = [
'function cmdSomeVerb(dirPath) {',
' const { completed: isDone } = scanPhasePlans(dirPath);',
' return isDone;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'd');
});
test('indirect form: the call and the .completed read sit on DIFFERENT lines in the SAME function — flagged, proving no line window', () => {
const text = [
'function cmdSomeVerb(dirPath) {',
' const scan = scanPhasePlans(dirPath);',
' const summaryCount = scan.summaryFiles.length;',
' const planCount = scan.planFiles.length;',
' // several unrelated lines of bookkeeping in between',
' const x = summaryCount + planCount;',
' const y = x * 2;',
' return scan.completed;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts'));
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'd');
assert.strictEqual(out[0].line, 8);
});
test('a `.completed` read inside src/plan-scan.cts itself (the owner, exempt function) is NOT flagged', () => {
const text = [
'function scanPhasePlans(phaseDir) {',
' const inner = scanPhasePlans(phaseDir);',
' return { completed: inner.completed, extra: true };',
'}',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'plan-scan.cts')), []);
});
test('an exempted function is not flagged, but a DIFFERENT function in the SAME file still is (function-scoped, never whole-file)', () => {
// OWNER_RELPATH (src/verification.cts) has `isPhaseComplete` exempt in
// FUNCTION_SCOPED_EXEMPTIONS — reused here to prove shape (d) shares that
// same per-function map rather than a whole-file allowlist.
const text = [
'function isPhaseComplete(phaseDir) {',
' const scan = scanPhasePlans(phaseDir);',
' return scan.completed;',
'}',
'',
'function cmdSomeNewVerb(phaseDir) {',
' return scanPhasePlans(phaseDir).completed;',
'}',
].join('\n');
const out = drift.findCompletionPredicateDrift(text, OWNER_RELPATH);
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].shape, 'd');
assert.strictEqual(out[0].fn, 'cmdSomeNewVerb');
});
test('an unrelated .completed property on a non-scanPhasePlans object is NOT flagged', () => {
const text = [
'function cmdSomeVerb(job) {',
' const result = someOtherFunction(job);',
' return result.completed;',
'}',
].join('\n');
assert.deepStrictEqual(drift.findCompletionPredicateDrift(text, path.join('src', 'unrelated.cts')), []);
});
test('scanRepo against the real repo tree is capable of finding a deliberate shape (d) fixture (not silently swallowed)', (t) => {
const root = createTempDir('gsd-completion-predicate-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(
path.join(root, 'src', 'fake-shape-d.cts'),
[
'function cmdSomeVerb(dirPath) {',
' const { completed } = scanPhasePlans(dirPath);',
' return completed;',
'}',
].join('\n'),
);
const violations = drift.scanRepo(root);
assert.strictEqual(violations.length, 1);
assert.strictEqual(violations[0].shape, 'd');
});
});
// ═════════════════════════════════════════════════════════════════════════
// D3 — the 0.x-split over-consolidation guard: plan-scan.cts does NOT
// import verification.cts (the owner consumes plan counts, never the
// reverse — ADR-3180 §7.4 HARD CONSTRAINT).
// ═════════════════════════════════════════════════════════════════════════
describe('D3 — dependency direction: plan-scan.cts does not import verification.cts', () => {
test('the compiled plan-scan.cjs source contains no reference to verification.cjs', () => {
const compiled = fs.readFileSync(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'plan-scan.cjs'), 'utf-8');
assert.ok(!/require\(['"]\.\/verification(\.cjs)?['"]\)/.test(compiled), 'plan-scan.cjs must not require verification.cjs');
});
test('the plan-scan.cts source has no import/require of verification.cjs/.cts (a prose comment MENTIONING it, e.g. explaining why a field is not routed through it, is not an import and must not false-positive)', () => {
const source = fs.readFileSync(path.join(ROOT, 'src', 'plan-scan.cts'), 'utf-8');
assert.ok(
!/\b(?:require|import)\s*(?:\(|\{|[A-Za-z_$][\w$]*\s*=)[^;\r\n]*verification\.c(?:j|t)s/.test(source),
'src/plan-scan.cts must not import/require verification.cts/.cjs',
);
});
test('src/verification.cts DOES import plan-scan.cjs (the direction that is allowed: owner consumes counts)', () => {
// Documents the ALLOWED direction so the pair of assertions above reads
// as a genuine one-way constraint, not an accidental total decoupling.
const source = fs.readFileSync(path.join(ROOT, 'src', 'verification.cts'), 'utf-8');
assert.ok(/require\(['"]\.\/plan-scan\.cjs['"]\)/.test(source), 'src/verification.cts is expected to import plan-scan.cjs (for staleness-check summary listing, pre-existing/unrelated to isPhaseComplete)');
});
});

View File

@@ -0,0 +1,6 @@
{
"version": 1,
"paths": {
"mvp-phase.md": "#3186 (epic #3180 Phase 4, ADR-3180 §7.4, disk-strict per #2957 maintainer decision): removes the `PHASE_COMPLETE` ROADMAP-checkbox override this workflow ORed into its completion check (`if [[ \"$DISK_STATUS\" == \"complete\" || \"$PHASE_COMPLETE\" == \"true\" ]]`) — a ticked checkbox is a human annotation with no machine authority, and the prompt layer was one of the (previously undiscovered) sites still trusting it. The `PHASE_COMPLETE=$(...jq -r '.roadmap_complete // false')` assignment line is deleted outright; `DISK_STATUS` alone (already routed through the canonical `isPhaseComplete` owner via `roadmap.analyze`) now decides completion. Growth is a 4-line explanatory comment documenting why the OR was removed and that this is the same predicate the read and write paths share — the deleted assignment line and the shortened `if` condition are smaller than what they replace, so the net +233 bytes is entirely the comment, not new control flow. See ADR-3180 §7.4 Decision 4(d) (the prompt layer is in scope) and the design doc `.gsd/phase/refactor-3186-phase-completion-predicate/40-design.md` row `mvp-phase.md:49`."
}
}

View File

@@ -1062,7 +1062,9 @@ function buildRoadmap(numPhases) {
/**
* Create phase dirs with full plan+summary coverage for the first `count` phases.
* Each dir gets 1 PLAN + 1 SUMMARY so the disk-scan treats them as complete.
* Each dir gets 1 PLAN + 1 SUMMARY + a passing *-VERIFICATION.md so the
* disk-strict predicate (ADR-3180 §7.4, #3186) treats them as complete — a
* summary alone no longer implies completion.
*/
function createPhaseDirs(phasesDir, count) {
for (let i = 1; i <= count; i++) {
@@ -1070,6 +1072,7 @@ function createPhaseDirs(phasesDir, count) {
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');
}
}

View File

@@ -0,0 +1,742 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
// allow-test-rule: source-text-is-the-product, see #3186
// F2 below reads gsd-core/workflows/mvp-phase.md and regex-tests its shell
// content for the disk-strict OR removal (Decision 4(d)). A workflow .md
// file's text IS the deployed prompt-layer artifact the runtime executes —
// there is no runtime API that "runs" mvp-phase.md to observe its shell
// logic behaviorally, so asserting on its source text tests the actual
// deployed contract. #3186 review finding 6(b).
/**
* Phase-completion single-owner tests (epic #3180, issue #3186, ADR-3180
* §7.4, disk-strict per #2957). Covers `.gsd/phase/refactor-3186-phase-
* completion-predicate/50-test-matrix.md` sections A-F:
*
* A — the predicate itself (`src/verification.cts` · `isPhaseComplete`)
* B — disk-strict: the ROADMAP checkbox has no machine authority
* C — identity at each CONSUMER's observable output (Decision 4c)
* D — the 0.x-split: sites answering a DIFFERENT question keep answering it
* F — Tier-2 regression surface
*
* Section E (the drift guard itself) lives in
* tests/completion-predicate-drift-guard.test.cjs.
*
* A1 is the #3168 regression (zero plans + passing `*-VERIFICATION.md` must
* read complete). Verified RED-before/GREEN-after manually against this
* change (git stash the src/ edits, rebuild, and probe `init manager` on
* the exact A1 fixture below): pre-fix it reported
* `{ phase_complete: false, verification_status: 'not_required',
* disk_status: 'empty' }`; post-fix it reports
* `{ phase_complete: true, verification_status: 'passed', disk_status:
* 'complete' }`. That evidence is reported in the implementation PR/summary
* rather than re-run here (a stash/rebuild inside a test body would not be
* hermetic); the assertions below pin the GREEN (post-fix) behavior as a
* permanent regression net.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { isPhaseComplete } = require('../gsd-core/bin/lib/verification.cjs');
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
const { scanPhasePlans } = require('../gsd-core/bin/lib/plan-scan.cjs');
const { buildWorkstreamInventory } = require('../gsd-core/bin/lib/workstream-inventory-builder.cjs');
const { runGsdTools, createTempDir, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Fixture helpers ────────────────────────────────────────────────────────
function writeRoadmap(tmpDir, phases) {
const sections = phases.map((p) => {
let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** ${p.goal || 'Do the thing'}\n`;
if (p.depends_on) section += `**Depends on:** ${p.depends_on}\n`;
return section;
}).join('\n');
const checklist = phases.map((p) => {
const mark = p.complete ? 'x' : ' ';
return `- [${mark}] **Phase ${p.number}: ${p.name}**`;
}).join('\n');
// A real Progress TABLE (gsd-core/templates/roadmap.md shape), not just the
// checklist — cmdRoadmapUpdatePlanProgress writes into this table via the
// markdown-table seam (editProgressTableSlice/updateTableCell), which no-ops
// when no table with these columns exists. G2 (#3186) needs a real table to
// observe the "Plans Complete / Status cells still update, only the
// completion checkbox is withheld" behavior.
const table = [
'| Phase | Plans Complete | Status | Completed |',
'|-------|-----------------|--------|-----------|',
...phases.map((p) => `| ${p.number}. ${p.name} | 0/0 | Not started | - |`),
].join('\n');
const content = `# Roadmap\n\n## Progress\n\n${checklist}\n\n${table}\n\n${sections}`;
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
}
function writeState(tmpDir) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n');
}
function scaffoldPhase(tmpDir, num, opts = {}) {
const padded = String(num).padStart(2, '0');
const slug = opts.slug || 'test-phase';
const dir = path.join(tmpDir, '.planning', 'phases', `${padded}-${slug}`);
fs.mkdirSync(dir, { recursive: true });
if (opts.plans) {
for (let i = 1; i <= opts.plans; i++) {
fs.writeFileSync(path.join(dir, `${padded}-${String(i).padStart(2, '0')}-PLAN.md`), `# Plan ${i}`);
}
}
if (opts.summaries) {
for (let i = 1; i <= opts.summaries; i++) {
fs.writeFileSync(path.join(dir, `${padded}-${String(i).padStart(2, '0')}-SUMMARY.md`), `# Summary ${i}`);
}
}
return dir;
}
function writeVerification(phaseDir, padded, status, filenameOverride) {
const filename = filenameOverride || `${padded}-VERIFICATION.md`;
fs.writeFileSync(path.join(phaseDir, filename), `---\nstatus: ${status}\n---\n# Verification\n`);
}
// ═════════════════════════════════════════════════════════════════════════
// A — isPhaseComplete: the predicate itself
// ═════════════════════════════════════════════════════════════════════════
describe('A — isPhaseComplete: disk-strict, unconditional readVerificationStatus', () => {
test('A1 (#3168 regression): zero plans + passing *-VERIFICATION.md -> complete', (t) => {
const dir = createTempDir('gsd-phase-complete-a1-');
t.after(() => cleanup(dir));
writeVerification(dir, '01', 'passed');
const result = isPhaseComplete(dir);
assert.strictEqual(result.value.complete, true);
assert.strictEqual(result.value.verification.status, 'passed');
assert.strictEqual(result.scope, SCOPE.COMPLETE);
});
test('A2: plans present, all summarized, passing verification -> complete', (t) => {
const dir = createTempDir('gsd-phase-complete-a2-');
t.after(() => cleanup(dir));
fs.writeFileSync(path.join(dir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), '# Summary');
writeVerification(dir, '01', 'passed');
const result = isPhaseComplete(dir);
assert.strictEqual(result.value.complete, true);
});
test('A3: plans present, all summarized, NO verification -> not complete', (t) => {
const dir = createTempDir('gsd-phase-complete-a3-');
t.after(() => cleanup(dir));
fs.writeFileSync(path.join(dir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), '# Summary');
const result = isPhaseComplete(dir);
assert.strictEqual(result.value.complete, false);
assert.strictEqual(result.value.verification.status, 'missing');
});
test('A4: verification present but FAILING -> not complete, distinguishable from absent', (t) => {
const dir = createTempDir('gsd-phase-complete-a4-');
t.after(() => cleanup(dir));
writeVerification(dir, '01', 'gaps_found');
const result = isPhaseComplete(dir);
assert.strictEqual(result.value.complete, false);
assert.strictEqual(result.value.verification.status, 'gaps_found');
assert.notStrictEqual(result.value.verification.status, 'missing');
});
test('A5: zero plans, NO verification -> not complete', (t) => {
const dir = createTempDir('gsd-phase-complete-a5-');
t.after(() => cleanup(dir));
const result = isPhaseComplete(dir);
assert.strictEqual(result.value.complete, false);
assert.strictEqual(result.value.verification.status, 'missing');
});
test('A6: plan count boundary 0/1/2 with passing verification -> complete at every count', (t) => {
for (const planCount of [0, 1, 2]) {
const dir = createTempDir(`gsd-phase-complete-a6-${planCount}-`);
t.after(() => cleanup(dir));
for (let i = 1; i <= planCount; i++) {
fs.writeFileSync(path.join(dir, `01-0${i}-PLAN.md`), `# Plan ${i}`);
fs.writeFileSync(path.join(dir, `01-0${i}-SUMMARY.md`), `# Summary ${i}`);
}
writeVerification(dir, '01', 'passed');
const result = isPhaseComplete(dir);
assert.strictEqual(result.value.complete, true, `planCount=${planCount} must be complete`);
}
});
test('A7: phase dir unreadable -> non-COMPLETE scope, not a false "incomplete"', (t) => {
const dir = createTempDir('gsd-phase-complete-a7-');
t.after(() => cleanup(dir));
// Injected via a fake `deps.fs` (method monkeypatching through the
// function's own dependency-injection seam) rather than chmod 0o000 —
// chmod is bypassed by root/Docker CI and does not exercise the code
// path deterministically.
const fakeFs = {
readdirSync: () => {
throw new Error('EACCES: permission denied, scandir');
},
readFileSync: fs.readFileSync,
statSync: fs.statSync,
};
const result = isPhaseComplete(dir, { fs: fakeFs });
assert.notStrictEqual(result.scope, SCOPE.COMPLETE);
assert.strictEqual(result.scope, SCOPE.UNREADABLE);
});
test('A8: multiple *-VERIFICATION.md files, one passing one failing -> defined, documented verdict', (t) => {
const dir = createTempDir('gsd-phase-complete-a8-');
t.after(() => cleanup(dir));
// readVerificationStatus (which isPhaseComplete wraps) takes the
// lexicographically-FIRST matching filename (`.sort()[0]`) — pin that
// contract here rather than leaving "multiple files" undefined.
writeVerification(dir, '01', 'gaps_found', '01-A-VERIFICATION.md');
writeVerification(dir, '01', 'passed', '01-B-VERIFICATION.md');
const result = isPhaseComplete(dir);
assert.strictEqual(result.value.verification.status, 'gaps_found', '01-A- sorts before 01-B-');
assert.strictEqual(result.value.complete, false);
});
test('A9: CRLF in the verification file -> identical to LF', (t) => {
const dirLf = createTempDir('gsd-phase-complete-a9-lf-');
const dirCrlf = createTempDir('gsd-phase-complete-a9-crlf-');
t.after(() => {
cleanup(dirLf);
cleanup(dirCrlf);
});
fs.writeFileSync(path.join(dirLf, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
fs.writeFileSync(path.join(dirCrlf, '01-VERIFICATION.md'), '---\r\nstatus: passed\r\n---\r\n# Verification\r\n');
const lfResult = isPhaseComplete(dirLf);
const crlfResult = isPhaseComplete(dirCrlf);
assert.strictEqual(lfResult.value.complete, true);
assert.strictEqual(crlfResult.value.complete, true);
assert.strictEqual(crlfResult.value.verification.status, lfResult.value.verification.status);
});
});
// ═════════════════════════════════════════════════════════════════════════
// B — Disk-strict: the checkbox has no machine authority
// ═════════════════════════════════════════════════════════════════════════
describe('B — disk-strict: ROADMAP checkbox carries no machine authority', () => {
function fixture(tmpDir, { checked, plans, summaries, verificationStatus }) {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo', complete: checked }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo', plans, summaries });
if (verificationStatus) writeVerification(dir, '01', verificationStatus);
return dir;
}
test('B1: checkbox ticked, plans outstanding, no verification -> NOT complete (the Tier-2 break)', () => {
const tmpDir = createTempProject();
try {
fixture(tmpDir, { checked: true, plans: 2, summaries: 0 });
const result = runGsdTools('roadmap analyze --raw', tmpDir);
assert.ok(result.success, result.error);
const analyzed = JSON.parse(result.output);
// 2 plans, 0 summaries -> 'planned' (disk_status's own taxonomy: no
// summaries yet means "planned", not "partial" — 'partial' requires
// summaryCount > 0). The load-bearing assertion for the Tier-2 break is
// the notStrictEqual below: the checkbox alone must not read 'complete'.
assert.strictEqual(analyzed.phases[0].disk_status, 'planned');
assert.notStrictEqual(analyzed.phases[0].disk_status, 'complete');
} finally {
cleanup(tmpDir);
}
});
test('B2: checkbox ticked AND verification passing -> complete (checkbox contributed nothing)', () => {
const tmpDir = createTempProject();
try {
fixture(tmpDir, { checked: true, plans: 1, summaries: 1, verificationStatus: 'passed' });
const result = runGsdTools('roadmap analyze --raw', tmpDir);
assert.ok(result.success, result.error);
const analyzed = JSON.parse(result.output);
assert.strictEqual(analyzed.phases[0].disk_status, 'complete');
} finally {
cleanup(tmpDir);
}
});
test('B3: checkbox UNTICKED, verification passing -> complete (disk wins in both directions)', () => {
const tmpDir = createTempProject();
try {
fixture(tmpDir, { checked: false, plans: 1, summaries: 1, verificationStatus: 'passed' });
const result = runGsdTools('roadmap analyze --raw', tmpDir);
assert.ok(result.success, result.error);
const analyzed = JSON.parse(result.output);
assert.strictEqual(analyzed.phases[0].disk_status, 'complete');
assert.strictEqual(analyzed.phases[0].roadmap_complete, false, 'checkbox itself stays unticked/reported');
} finally {
cleanup(tmpDir);
}
});
test('B4: checkbox ticked, verification FAILING -> not complete', () => {
const tmpDir = createTempProject();
try {
fixture(tmpDir, { checked: true, plans: 1, summaries: 1, verificationStatus: 'gaps_found' });
const result = runGsdTools('roadmap analyze --raw', tmpDir);
assert.ok(result.success, result.error);
const analyzed = JSON.parse(result.output);
assert.notStrictEqual(analyzed.phases[0].disk_status, 'complete');
} finally {
cleanup(tmpDir);
}
});
test('B5: ROADMAP.md absent entirely -> the predicate itself is unaffected (isPhaseComplete never reads it)', (t) => {
const dir = createTempDir('gsd-phase-complete-b5-');
t.after(() => cleanup(dir));
writeVerification(dir, '01', 'passed');
// No ROADMAP.md anywhere near `dir` — isPhaseComplete takes a phase
// directory, not a project root, and never touches ROADMAP.md.
const result = isPhaseComplete(dir);
assert.strictEqual(result.value.complete, true);
});
test('B6: a ticked checkbox is NOT deleted from ROADMAP.md — only its authority is removed', () => {
const tmpDir = createTempProject();
try {
fixture(tmpDir, { checked: true, plans: 2, summaries: 0 });
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(before.includes('[x] **Phase 1'), 'fixture sanity: checkbox starts ticked');
const result = runGsdTools('roadmap analyze --raw', tmpDir);
assert.ok(result.success, result.error);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.strictEqual(after, before, 'roadmap analyze is read-only: the human annotation survives untouched');
assert.ok(after.includes('[x] **Phase 1'), 'the ticked checkbox itself is still present');
} finally {
cleanup(tmpDir);
}
});
});
// ═════════════════════════════════════════════════════════════════════════
// C — Identity at each CONSUMER's observable output (Decision 4c)
// ═════════════════════════════════════════════════════════════════════════
describe('C — consumer identity: every reader of "is phase P complete?" agrees', () => {
test('C1: init manager reports complete for the A1 fixture (the #3168 symptom)', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo' }); // zero plans
writeVerification(dir, '01', 'passed');
const result = runGsdTools('init manager --raw', tmpDir);
assert.ok(result.success, result.error);
const output = JSON.parse(result.output);
const phase1 = output.phases.find((p) => p.number === '1' || p.number === '01');
assert.strictEqual(phase1.phase_complete, true);
assert.strictEqual(phase1.verification_status, 'passed');
assert.notStrictEqual(phase1.verification_status, 'not_required');
} finally {
cleanup(tmpDir);
}
});
test('C2: roadmap analyze disk_status matches the owner, with no checkbox arm', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]); // checkbox UNTICKED
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo' });
writeVerification(dir, '01', 'passed');
const owner = isPhaseComplete(dir);
const result = runGsdTools('roadmap analyze --raw', tmpDir);
assert.ok(result.success, result.error);
const analyzed = JSON.parse(result.output);
assert.strictEqual(analyzed.phases[0].disk_status === 'complete', owner.value.complete);
} finally {
cleanup(tmpDir);
}
});
test('C3: phase complete is unchanged — still succeeds exactly when the owner says complete', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo' }); // zero plans, A1 shape
writeVerification(dir, '01', 'passed');
const owner = isPhaseComplete(dir);
assert.strictEqual(owner.value.complete, true);
const result = runGsdTools('phase complete 1 --raw', tmpDir);
assert.ok(result.success, `phase complete must succeed when the owner reports complete: ${result.error}`);
} finally {
cleanup(tmpDir);
}
});
test('C4: roadmap update-plan-progress "complete" field matches the owner', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo', plans: 1, summaries: 1 });
writeVerification(dir, '01', 'gaps_found'); // failing -> owner says not complete
const owner = isPhaseComplete(dir);
assert.strictEqual(owner.value.complete, false);
// No --raw: cmdRoadmapUpdatePlanProgress's output() call carries a
// non-undefined rawValue (a "N/N Status" text fallback), so --raw
// would switch this to plain text instead of JSON (unlike roadmap
// analyze / init manager, whose output() calls pass rawValue:
// undefined and always emit JSON regardless of --raw).
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, result.error);
const output = JSON.parse(result.output);
assert.strictEqual(output.complete, owner.value.complete);
} finally {
cleanup(tmpDir);
}
});
test('C5: cross-consumer — one fixture, init manager AND roadmap analyze report the SAME verdict', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo' }); // zero plans
writeVerification(dir, '01', 'passed');
const initResult = runGsdTools('init manager --raw', tmpDir);
const roadmapResult = runGsdTools('roadmap analyze --raw', tmpDir);
assert.ok(initResult.success, initResult.error);
assert.ok(roadmapResult.success, roadmapResult.error);
const initPhase = JSON.parse(initResult.output).phases.find((p) => p.number === '1' || p.number === '01');
const roadmapPhase = JSON.parse(roadmapResult.output).phases[0];
assert.strictEqual(initPhase.phase_complete, true);
assert.strictEqual(roadmapPhase.disk_status, 'complete');
} finally {
cleanup(tmpDir);
}
});
describe('C6: the §7.4 headline — "phase complete succeeds while init manager reports incomplete" is unrepresentable', () => {
test('agreement case: zero plans + passing verification -> BOTH succeed/report complete', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo' });
writeVerification(dir, '01', 'passed');
const initResult = runGsdTools('init manager --raw', tmpDir);
assert.ok(initResult.success, initResult.error);
const initPhase = JSON.parse(initResult.output).phases.find((p) => p.number === '1' || p.number === '01');
assert.strictEqual(initPhase.phase_complete, true);
const completeResult = runGsdTools('phase complete 1 --raw', tmpDir);
assert.ok(completeResult.success, `phase complete must succeed to agree with init manager: ${completeResult.error}`);
} finally {
cleanup(tmpDir);
}
});
test('agreement case: plans outstanding, no verification -> BOTH report/refuse incomplete', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
scaffoldPhase(tmpDir, 1, { slug: 'foo', plans: 1, summaries: 1 }); // no *-VERIFICATION.md written
const initResult = runGsdTools('init manager --raw', tmpDir);
assert.ok(initResult.success, initResult.error);
const initPhase = JSON.parse(initResult.output).phases.find((p) => p.number === '1' || p.number === '01');
assert.strictEqual(initPhase.phase_complete, false);
const completeResult = runGsdTools('phase complete 1 --raw', tmpDir);
assert.strictEqual(completeResult.success, false, 'phase complete must be BLOCKED to agree with init manager reporting incomplete');
} finally {
cleanup(tmpDir);
}
});
});
});
// ═════════════════════════════════════════════════════════════════════════
// D — the 0.x-split: sites asking a DIFFERENT question keep answering it
// ═════════════════════════════════════════════════════════════════════════
describe('D — the 0.x split: "are plans summarized" stays a different, legitimate answer', () => {
function buildD1Fixture(t) {
// All plans summarized, but NO *-VERIFICATION.md — the exact fixture
// the design's "0.x split" section names as the trap.
const dir = createTempDir('gsd-phase-completion-d1-');
t.after(() => cleanup(dir));
fs.writeFileSync(path.join(dir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), '# Summary');
return dir;
}
test('D1: scanPhasePlans.completed still reports true — "are plans summarized" is a different question', (t) => {
const dir = buildD1Fixture(t);
const scan = scanPhasePlans(dir);
assert.strictEqual(scan.completed, true, 'plan-scan.cts answers "are all plans summarized", not "is the phase complete"');
});
test('D2: the SAME fixture through isPhaseComplete -> NOT complete (the two answers legitimately differ)', (t) => {
const dir = buildD1Fixture(t);
const owner = isPhaseComplete(dir);
assert.strictEqual(owner.value.complete, false);
const scan = scanPhasePlans(dir);
assert.notStrictEqual(scan.completed, owner.value.complete, 'the two derivations must legitimately disagree on this exact fixture');
});
// ADR-3180 §7.4 (#3186 review finding 3, corrected from this phase's own
// design doc): `buildWorkstreamInventory` was ORIGINALLY (wrongly)
// classified as staying on the "different question" side of the 0.x
// split, the same way `scanPhasePlans.completed` legitimately does. Review
// found it reproduced the §7.4 headline case (#3168) in a third surface —
// it combined a local summaries-met derivation with caller-supplied
// verification data to decide the SAME "is phase P complete?" question,
// not a different one. Corrected: the module is a pure, I/O-free
// projection (module header: "No I/O. No async.") that cannot call
// `isPhaseComplete` itself, so per Decision 4(c) the CALLER computes the
// owner's real verdict and passes it in via `PhaseFilesCount.complete` —
// `workstream-inventory.cts` does this with a real `isPhaseComplete` call
// in production. D4 now pins that routing directly.
test('D4: buildWorkstreamInventory on the D1 fixture is NOT complete when the caller supplies the owner\'s real (false) verdict', (t) => {
const dir = buildD1Fixture(t);
const scan = scanPhasePlans(dir);
const owner = isPhaseComplete(dir);
assert.strictEqual(owner.value.complete, false, 'fixture sanity: no *-VERIFICATION.md -> the owner says not complete');
const inventory = buildWorkstreamInventory({
name: 'default',
projectDir: path.dirname(dir),
workstreamDir: path.dirname(dir),
phaseDirNames: ['01-fixture'],
activeWorkstreamName: 'default',
phaseFilesCounts: [
{
directory: '01-fixture',
planCount: scan.planCount,
summaryCount: scan.summaryCount,
// ADR-3180 §7.4 (#3186): the caller-computed owner verdict, NOT a
// local re-derivation from planCount/summaryCount.
complete: owner.value.complete,
},
],
roadmapPhaseCount: 1,
stateProjection: { status: 'in_progress', current_phase: '01', last_activity: null },
filesExist: { roadmap: true, state: true, requirements: false },
});
assert.strictEqual(
inventory.phases[0].status,
'in_progress',
'summaries-met alone no longer resolves complete — the builder routes through the caller-supplied owner verdict (#3186 fix)',
);
});
test('D5: buildWorkstreamInventory reports complete for a zero-plan phase when the caller supplies a true owner verdict (#3168 parity)', (t) => {
const dir = createTempDir('gsd-phase-completion-d5-');
t.after(() => cleanup(dir));
writeVerification(dir, '01', 'passed');
const owner = isPhaseComplete(dir);
assert.strictEqual(owner.value.complete, true);
const inventory = buildWorkstreamInventory({
name: 'default',
projectDir: path.dirname(dir),
workstreamDir: path.dirname(dir),
phaseDirNames: ['01-fixture'],
activeWorkstreamName: 'default',
phaseFilesCounts: [
{ directory: '01-fixture', planCount: 0, summaryCount: 0, complete: owner.value.complete },
],
roadmapPhaseCount: 1,
stateProjection: { status: 'in_progress', current_phase: '01', last_activity: null },
filesExist: { roadmap: true, state: true, requirements: false },
});
assert.strictEqual(
inventory.phases[0].status,
'complete',
'zero plans + a passing verification -> complete, matching isPhaseComplete (#3168) even with planCount 0',
);
});
});
// ═════════════════════════════════════════════════════════════════════════
// F — Tier-2 regression surface
// ═════════════════════════════════════════════════════════════════════════
describe('F — Tier-2 regression surface', () => {
test('F1: a project relying on checkbox-only completion — roadmap analyze stops reporting complete (documented break)', () => {
// Same fixture shape as B1, framed as the Tier-2 regression this phase
// ships deliberately: a downstream project that was relying on a ticked
// checkbox alone (no passing verification, plans outstanding) now sees
// `disk_status` flip away from 'complete'.
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo', complete: true }]);
scaffoldPhase(tmpDir, 1, { slug: 'foo', plans: 3, summaries: 0 });
const result = runGsdTools('roadmap analyze --raw', tmpDir);
assert.ok(result.success, result.error);
const analyzed = JSON.parse(result.output);
assert.notStrictEqual(analyzed.phases[0].disk_status, 'complete');
} finally {
cleanup(tmpDir);
}
});
test('F2: gsd-core/workflows/mvp-phase.md no longer ORs PHASE_COMPLETE with disk status', () => {
const content = fs.readFileSync(
path.join(__dirname, '..', 'gsd-core', 'workflows', 'mvp-phase.md'),
'utf-8',
);
assert.ok(
!/"\$DISK_STATUS"\s*==\s*"complete"\s*\|\|\s*"\$PHASE_COMPLETE"/.test(content),
'the disk-strict OR must be gone from mvp-phase.md',
);
assert.ok(
/if \[\[ "\$DISK_STATUS" == "complete" \]\]; then/.test(content),
'DISK_STATUS alone must decide completion',
);
});
test('F3: init manager never emits the old not_required sentinel for a zero-plan phase (regression net replacing it)', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
scaffoldPhase(tmpDir, 1, { slug: 'foo' }); // zero plans, no verification either
const result = runGsdTools('init manager --raw', tmpDir);
assert.ok(result.success, result.error);
const output = JSON.parse(result.output);
const phase1 = output.phases.find((p) => p.number === '1' || p.number === '01');
assert.notStrictEqual(phase1.verification_status, 'not_required', 'not_required is retired — the owner always reports a real readVerificationStatus verdict');
assert.strictEqual(phase1.verification_status, 'missing');
assert.strictEqual(phase1.phase_complete, false);
} finally {
cleanup(tmpDir);
}
});
});
// ═════════════════════════════════════════════════════════════════════════
// G — write-path plan-coverage gate (#3186 review finding 1, #2648
// precedent). `isPhaseComplete` deliberately has NO plan-count precondition
// (the owner), but `roadmap update-plan-progress` WRITES a checkbox +
// completion date into ROADMAP.md — a stronger claim than "verification
// passed" — and must additionally refuse when a plan has no completion
// record, exactly like `phase complete`'s own #2648 gate. Matrix rows A-F
// never covered "plans added AFTER a still-fresh passing verification";
// this is that row.
// ═════════════════════════════════════════════════════════════════════════
describe('G — write-path plan-coverage gate: a plan added after a still-fresh passing verification', () => {
test('G1: the OWNER (isPhaseComplete) reports complete — no plan-count precondition, by design', () => {
const dir = createTempDir('gsd-phase-completion-g1-');
try {
fs.writeFileSync(path.join(dir, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), '# Summary 1');
writeVerification(dir, '01', 'passed');
// A second plan lands AFTER verification passed — never summarized.
// The verification file is untouched, so its status stays 'passed'
// (readVerificationStatus's staleness check compares only SUMMARY
// mtimes against the verification file, never plan count).
fs.writeFileSync(path.join(dir, '01-02-PLAN.md'), '# Plan 2');
const owner = isPhaseComplete(dir);
assert.strictEqual(owner.value.verification.status, 'passed', 'fixture sanity: verification reads as still-fresh passed');
assert.strictEqual(owner.value.complete, true, 'the owner has no plan-count precondition by design (ADR-3180 §7.4 hard constraint) — this is not the bug');
} finally {
cleanup(dir);
}
});
test('G2: roadmap update-plan-progress REFUSES to write completion for the identical fixture', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo', plans: 1, summaries: 1 });
writeVerification(dir, '01', 'passed');
fs.writeFileSync(path.join(dir, '01-02-PLAN.md'), '# Plan 2'); // outstanding, no summary
const roadmapBefore = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, result.error);
const output = JSON.parse(result.output);
assert.strictEqual(output.complete, false, 'the write site must refuse — an outstanding plan has no completion record (#2648 precedent)');
const roadmapAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmapAfter.includes('[x] **Phase 1'), 'the checkbox must NOT be checked');
assert.ok(!/\(completed \d{4}-\d{2}-\d{2}\)/.test(roadmapAfter), 'no completion date must be stamped');
assert.notStrictEqual(roadmapAfter, roadmapBefore, 'the command still updates the Plans Complete / Status cells — only the completion checkbox is withheld');
} finally {
cleanup(tmpDir);
}
});
test('G3: phase complete agrees — refuses for the identical fixture, same reason class', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo', plans: 1, summaries: 1 });
writeVerification(dir, '01', 'passed');
fs.writeFileSync(path.join(dir, '01-02-PLAN.md'), '# Plan 2');
const updateResult = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(updateResult.success, updateResult.error);
const updateOutput = JSON.parse(updateResult.output);
const completeResult = runGsdTools('phase complete 1 --raw', tmpDir);
assert.strictEqual(completeResult.success, false, 'phase complete must refuse (its own #2648 gate)');
assert.strictEqual(updateOutput.complete, false, 'both write paths agree: incomplete');
} finally {
cleanup(tmpDir);
}
});
test('G4: once the outstanding plan gets its summary, both write paths agree completion proceeds', () => {
const tmpDir = createTempProject();
try {
writeState(tmpDir);
writeRoadmap(tmpDir, [{ number: '1', name: 'Foo' }]);
const dir = scaffoldPhase(tmpDir, 1, { slug: 'foo', plans: 2, summaries: 2 });
writeVerification(dir, '01', 'passed');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, result.error);
const output = JSON.parse(result.output);
assert.strictEqual(output.complete, true, 'no outstanding plan — the gate does not withhold completion');
const roadmapAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(/\(completed \d{4}-\d{2}-\d{2}\)/.test(roadmapAfter), 'completion date IS stamped once plan coverage is satisfied');
} finally {
cleanup(tmpDir);
}
});
});

View File

@@ -5905,6 +5905,15 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
fs.writeFileSync(path.join(phase4Dir, `04-${padded}-PLAN.md`), `plan ${i}`, 'utf8');
fs.writeFileSync(path.join(phase4Dir, `04-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8');
}
// Disk-strict completion (ADR-3180 §7.4, #3186): Phase 4 is already
// shipped per the ROADMAP checklist/table above — a passing
// *-VERIFICATION.md is what actually makes it count as complete now
// (runSdkQuery's writePassedVerificationForPhase only covers the phase
// under test, phase 5, not this already-complete phase 4 fixture).
fs.writeFileSync(
path.join(phase4Dir, '04-VERIFICATION.md'),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
);
const phase6Dir = path.join(phasesDir, '06-integration');
fs.mkdirSync(phase6Dir, { recursive: true });

View File

@@ -261,6 +261,9 @@ describe('roadmap analyze command', () => {
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes phase 1 count as complete now.
fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const p2 = path.join(tmpDir, '.planning', 'phases', '02-authentication');
fs.mkdirSync(p2, { recursive: true });

View File

@@ -2054,6 +2054,11 @@ describe('milestone-scoped phase counting in frontmatter', () => {
// Add a plan to each
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
// Disk-strict completion (ADR-3180 §7.4, #3186): a plan+summary count no
// longer implies "complete" — only a passing *-VERIFICATION.md does. Both
// milestone phases (5 and 6) get one so this test still exercises milestone
// SCOPING, not the (now-separate) completion predicate.
if (i === 5 || i === 6) writePassedVerification(tmpDir, `${padded}-phase-${i}`, padded);
}
// Write a STATE.md and trigger a write that will sync frontmatter
@@ -2071,7 +2076,7 @@ describe('milestone-scoped phase counting in frontmatter', () => {
const output = JSON.parse(jsonResult.output);
assert.strictEqual(Number(output.progress.total_phases), 2, 'should count only milestone phases (5 and 6), not all 6');
assert.strictEqual(Number(output.progress.completed_phases), 2, 'both milestone phases have summaries');
assert.strictEqual(Number(output.progress.completed_phases), 2, 'both milestone phases have a passing verification');
});
test('total_phases includes ROADMAP phases without directories', () => {
@@ -2097,6 +2102,10 @@ describe('milestone-scoped phase counting in frontmatter', () => {
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
// Disk-strict completion (ADR-3180 §7.4, #3186): give each of the 4
// phases-with-directories a passing verification so this test still
// exercises ROADMAP-vs-disk SCOPING, not the completion predicate.
writePassedVerification(tmpDir, `${padded}-phase-${i}`, padded);
}
fs.writeFileSync(
@@ -2112,7 +2121,7 @@ describe('milestone-scoped phase counting in frontmatter', () => {
const output = JSON.parse(jsonResult.output);
assert.strictEqual(Number(output.progress.total_phases), 6, 'should count all 6 ROADMAP phases, not just 4 with directories');
assert.strictEqual(Number(output.progress.completed_phases), 4, 'only 4 phases have summaries');
assert.strictEqual(Number(output.progress.completed_phases), 4, 'only 4 phases have a passing verification');
});
test('without ROADMAP counts all phases (pass-all filter)', () => {
@@ -2339,6 +2348,12 @@ describe('progress counters correct after plan execution (#1589)', () => {
fs.writeFileSync(path.join(phase02Dir, '02-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phase02Dir, '02-02-SUMMARY.md'), '# Summary\n');
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing *-VERIFICATION.md
// is what makes a phase complete now, not plan/summary parity — this test is
// about percent DERIVATION, so give both phases one.
writePassedVerification(tmpDir, '01-foundation', '01');
writePassedVerification(tmpDir, '02-api', '02');
// Body Progress: still says 0% (stale — never updated by update-progress)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
@@ -2416,6 +2431,14 @@ describe('progress counters correct after plan execution (#1589)', () => {
fs.writeFileSync(path.join(phase04Dir, '04-02-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phase04Dir, '04-02-SUMMARY.md'), '# Summary\n');
// Disk-strict completion (ADR-3180 §7.4, #3186): give all 4 phases a
// passing verification so this test still exercises the STALE-FRONTMATTER
// rebuild, not the (now-separate) completion predicate.
writePassedVerification(tmpDir, '01-phase', '01');
writePassedVerification(tmpDir, '02-phase', '02');
writePassedVerification(tmpDir, '03-phase', '03');
writePassedVerification(tmpDir, '04-phase', '04');
// Write STATE.md with stale frontmatter matching the bug report exactly
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
@@ -9973,6 +9996,10 @@ describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
fs.writeFileSync(path.join(plansDir, planFile), '# Plan\n');
fs.writeFileSync(path.join(plansDir, summaryFile), '# Summary\n');
}
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes a phase complete, not plan/summary
// parity — this test is about nested plans/ COUNTING, not the predicate.
writePassedVerification(tmpDir, phaseSlug, `0${phase}`);
}
writeRoadmap(tmpDir, [1, 2]);
@@ -9987,7 +10014,7 @@ describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 6, 'total_plans must count nested plans/ files (2 phases × 3 plans)');
assert.strictEqual(Number(progress.completed_plans), 6, 'completed_plans must count nested summary files (2 phases × 3 summaries)');
assert.strictEqual(Number(progress.completed_phases), 2, 'completed_phases: both phases have summaries >= plans');
assert.strictEqual(Number(progress.completed_phases), 2, 'completed_phases: both phases have a passing verification');
});
test('counts PLAN-NN-slug form (bare PLAN- prefix, no phase prefix)', () => {
@@ -10025,6 +10052,9 @@ describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
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');
// Disk-strict completion (ADR-3180 §7.4, #3186): only a passing
// *-VERIFICATION.md makes the phase complete now.
writePassedVerification(tmpDir, '01-init', '01');
writeRoadmap(tmpDir, [1]);
writeStateFile(tmpDir, { phase: '01' });
@@ -10038,7 +10068,7 @@ describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 2, 'flat layout: top-level *-PLAN.md files counted');
assert.strictEqual(Number(progress.completed_plans), 2, 'flat layout: top-level *-SUMMARY.md files counted');
assert.strictEqual(Number(progress.completed_phases), 1, 'flat layout: phase complete when summaries >= plans');
assert.strictEqual(Number(progress.completed_phases), 1, 'flat layout: phase complete with a passing verification');
});
test('no double-count when both top-level and nested plan files coexist', () => {
@@ -10081,6 +10111,9 @@ describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
// One top-level plan
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
// Disk-strict completion (ADR-3180 §7.4, #3186): only a passing
// *-VERIFICATION.md makes the phase complete now.
writePassedVerification(tmpDir, '01-init', '01');
writeRoadmap(tmpDir, [1]);
writeStateFile(tmpDir, { phase: '01' });
@@ -10094,7 +10127,7 @@ describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 1, 'empty plans/ must not add phantom plan count');
assert.strictEqual(Number(progress.completed_plans), 1, 'empty plans/ must not affect summary count');
assert.strictEqual(Number(progress.completed_phases), 1, 'phase complete: 1 summary >= 1 plan');
assert.strictEqual(Number(progress.completed_phases), 1, 'phase complete with a passing verification');
});
test('PLAN-OUTLINE.md files are excluded from nested plan count', () => {
@@ -10168,6 +10201,9 @@ describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
fs.writeFileSync(path.join(plansDir, `${num}-PLAN-${pad}-task${p}.md`), '# Plan\n');
fs.writeFileSync(path.join(plansDir, `${num}-SUMMARY-${pad}-task${p}.md`), '# Summary\n');
}
// Disk-strict completion (ADR-3180 §7.4, #3186): only a passing
// *-VERIFICATION.md makes a phase complete now.
writePassedVerification(tmpDir, `0${num}-phase-${num}`, `0${num}`);
}
writeRoadmap(tmpDir, [1, 2]);
@@ -10182,7 +10218,7 @@ describe('buildStateFrontmatter nested plans/ layout (#3257)', () => {
const progress = JSON.parse(jsonResult.output).progress;
assert.strictEqual(Number(progress.total_plans), 7, 'reporter scenario: total_plans must be 7');
assert.strictEqual(Number(progress.completed_plans), 7, 'reporter scenario: completed_plans must be 7');
assert.strictEqual(Number(progress.completed_phases), 2, 'reporter scenario: both phases complete');
assert.strictEqual(Number(progress.completed_phases), 2, 'reporter scenario: both phases have a passing verification');
assert.strictEqual(Number(progress.percent), 100, 'reporter scenario: 100% when all plans have summaries');
});
});
@@ -10419,6 +10455,13 @@ describe('cmdStateSync nested plans/ layout (#3257)', () => {
fs.writeFileSync(path.join(plansDir, `1-SUMMARY-${pad}-t.md`), '# Summary\n');
}
}
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing *-VERIFICATION.md
// is what makes a phase complete now, not plan/summary counts alone. Phase
// 01-alpha is the one this test intends to be "complete" (fully planned and
// summarized), so give it a passing verification too — otherwise completed
// phases = 0 and no Progress change is emitted, which isn't what this test
// (nested plans/ summing correctly) is about.
writePassedVerification(tmpDir, '01-alpha', '01');
const stateContent = [
'# Project State',
@@ -10692,6 +10735,10 @@ describe('buildStateFrontmatter cache invalidation (#1967)', () => {
fs.mkdirSync(phase2);
fs.writeFileSync(path.join(phase2, '02-1-PLAN.md'), '---\nphase: 2\nplan: 1\n---\n# Plan\n');
fs.writeFileSync(path.join(phase2, '02-1-SUMMARY.md'), '---\nstatus: complete\n---\n# Summary\n');
// Disk-strict completion (ADR-3180 §7.4, #3186): only a passing
// *-VERIFICATION.md makes a phase complete now — this test is about CACHE
// invalidation, not the completion predicate, so give phase 2 one.
fs.writeFileSync(path.join(phase2, '02-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
// Second write in the SAME process — must see the new phase
const content2 = fs.readFileSync(statePath, 'utf-8');
@@ -11551,9 +11598,12 @@ describe('bug #1446 — state sync writes corrected (lower) total_phases', () =>
const dir = path.join(planning, 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
// Mark 01 and 02 as complete (2 summaries)
// Mark 01 and 02 as complete: disk-strict completion (ADR-3180 §7.4,
// #3186) requires a passing *-VERIFICATION.md, not just a summary — a
// summary alone no longer implies completion.
if (d !== '03-gamma') {
fs.writeFileSync(path.join(dir, 'PLAN-SUMMARY.md'), '# Summary\n', 'utf-8');
fs.writeFileSync(path.join(dir, `${d.slice(0, 2)}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n', 'utf-8');
}
}
});
@@ -11576,10 +11626,14 @@ describe('bug #1446 — state sync writes corrected (lower) total_phases', () =>
3,
`total_phases must be corrected to 3 (derived), not kept at 10 (stale). Got ${state.progress.total_phases}`,
);
// completed_phases ratchet still works: existing 2 ≥ disk-derived → keep 2
assert.ok(
state.progress.completed_phases >= 2,
`completed_phases must be at least 2 (ratchet). Got ${state.progress.completed_phases}`,
// Disk-strict (ADR-3180 §7.4, #3186): completed_phases is recomputed from
// disk on every sync (resync=true bypasses the curated-progress ratchet),
// so it must equal the number of phases with a passing *-VERIFICATION.md
// (01 and 02), not a preserved/ratcheted stale frontmatter value.
assert.equal(
state.progress.completed_phases,
2,
`completed_phases must be 2 (01 and 02 have a passing verification). Got ${state.progress.completed_phases}`,
);
});
});
@@ -11663,6 +11717,11 @@ function seedProject(prefix, roadmap, completeDirs) {
if (completeDirs.includes(d)) {
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes a phase complete now, not a summary
// alone — this suite is about RETIRED-phase exclusion, not the
// completion predicate itself.
fs.writeFileSync(path.join(dir, `${d}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n', 'utf-8');
}
}
return tmpDir;
@@ -11800,6 +11859,9 @@ function seedFromSpecs(prefix, specs) {
if (s.shipped) {
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
// Disk-strict completion (ADR-3180 §7.4, #3186): only a passing
// *-VERIFICATION.md makes a phase complete now.
fs.writeFileSync(path.join(dir, `${s.dir}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n', 'utf-8');
}
}
return tmpDir;
@@ -11889,6 +11951,9 @@ describe('bug #1514 — retired exclusion across phase shapes', () => {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
// Disk-strict completion (ADR-3180 §7.4, #3186): only a passing
// *-VERIFICATION.md makes a phase complete now.
fs.writeFileSync(path.join(dir, `${d}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n', 'utf-8');
}
tmpDir = tmp;
const result = runGsdTools(['state', 'json'], tmpDir);

View File

@@ -147,14 +147,22 @@ describe('#2562 — progress/status scoped to the current milestone (derived fro
};
// ── Defect 3: verification-gated completeness (builder unit) ─────────────────
// ADR-3180 §7.4 (#3186 review finding 3): `complete` is now the CALLER-
// computed owner verdict (`isPhaseComplete`), passed in per phase via
// `PhaseFilesCount.complete` — the builder no longer re-derives it from
// `verificationStatus` + counts. These builder-unit fixtures pass
// `complete` directly (mirroring what `workstream-inventory.cts` computes
// from a real `isPhaseComplete(phaseDir)` call in production); `verificationStatus`
// stays on the fixture only because the `PhaseFilesCount` type still carries
// it (informational, unconsumed by `status`).
test('builder: SUMMARY≥PLAN but a human_needed verdict is NOT complete', () => {
const inv = buildWorkstreamInventory({
...BUILDER_BASE,
name: 'ws',
phaseDirNames: ['1-a', '2-b'],
phaseFilesCounts: [
{ directory: '1-a', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed' },
{ directory: '2-b', planCount: 4, summaryCount: 4, inMilestone: true, verificationStatus: 'human_needed' },
{ directory: '1-a', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed', complete: true },
{ directory: '2-b', planCount: 4, summaryCount: 4, inMilestone: true, verificationStatus: 'human_needed', complete: false },
],
roadmapPhaseCount: 2,
currentMilestonePhaseCount: 2,
@@ -164,19 +172,25 @@ describe('#2562 — progress/status scoped to the current milestone (derived fro
assert.equal(inv.progress_percent, 50);
});
test('builder: missing/unknown verdict still counts complete (no verifier-off regression)', () => {
// ADR-3180 §7.4 (#3186 review finding 3): disk-strict retires this
// tolerance. `isPhaseComplete` requires `verification.status === 'passed'`
// UNCONDITIONALLY — a 'missing' verdict (no `*-VERIFICATION.md`, e.g. a
// verifier-disabled project) is never complete, matching `roadmap analyze`
// / `init manager` / `phase complete` exactly. Disclosed in this phase's
// changeset.
test('builder: a missing verdict is NOT complete (verifier-off tolerance retired, disk-strict)', () => {
const inv = buildWorkstreamInventory({
...BUILDER_BASE,
name: 'ws',
phaseDirNames: ['1-a'],
phaseFilesCounts: [
{ directory: '1-a', planCount: 2, summaryCount: 2, inMilestone: true, verificationStatus: 'missing' },
{ directory: '1-a', planCount: 2, summaryCount: 2, inMilestone: true, verificationStatus: 'missing', complete: false },
],
roadmapPhaseCount: 1,
currentMilestonePhaseCount: 1,
});
assert.equal(inv.phases[0].status, 'complete');
assert.equal(inv.progress_percent, 100);
assert.equal(inv.phases[0].status, 'in_progress');
assert.equal(inv.progress_percent, 0);
});
// ── Defect 2: denominator includes declared-but-unscaffolded phases (builder) ─
@@ -186,8 +200,8 @@ describe('#2562 — progress/status scoped to the current milestone (derived fro
name: 'ws',
phaseDirNames: ['1-a', '2-b'], // phase 3 declared for the milestone but never scaffolded
phaseFilesCounts: [
{ directory: '1-a', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed' },
{ directory: '2-b', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed' },
{ directory: '1-a', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed', complete: true },
{ directory: '2-b', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed', complete: true },
],
roadmapPhaseCount: 2,
currentMilestonePhaseCount: 3,
@@ -204,8 +218,8 @@ describe('#2562 — progress/status scoped to the current milestone (derived fro
name: 'ws',
phaseDirNames: ['1-old', '2-cur'],
phaseFilesCounts: [
{ directory: '1-old', planCount: 3, summaryCount: 3, inMilestone: false, verificationStatus: 'passed' },
{ directory: '2-cur', planCount: 2, summaryCount: 0, inMilestone: true, verificationStatus: 'missing' },
{ directory: '1-old', planCount: 3, summaryCount: 3, inMilestone: false, verificationStatus: 'passed', complete: true },
{ directory: '2-cur', planCount: 2, summaryCount: 0, inMilestone: true, verificationStatus: 'missing', complete: false },
],
roadmapPhaseCount: 2,
currentMilestonePhaseCount: 1,
@@ -678,18 +692,21 @@ describe('#2562 — milestone scoping boundaries (one phase-key derivation)', ()
milestoneShipped: false,
phaseDirNames: ['1-a', '2-b', '3-c'],
phaseFilesCounts: [
{ directory: '1-a', phaseKey: '01', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed' },
{ directory: '2-b', phaseKey: '02', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed' },
{ directory: '3-c', phaseKey: '03', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed' },
{ directory: '1-a', phaseKey: '01', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed', complete: true },
{ directory: '2-b', phaseKey: '02', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed', complete: true },
{ directory: '3-c', phaseKey: '03', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: 'passed', complete: true },
],
roadmapPhaseCount: 3,
currentMilestonePhaseCount: 2,
}), /invariant violated/);
});
// The builder hand-lists the verdicts that disqualify a phase from `complete`.
// Pin it to the verifier's own vocabulary so a new emitted status cannot land
// without a decision here.
// ADR-3180 §7.4 (#3186 review finding 3): the builder no longer hand-lists
// disqualifying verdicts itself (`FAILING_VERIFICATION_STATUSES` is
// retired) — it trusts the caller-supplied `complete` boolean entirely.
// The vocabulary pin now lives at the OWNER (`isPhaseComplete`,
// `complete: verification.status === 'passed'`), mirrored here for every
// verifier status other than 'passed'.
test('parity: every verifier status other than passed blocks completeness', () => {
const nonPassing = VERIFIER_STATUSES.filter(s => s !== 'passed');
assert.ok(nonPassing.length > 0, 'guard: the verifier must emit a non-passing status');
@@ -704,7 +721,7 @@ describe('#2562 — milestone scoping boundaries (one phase-key derivation)', ()
milestoneShipped: false,
phaseDirNames: ['1-a'],
phaseFilesCounts: [
{ directory: '1-a', phaseKey: '01', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: status },
{ directory: '1-a', phaseKey: '01', planCount: 1, summaryCount: 1, inMilestone: true, verificationStatus: status, complete: status === 'passed' },
],
roadmapPhaseCount: 1,
currentMilestonePhaseCount: 1,
@@ -1089,9 +1106,15 @@ describe('#2645 — deleting a verification report must not raise completeness',
assert.equal(after.progress_percent, 0);
});
// Row 3 — criterion 2: verifier-disabled projects (no report ever written)
// must still be able to reach 100%.
test('a phase that was never verified still reaches complete (#2645, criterion 2)', () => {
// Row 3 — SUPERSEDED by ADR-3180 §7.4 (#3186, disk-strict): #2645's
// criterion 2 ("verifier-disabled projects must still reach 100%") is
// exactly the site-local tolerance disk-strict retires. `complete` now
// routes through the single canonical owner (`isPhaseComplete`), which
// requires `verification.status === 'passed'` UNCONDITIONALLY — a phase
// with NO `*-VERIFICATION.md` reads 'missing', never complete, regardless
// of how many plans it has summarized. Disclosed in this phase's
// changeset.
test('a phase that was never verified is NOT complete (disk-strict; #2645 criterion 2 retired)', () => {
const wsDir = seedWorkstream(tmpDir, { name: 'ws-2645-never' });
fs.writeFileSync(path.join(wsDir, 'STATE.md'), FLAT_STATE);
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), flatRoadmap([
@@ -1101,15 +1124,15 @@ describe('#2645 — deleting a verification report must not raise completeness',
const inv = inspectWorkstream(tmpDir, 'ws-2645-never', { active: null });
assert.ok(inv);
assert.equal(inv.phases[0].status, 'complete');
assert.equal(inv.completed_phases, 1);
assert.equal(inv.progress_percent, 100);
assert.equal(inv.phases[0].status, 'in_progress');
assert.equal(inv.completed_phases, 0);
assert.equal(inv.progress_percent, 0);
});
// Row 4 — criterion 3: same on-disk shape as row 3 (no file), across TWO
// reads, must behave IDENTICALLY — the ledger must not newly gate a phase
// that was simply never verified in the first place.
test('a not-yet-verified phase is not newly gated by the ledger (#2645, criterion 3)', () => {
// Row 4 — SUPERSEDED by ADR-3180 §7.4: same on-disk shape as row 3 (no
// file), across TWO reads — disk-strict requires this to behave
// IDENTICALLY (never complete) both times, not just consistently.
test('a not-yet-verified phase reads NOT complete on every read (disk-strict; #2645 criterion 3 retired)', () => {
const wsDir = seedWorkstream(tmpDir, { name: 'ws-2645-not-yet' });
fs.writeFileSync(path.join(wsDir, 'STATE.md'), FLAT_STATE);
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), flatRoadmap([
@@ -1119,14 +1142,21 @@ describe('#2645 — deleting a verification report must not raise completeness',
const first = inspectWorkstream(tmpDir, 'ws-2645-not-yet', { active: null });
const second = inspectWorkstream(tmpDir, 'ws-2645-not-yet', { active: null });
assert.equal(first.progress_percent, 100);
assert.equal(second.progress_percent, 100, 'a second read must not change the outcome');
assert.equal(second.phases[0].status, 'complete');
assert.equal(first.progress_percent, 0);
assert.equal(second.progress_percent, 0, 'a second read must not change the outcome');
assert.equal(second.phases[0].status, 'in_progress');
});
// Row 5 — recovery: a genuinely re-verified phase must not be pinned by an
// earlier failing verdict the ledger remembers.
test('a re-verified passed phase is not pinned by an earlier failing ledger entry (#2645)', () => {
// Row 5 — the FIRST half (a genuine re-verify counts) is unchanged. The
// SECOND half is SUPERSEDED by ADR-3180 §7.4: `isPhaseComplete` reads
// fresh off disk, UNCONDITIONALLY, with no memory — the ledger's "hold the
// newest real verdict after the file is deleted" behavior is structurally
// incompatible with a single owner that never consults a ledger. Deleting
// ANY `*-VERIFICATION.md` (passing or failing) now uniformly reads
// 'missing' → not complete, matching `roadmap analyze` / `init manager` /
// `phase complete` for the identical disk state. Disclosed in this
// phase's changeset.
test('a re-verified passed phase counts complete; deleting the report afterward is NOT complete (disk-strict; #2645 memory retired)', () => {
const wsDir = seedWorkstream(tmpDir, { name: 'ws-2645-recover' });
fs.writeFileSync(path.join(wsDir, 'STATE.md'), FLAT_STATE);
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), flatRoadmap([
@@ -1140,12 +1170,14 @@ describe('#2645 — deleting a verification report must not raise completeness',
const reverified = inspectWorkstream(tmpDir, 'ws-2645-recover', { active: null });
assert.equal(reverified.phases[0].status, 'complete', 'a genuine re-verify must count');
// Delete the now-passing report — the ledger's newest real verdict is
// 'passed', which is not in the failing set, so this must stay complete.
// Delete the now-passing report — the owner reads fresh off disk every
// time; no ledger memory feeds into `complete` anymore, so this reads
// 'missing' → not complete, exactly like every other disk-strict
// consumer for the same disk state.
fs.unlinkSync(verificationFilePath(wsDir, '1-foo'));
const afterDelete = inspectWorkstream(tmpDir, 'ws-2645-recover', { active: null });
assert.equal(afterDelete.phases[0].status, 'complete',
'the ledger must hold the newest verdict, not an earlier failing one');
assert.equal(afterDelete.phases[0].status, 'in_progress',
'disk-strict: a deleted verification file is never complete, regardless of what was previously observed');
});
// Row 6 — criterion 4: the ledger must not live inside the phase directory
@@ -1171,10 +1203,17 @@ describe('#2645 — deleting a verification report must not raise completeness',
assert.ok(fs.existsSync(ledgerPath), 'removing the phase directory must not remove the ledger');
});
// Row 7 — Bug #2445 dedup safety: a stale duplicate directory sharing the
// same phase key must not be able to clobber the WINNING (newest)
// directory's ledger entry with its own stale/leftover verdict.
test('a stale duplicate directory cannot clobber the ledger entry of the live directory (#2645)', () => {
// Row 7 — Bug #2445 dedup safety (winner SELECTION, still real): a stale
// duplicate directory sharing the same phase key must never be the one
// whose live verdict the rollup counts. SUPERSEDED for the DELETION half
// by ADR-3180 §7.4: `isPhaseComplete` reads the WINNING directory fresh
// off disk on every call, unconditionally — once its report is deleted,
// the winner's own live read is 'missing', so this phase key correctly
// stops counting as complete (no ledger memory left to "remember" the
// pre-deletion 'passed'). The winner-selection guarantee itself (the stale
// duplicate's gaps_found never contaminates the live directory's result)
// still holds and is still what this test pins.
test('a stale duplicate directory never contaminates the live directory (winner selection; #2645 memory retired)', () => {
const wsDir = seedWorkstream(tmpDir, { name: 'ws-2645-dupe' });
fs.writeFileSync(path.join(wsDir, 'STATE.md'), FLAT_STATE);
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), flatRoadmap([
@@ -1192,31 +1231,27 @@ describe('#2645 — deleting a verification report must not raise completeness',
const before7 = inspectWorkstream(tmpDir, 'ws-2645-dupe', { active: null });
assert.ok(before7);
assert.equal(before7.completed_phases, 1, 'the winning (newest) directory is passed');
assert.equal(before7.completed_phases, 1, 'the winning (newest) directory is passed — the stale duplicate never won selection');
// Delete the WINNING directory's report. If the stale directory's
// gaps_found had clobbered the ledger, this phase key would now
// incorrectly read gaps_found and never recover. It must instead read
// the winning directory's own remembered 'passed'.
// Delete the WINNING directory's report. Disk-strict: the winner's own
// live read is now 'missing', so this phase key stops counting complete
// — but it must NOT flip to the stale duplicate's gaps_found either
// (that would be a DIFFERENT bug: the stale directory winning selection).
fs.unlinkSync(path.join(liveDir, '01-VERIFICATION.md'));
const after7 = inspectWorkstream(tmpDir, 'ws-2645-dupe', { active: null });
assert.ok(after7);
assert.equal(after7.completed_phases, 1,
"the ledger must remember the WINNING directory's passed verdict, not the stale duplicate's gaps_found");
assert.equal(after7.completed_phases, 0,
'disk-strict: the winning directory\'s own deleted report is not complete — no ledger memory papers over it');
assert.equal(after7.phases.find(p => p.directory === '1-foo').status, 'in_progress');
});
// Row 8 — boundary: an EXACT mtime tie between two same-keyed directories.
// The builder's own tie-break (`rollupDirByKey`) walks
// `[...phaseDirNames].sort()` and keeps the incumbent on a tie
// (first-in-sort-order wins, since only a STRICTLY newer mtime replaces
// it). The ledger's winner selection must walk the SAME sorted order so
// the two deterministically agree on which directory wins — not merely
// "some directory wins" (a prior version of this test asserted only
// `completed_phases === 0 || 1`, which is true regardless of agreement and
// caught nothing; both `01-foo-a`/`01-foo-b` sort deterministically, so the
// outcome here is not a coin flip).
test('an exact mtime tie resolves the ledger winner by sort order, matching the builder (#2645)', () => {
// it). SUPERSEDED for the deletion half by ADR-3180 §7.4 — see Row 7.
test('an exact mtime tie resolves the winner by sort order, matching the builder (#2645 memory retired)', () => {
const wsDir = seedWorkstream(tmpDir, { name: 'ws-2645-tie' });
fs.writeFileSync(path.join(wsDir, 'STATE.md'), FLAT_STATE);
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), flatRoadmap([
@@ -1224,8 +1259,8 @@ describe('#2645 — deleting a verification report must not raise completeness',
]));
const tieTime = new Date('2025-06-01T00:00:00Z');
// '01-foo-a' sorts before '01-foo-b' — with equal mtimes, BOTH the
// builder's rollup and the ledger's winner selection must keep the
// incumbent '01-foo-a' (passed), never adopt '01-foo-b' (gaps_found).
// builder's rollup and the winner selection must keep the incumbent
// '01-foo-a' (passed), never adopt '01-foo-b' (gaps_found).
const dirA = writePhase(wsDir, '01-foo-a', { plans: 1, summaries: 1, verification: 'passed' });
fs.utimesSync(dirA, tieTime, tieTime);
const dirB = writePhase(wsDir, '01-foo-b', { plans: 1, summaries: 1, verification: 'gaps_found' });
@@ -1239,44 +1274,29 @@ describe('#2645 — deleting a verification report must not raise completeness',
assert.equal(inv.completed_phases, 1,
'the sort-order incumbent (01-foo-a, passed) must be the one the builder counts complete');
// Concrete cross-check that the LEDGER's winner selection agrees with the
// builder's, not just that this run's numbers happen to match: delete
// 01-foo-a's report (unlink bumps the directory's mtime — restore it to
// the exact tie value so the SECOND read still sees a genuine tie, not a
// newest-mtime win). If the ledger's winner selection had instead picked
// 01-foo-b (the bug this test guards — unsorted iteration disagreeing
// with the builder's sorted `rollupDirByKey`), this phase key's
// remembered verdict would be 'gaps_found' and the phase would flip to
// in_progress. It must instead stay complete, because the ledger
// remembers 01-foo-a's 'passed' — the same directory the builder uses.
// Delete 01-foo-a's report (unlink bumps the directory's mtime — restore
// it to the exact tie value so the SECOND read still sees a genuine tie,
// not a newest-mtime win). Disk-strict: the incumbent's own live read is
// now 'missing', so completed_phases must drop to 0 — it must NOT flip
// to 01-foo-b's gaps_found winning selection instead (that would be the
// sort-order-disagreement bug this test also guards).
fs.unlinkSync(path.join(dirA, '01-VERIFICATION.md'));
fs.utimesSync(dirA, tieTime, tieTime); // restore the tie the unlink disturbed
const afterDelete = inspectWorkstream(tmpDir, 'ws-2645-tie', { active: null });
assert.equal(afterDelete.completed_phases, 1,
"the ledger must have remembered the sort-order incumbent's 'passed' verdict, not the other directory's gaps_found");
assert.equal(afterDelete.completed_phases, 0,
'disk-strict: the sort-order incumbent\'s own deleted report is not complete, and 01-foo-b never wins selection instead');
});
// Row 9 — property: whatever sequence of REAL verdicts is observed for a
// phase key, once the file goes missing the ledger must replay exactly the
// LAST one observed — never an earlier one, never a synthesized value.
//
// #2645 review: `'stale'` is deliberately EXCLUDED here, not merely
// forgotten. Writing a literal `status: stale` frontmatter value does NOT
// round-trip as `'stale'` — `readVerificationStatus`
// (`src/verification.cts`) explicitly excludes `'stale'` from its raw-file
// routing lookup (`rawStatus !== 'stale'`) and falls through to the
// "Unknown value" branch, returning `'unknown'` instead. A genuine
// `'stale'` verdict is only reachable via `findStaleVerificationSummary`'s
// mtime comparison (a SUMMARY file newer than the VERIFICATION file), not
// by writing the word into the file. Including `'stale'` in this array
// without accounting for that would silently substitute `'unknown'` on
// every iteration and still pass (both are non-failing) — a docstring
// claiming "real verdicts" coverage it does not actually exercise.
// `'stale'` handling is explicitly out of scope for this issue (#2348) and
// is not gated by `FAILING_VERIFICATION_STATUSES` either way.
test('property: the ledger always replays the most recently observed real verdict after deletion (#2645)', () => {
// Row 9 — SUPERSEDED by ADR-3180 §7.4 (#3186, disk-strict): the ledger's
// "replay the last real verdict after deletion" memory is retired —
// `isPhaseComplete` reads fresh off disk, unconditionally, every call.
// The property this test now pins is simpler and STRONGER than the
// #2645-era one: whatever sequence of REAL verdicts was observed, once
// the file goes missing the phase is NEVER complete — full stop, not
// "unless the last real verdict was passed/unknown". Disclosed in this
// phase's changeset.
test('property: after the report goes missing, the phase is NEVER complete regardless of prior history (disk-strict; #2645 memory retired)', () => {
const REAL_STATUSES = ['passed', 'gaps_found', 'human_needed', 'unknown'];
const FAILING = new Set(['gaps_found', 'human_needed']);
fc.assert(fc.property(
fc.array(fc.constantFrom(...REAL_STATUSES), { minLength: 1, maxLength: 6 }),
(sequence) => {
@@ -1289,18 +1309,15 @@ describe('#2645 — deleting a verification report must not raise completeness',
const dir = writePhase(wsDir, '1-foo', { plans: 1, summaries: 1 });
const reportPath = path.join(dir, '01-VERIFICATION.md');
let lastReal = null;
for (const status of sequence) {
fs.writeFileSync(reportPath, `---\nstatus: ${status}\n---\n`);
inspectWorkstream(tmpDir, wsName, { active: null }); // observe
lastReal = status;
}
fs.unlinkSync(reportPath);
const inv = inspectWorkstream(tmpDir, wsName, { active: null });
const expectComplete = !FAILING.has(lastReal);
assert.equal(inv.phases[0].status === 'complete', expectComplete,
`after observing ${JSON.stringify(sequence)} then deleting, status must reflect the last real verdict (${lastReal})`);
assert.equal(inv.phases[0].status, 'in_progress',
`disk-strict: after observing ${JSON.stringify(sequence)} then deleting the report, the phase must never be complete`);
cleanup(wsDir);
},
@@ -1391,19 +1408,15 @@ describe('#2645 — deleting a verification report must not raise completeness',
assert.ok(fs.existsSync(ledgerPath), 'a read that observes a real verdict must recreate/self-heal the ledger');
});
// Row 14 — the DISCLOSED, ACCEPTED residual gap, pinned deliberately so it
// is never mistaken for a silent regression: deleting the report AND the
// ledger TOGETHER, after a failing verdict was already observed and
// recorded, returns this phase key to the pre-adoption `'absent'` ledger
// state — indistinguishable, by design, from a workstream that never used
// the verifier at all (criteria 2/3 require exactly that indistinguishability
// for a workstream that HASN'T adopted the ledger). This is the "prospective
// only" limitation named in the changeset: any durable store that must fail
// OPEN when wholly absent (to avoid gating every pre-existing project on
// upgrade) has this property at its own root. The bar is raised from "delete
// one file" to "delete two files in two different directories, one of which
// this issue's own reproduction never needed to touch" — not eliminated.
test('deleting the report AND the ledger together reopens the pre-adoption window (documented, not a regression) (#2645)', () => {
// Row 14 — SUPERSEDED by ADR-3180 §7.4 (#3186, disk-strict): the
// "prospective only" residual gap this row used to document (removing
// BOTH files reopens the pre-adoption 'absent' window, which USED to be
// allowed to complete because criteria 2/3 tolerated a never-verified
// phase) no longer exists — criteria 2/3 themselves are retired (Rows 3/4
// above). Deleting the report and/or the ledger, in any combination, now
// uniformly reads 'missing' → not complete. There is no residual gap left
// to disclose for this row.
test('deleting the report AND the ledger together is still NOT complete (disk-strict; #2645 residual gap closed)', () => {
const wsDir = seedWorkstream(tmpDir, { name: 'ws-2645-delete-both' });
fs.writeFileSync(path.join(wsDir, 'STATE.md'), FLAT_STATE);
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), flatRoadmap([
@@ -1420,9 +1433,8 @@ describe('#2645 — deleting a verification report must not raise completeness',
const inv = inspectWorkstream(tmpDir, 'ws-2645-delete-both', { active: null });
assert.ok(inv);
assert.equal(inv.phases[0].status, 'complete',
'documented limitation: removing BOTH files returns this phase to the pre-adoption/never-verified state — ' +
'this is the accepted "prospective only" boundary, not a bug in this fix');
assert.equal(inv.phases[0].status, 'in_progress',
'disk-strict: removing both files still reads \'missing\' — never complete, regardless of ledger adoption state');
});
// Row 11 — fault injection per CONTRIBUTING.md: read-only target directory

View File

@@ -624,6 +624,12 @@ describe('getOtherActiveWorkstreams', () => {
fs.writeFileSync(alphaPlan, '# Plan\n');
fs.writeFileSync(betaPlan, '# Plan\n');
fs.writeFileSync(betaSummary, '# Summary\n');
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes beta's phase count as complete now.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases', '01-beta', '01-VERIFICATION.md'),
'---\nstatus: passed\n---\n# Verification\n',
);
const others = getOtherActiveWorkstreams(tmpDir, 'alpha');
assert.strictEqual(others.length, 1);
@@ -646,6 +652,9 @@ describe('workstream progress', () => {
fs.mkdirSync(path.join(wsDir, 'phases', '01-init'), { recursive: true });
fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'SUMMARY.md'), '# Summary\n');
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes phase 01 count as complete now.
fs.writeFileSync(path.join(wsDir, 'phases', '01-init', '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 2\n');
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap\n### Phase 1: Init\n### Phase 2: Build\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'feature\n');
@@ -674,6 +683,9 @@ describe('workstream progress', () => {
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, 'SUMMARY.md'), '# Summary\n');
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes each phase count as complete now.
fs.writeFileSync(path.join(phaseDir, `${phase.slice(0, 2)}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n');
}
fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n');
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '# Roadmap\n### Phase 1: One\n');
@@ -863,6 +875,9 @@ describe('#2562 — a refused shipped marker reaches every workstream command',
fs.mkdirSync(path.join(wsDir, 'phases', '1-foo'), { recursive: true });
fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-SUMMARY.md'), '# Summary\n');
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes phase 1 count as complete now.
fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
fs.writeFileSync(path.join(wsDir, 'STATE.md'), 'milestone: v2.0\nstatus: executing\n');
fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), [
'# Roadmap', '', '## Milestone v2.0 — Two', '', '## Progress', '',