* 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>
743 lines
36 KiB
JavaScript
743 lines
36 KiB
JavaScript
'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);
|
|
}
|
|
});
|
|
});
|