Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
318 lines
12 KiB
JavaScript
318 lines
12 KiB
JavaScript
/**
|
|
* Tests for SUMMARY-frontmatter-status-aware plan completion (#3345).
|
|
*
|
|
* Before #3345, both completion readers paired PLAN↔SUMMARY by FILENAME
|
|
* EXISTENCE only: `scanPhasePlans`'s `summaryCount`/`completed`
|
|
* (src/plan-scan.cts, via core-utils `countMatchedSummaries`) and
|
|
* `phase-plan-index`'s `has_summary`/`incomplete` (src/phase.cts, via
|
|
* core-utils `findUnsummarizedPlans`) never opened a SUMMARY file, so a
|
|
* SUMMARY declaring `status: blocked` counted as a completed plan in STATE.md
|
|
* progress counters AND was omitted from phase-plan-index's `incomplete` list.
|
|
*
|
|
* This suite pins the #3345 contract:
|
|
* - a SUMMARY whose frontmatter `status:` is `blocked` is NOT a completion
|
|
* record (count side: not counted; read side: has_summary false, lands in
|
|
* `incomplete`);
|
|
* - filename existence stays the fallback when the SUMMARY carries no
|
|
* `status` key (untouched projects are byte-for-behaviour unchanged);
|
|
* - `status: halted` STAYS counted — #2830's designed-stop model (a halt
|
|
* still writes a completion record; dependents get `blocked_by`
|
|
* propagation) is deliberately preserved;
|
|
* - PARITY GUARD (Generative Fix Divergence convention): the count side and
|
|
* the read side must filter their summary lists through the ONE shared
|
|
* predicate (`plan-dependency-graph.cjs`'s `isSummaryFileBlocked`), so the
|
|
* two can never re-diverge on this rule;
|
|
* - fail-open: an unreadable SUMMARY degrades to the pre-#3345
|
|
* filename-existence behaviour, never throws.
|
|
*
|
|
* Uses helpers.cjs createTempProject/cleanup per CONTRIBUTING.md and the
|
|
* process-seam-backed runMsdTools for the CLI-level assertions.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test, describe, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { join } = path;
|
|
|
|
function tempDir(prefix) {
|
|
return fs.mkdtempSync(join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
const planScan = require('../msd-core/bin/lib/plan-scan.cjs');
|
|
const planDependencyGraph = require('../msd-core/bin/lib/plan-dependency-graph.cjs');
|
|
const coreUtils = require('../msd-core/bin/lib/core-utils.cjs');
|
|
const { runMsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
function planBody() {
|
|
return '# Plan\n';
|
|
}
|
|
|
|
function summaryWithStatus(status) {
|
|
return status === undefined
|
|
? '# Summary\n'
|
|
: `---\nstatus: ${status}\n---\n\n# Summary\n`;
|
|
}
|
|
|
|
function writePhase(dir, files) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
for (const [name, content] of Object.entries(files)) {
|
|
fs.writeFileSync(join(dir, name), content);
|
|
}
|
|
}
|
|
|
|
// ─── isBlockedStatus unit (the one place "blocked" is decided) ─────────────
|
|
|
|
describe('#3345 isBlockedStatus — truthtable', () => {
|
|
test('matches exactly the blocked spelling, case/whitespace/comment tolerant', () => {
|
|
const { isBlockedStatus } = planDependencyGraph;
|
|
assert.equal(typeof isBlockedStatus, 'function', 'isBlockedStatus must be exported');
|
|
assert.equal(isBlockedStatus('blocked'), true);
|
|
assert.equal(isBlockedStatus('Blocked'), true);
|
|
assert.equal(isBlockedStatus(' BLOCKED '), true);
|
|
// Unquoted trailing YAML comment (same #2830 review defect-2 rule as isHaltedStatus).
|
|
assert.equal(isBlockedStatus('blocked # waiting on upstream'), true);
|
|
assert.equal(isBlockedStatus('halted'), false, 'halted is NOT blocked (designed stop)');
|
|
assert.equal(isBlockedStatus('complete'), false);
|
|
assert.equal(isBlockedStatus('superseded'), false);
|
|
assert.equal(isBlockedStatus('blocked#nospace'), false, 'no-whitespace # is not a YAML comment');
|
|
assert.equal(isBlockedStatus(undefined), false);
|
|
assert.equal(isBlockedStatus(42), false);
|
|
assert.equal(isBlockedStatus(null), false);
|
|
});
|
|
});
|
|
|
|
// ─── scanPhasePlans: the count side ────────────────────────────────────────
|
|
|
|
describe('#3345 scanPhasePlans — blocked SUMMARY is not a completion record', () => {
|
|
test('status: blocked SUMMARY -> summaryCount 0, completed false', () => {
|
|
const dir = tempDir('msd-3345-blocked-');
|
|
try {
|
|
writePhase(dir, {
|
|
'01-01-PLAN.md': planBody(),
|
|
'01-01-SUMMARY.md': summaryWithStatus('blocked'),
|
|
});
|
|
const scan = planScan(dir);
|
|
assert.equal(scan.planCount, 1);
|
|
assert.equal(scan.summaryCount, 0, 'blocked SUMMARY must not count as a completion record');
|
|
assert.equal(scan.completed, false, 'a blocked plan must not read as phase-complete');
|
|
// The on-disk list is untouched — callers that list summaries still see the file.
|
|
assert.deepEqual(scan.summaryFiles, ['01-01-SUMMARY.md']);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('no status key -> filename-existence fallback, counted exactly as before', () => {
|
|
const dir = tempDir('msd-3345-nostatus-');
|
|
try {
|
|
writePhase(dir, {
|
|
'01-01-PLAN.md': planBody(),
|
|
'01-01-SUMMARY.md': summaryWithStatus(undefined),
|
|
});
|
|
const scan = planScan(dir);
|
|
assert.equal(scan.summaryCount, 1);
|
|
assert.equal(scan.completed, true);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('status: complete SUMMARY -> counted', () => {
|
|
const dir = tempDir('msd-3345-complete-');
|
|
try {
|
|
writePhase(dir, {
|
|
'01-01-PLAN.md': planBody(),
|
|
'01-01-SUMMARY.md': summaryWithStatus('complete'),
|
|
});
|
|
const scan = planScan(dir);
|
|
assert.equal(scan.summaryCount, 1);
|
|
assert.equal(scan.completed, true);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('status: halted SUMMARY stays counted (#2830 designed-stop pin)', () => {
|
|
const dir = tempDir('msd-3345-halted-');
|
|
try {
|
|
writePhase(dir, {
|
|
'01-01-PLAN.md': planBody(),
|
|
'01-01-SUMMARY.md': summaryWithStatus('halted'),
|
|
});
|
|
const scan = planScan(dir);
|
|
assert.equal(scan.summaryCount, 1, 'a designed stop still writes a completion record');
|
|
assert.equal(scan.completed, true);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('mixed phase: 1 blocked + 1 complete of 2 plans -> summaryCount 1, completed false', () => {
|
|
const dir = tempDir('msd-3345-mixed-');
|
|
try {
|
|
writePhase(dir, {
|
|
'01-01-PLAN.md': planBody(),
|
|
'01-01-SUMMARY.md': summaryWithStatus('blocked'),
|
|
'01-02-PLAN.md': planBody(),
|
|
'01-02-SUMMARY.md': summaryWithStatus(undefined),
|
|
});
|
|
const scan = planScan(dir);
|
|
assert.equal(scan.planCount, 2);
|
|
assert.equal(scan.summaryCount, 1);
|
|
assert.equal(scan.completed, false);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('case/whitespace/comment variants of blocked are recognized', () => {
|
|
const dir = tempDir('msd-3345-casing-');
|
|
try {
|
|
writePhase(dir, {
|
|
'01-01-PLAN.md': planBody(),
|
|
'01-01-SUMMARY.md': summaryWithStatus('Blocked # waiting on upstream'),
|
|
});
|
|
const scan = planScan(dir);
|
|
assert.equal(scan.summaryCount, 0);
|
|
assert.equal(scan.completed, false);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('unreadable SUMMARY degrades fail-open to the filename fallback', (t) => {
|
|
const dir = tempDir('msd-3345-unreadable-');
|
|
try {
|
|
writePhase(dir, {
|
|
'01-01-PLAN.md': planBody(),
|
|
'01-01-SUMMARY.md': summaryWithStatus('blocked'),
|
|
});
|
|
const mocked = mock.method(fs, 'openSync', () => {
|
|
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
});
|
|
t.after(() => mocked.mock.restore());
|
|
const scan = planScan(dir);
|
|
assert.equal(scan.summaryCount, 1, 'unreadable SUMMARY counts by filename, never throws');
|
|
assert.equal(scan.completed, true);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── PARITY GUARD: count side and read side share ONE predicate ────────────
|
|
|
|
describe('#3345 parity — scanPhasePlans counts and findUnsummarizedPlans agree through the shared predicate', () => {
|
|
const PARITY_SCENARIOS = [
|
|
{ id: 'blocked', files: { '01-01-PLAN.md': planBody(), '01-01-SUMMARY.md': summaryWithStatus('blocked') } },
|
|
{ id: 'no-status', files: { '01-01-PLAN.md': planBody(), '01-01-SUMMARY.md': summaryWithStatus(undefined) } },
|
|
{ id: 'halted', files: { '01-01-PLAN.md': planBody(), '01-01-SUMMARY.md': summaryWithStatus('halted') } },
|
|
{
|
|
id: 'mixed',
|
|
files: {
|
|
'01-01-PLAN.md': planBody(),
|
|
'01-01-SUMMARY.md': summaryWithStatus('blocked'),
|
|
'01-02-PLAN.md': planBody(),
|
|
'01-02-SUMMARY.md': summaryWithStatus(undefined),
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const scenario of PARITY_SCENARIOS) {
|
|
test(`scenario ${scenario.id}: unsummarized-through-predicate mirrors summaryCount`, () => {
|
|
const dir = tempDir(`msd-3345-parity-${scenario.id}-`);
|
|
try {
|
|
writePhase(dir, scenario.files);
|
|
const scan = planScan(dir);
|
|
const { isSummaryFileBlocked } = planDependencyGraph;
|
|
assert.equal(typeof isSummaryFileBlocked, 'function', 'isSummaryFileBlocked must be exported');
|
|
const countable = scan.summaryFiles.filter((f) => !isSummaryFileBlocked(join(dir, f)));
|
|
const unsummarized = coreUtils.findUnsummarizedPlans(scan.planFiles, countable);
|
|
assert.equal(
|
|
scan.planCount - scan.summaryCount,
|
|
unsummarized.length,
|
|
'the count side (scanPhasePlans) and the read side (findUnsummarizedPlans over the SAME predicate-filtered list) must agree',
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── CLI: the read path (phase-plan-index) ─────────────────────────────────
|
|
|
|
describe('#3345 phase-plan-index — blocked plan lands in incomplete', () => {
|
|
function writeCliPhase(projectDir, summaryContent) {
|
|
const phaseDir = join(projectDir, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
const planFm = [
|
|
'---',
|
|
'phase: 01-test',
|
|
'plan: "01"',
|
|
'type: execute',
|
|
'wave: 1',
|
|
'depends_on: []',
|
|
'files_modified: []',
|
|
'autonomous: true',
|
|
'---',
|
|
'# plan',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(join(phaseDir, '01-01-PLAN.md'), planFm);
|
|
fs.writeFileSync(join(phaseDir, '01-01-SUMMARY.md'), summaryContent);
|
|
return phaseDir;
|
|
}
|
|
|
|
test('blocked SUMMARY -> has_summary false and the plan appears in incomplete', () => {
|
|
const project = createTempProject('msd-3345-idx-blocked-');
|
|
try {
|
|
writeCliPhase(project, summaryWithStatus('blocked'));
|
|
const r = runMsdTools(['phase-plan-index', '01-test', '--json'], project);
|
|
assert.equal(r.exitCode, 0, r.output);
|
|
const idx = JSON.parse(r.output);
|
|
const plan = idx.plans.find((p) => p.id === '01-01');
|
|
assert.ok(plan, 'plan 01-01 must be indexed');
|
|
assert.equal(plan.has_summary, false, 'a blocked SUMMARY is not a completion record');
|
|
assert.ok(idx.incomplete.includes('01-01'), 'the blocked plan must land in incomplete');
|
|
} finally {
|
|
cleanup(project);
|
|
}
|
|
});
|
|
|
|
test('plain SUMMARY (no status) -> has_summary true, incomplete empty (unchanged)', () => {
|
|
const project = createTempProject('msd-3345-idx-plain-');
|
|
try {
|
|
writeCliPhase(project, summaryWithStatus(undefined));
|
|
const r = runMsdTools(['phase-plan-index', '01-test', '--json'], project);
|
|
assert.equal(r.exitCode, 0, r.output);
|
|
const idx = JSON.parse(r.output);
|
|
const plan = idx.plans.find((p) => p.id === '01-01');
|
|
assert.equal(plan.has_summary, true);
|
|
assert.deepEqual(idx.incomplete, []);
|
|
} finally {
|
|
cleanup(project);
|
|
}
|
|
});
|
|
|
|
test('halted SUMMARY stays has_summary true with halted true (#2830 pin, unchanged)', () => {
|
|
const project = createTempProject('msd-3345-idx-halted-');
|
|
try {
|
|
writeCliPhase(project, summaryWithStatus('halted'));
|
|
const r = runMsdTools(['phase-plan-index', '01-test', '--json'], project);
|
|
assert.equal(r.exitCode, 0, r.output);
|
|
const idx = JSON.parse(r.output);
|
|
const plan = idx.plans.find((p) => p.id === '01-01');
|
|
assert.equal(plan.has_summary, true, 'a designed stop keeps its completion record');
|
|
assert.equal(plan.halted, true);
|
|
assert.deepEqual(idx.incomplete, []);
|
|
} finally {
|
|
cleanup(project);
|
|
}
|
|
});
|
|
});
|