Phase 1 of #3464. Removes the `// allow-test-rule:` marker from 22 test files where it is provably vestigial, and tightens the ratchet ceiling in scripts/lint-allow-test-rule-refs.ceiling.json from 305 to 285. Eligibility is decided by two independent AST discriminators, both conservative (any doubt => keep): (a) Read-target type. Every readFileSync/readFile call in the file resolves statically to a prose/config extension (.md/.json/.yml/.yaml/.toml/.txt), or the file performs no reads at all. Any read of a source extension (.cjs/.js/.mjs/.ts/.cts/.mts/.jsx/.tsx), any dynamic/unresolvable path, and any other extension all disqualify the file. (b) Marker context. Every `allow-test-rule:` occurrence is a genuine comment node, never string- or template-literal payload. A marker that lives inside a RuleTester `code:` fixture is test DATA, not a suppression directive; stripping it corrupts the test. tests/eslint-rules.test.cjs is the one such fixture host and is deliberately untouched. An earlier attempt at this phase classified markers by "strip it and see if local/no-source-grep still passes" and was reverted in full before commit. That oracle is unsound: the rule only fires on a literal .cjs/.js/.ts path containing a quoted bin/lib/gsd-core/src segment, tracked one hop from the binding, so files that genuinely source-grep real JavaScript pass it silently -- tests/no-unbounded-spawn-allowlist.test.cjs (reads test sources through a listTestFiles() walk) and tests/claude-imperative-reference.test.cjs (matches bin/install.js through an intermediate variable) both cleared it while being real source-greps. The rule's implementation is narrower than its intent, so it cannot adjudicate whether an exemption is load-bearing. Scope is limited to comment deletions: the diff over the test tree is 100% line removals with zero insertions, and no executable line is altered. On the ceiling value. The measured count at this HEAD is 283, so 285 leaves 2 slack -- deliberate, and well inside the documented grace band of 3. Pinning the ceiling to the exact count makes this change effectively unmergeable: any concurrent PR that lands one marker-bearing test file re-reds it. That race fired twice while preparing this branch (once mid-rebase taking the count 304->305 on next, once between rebase and the verification run taking it 282->283), and it is the same race that broke next in #3461. A ceiling of actual+2 preserves a merge window while still ratcheting 305 -> 285. Known limit, disclosed rather than papered over: this clears 22 of 303 markers and does not reach #3464's trend-to-zero goal. Most of the remaining markers sit on dynamic-path reads, commonly a hoisted `const p = path.join(tmpDir, 'STATE.md')` whose target is prose but is unresolvable to this classifier. A stricter one-hop const resolution would flip an estimated 95 more; that is deliberately left to a follow-up so it can be reviewed on its own evidence. Marker discovery reads bytes rather than shelling out to grep: tests/security-prompt-injection.security.test.cjs carries a literal NUL byte (an intentional injection fixture) that makes grep treat it as binary and skip it, which is why the true marked-file count is 303 and not the 302 a shell scan reports. Closes #3465 Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
742 lines
36 KiB
JavaScript
742 lines
36 KiB
JavaScript
'use strict';
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|