Files
msd-core/tests/enumeration-single-owner.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
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.
2026-10-06 01:47:40 +02:00

487 lines
21 KiB
JavaScript

/**
* Tests for the phase-ENUMERATION single-owner contract (#3185, epic #3180
* Phase 3, ADR-3180 Decision 1 row "Phase enumeration").
* Matrix: .msd/phase/refactor-3185-phase-enumeration-single-owner/50-test-matrix.md
*
* This file lands FAILING-FIRST. Every test below asserts the behavior the
* design requires and that the tree does NOT have yet, so the remote runner
* records a real RED before the fix.
*
* Rows covered here are the ones with direct code evidence in the design:
* - row 36 `progress` lists 999.* backlog dirs as current-milestone phases
* - row 39 a `0-*` sentinel directory is listed by `stats`/`progress`
* - row 14 negative space: `P0.0-foundation` is a REAL phase (#1324
* letter-prefixed-decimal family), not sentinel milestone 0
* - row 38 a `### Phase 999.1:` ROADMAP heading produces a `stats` row
* - row 43 `phases clear --confirm` DELETES a `0-*` directory, because
* cmdPhasesClear carries its own 5th sentinel copy
* (`/^999(?:\.|$)/`) excluding 999 but NOT 0 — while
* `roadmap analyze` treats Phase 0 as a sentinel to preserve.
*
* Assertions are on `--json` output (a typed surface), never on rendered
* prose — CONTRIBUTING.md bans raw-text matching on test outputs. Row 43 also
* carries the NEGATIVE PROOF that the directory still exists on disk, because
* an exit code alone cannot distinguish "refused" from "deleted and reported
* success".
*/
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
createTempProject,
createTempGitProject,
cleanup,
runMsdTools,
} = require('./helpers.cjs');
const { SCOPE } = require('../msd-core/bin/lib/planning-scope.cjs');
const { isSentinelPhaseId } = require('../msd-core/bin/lib/phase-id.cjs');
// ─── Fixture helpers ───────────────────────────────────────────────────────
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function writeRoadmap(cwd, lines) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), lines.join('\n'));
}
function writeState(cwd, fields) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const lines = ['---'];
for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`);
lines.push('---', '');
fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), lines.join('\n'));
}
/** Create `<cwd>/.planning/phases/<dirName>/` and optionally seed files. */
function makePhaseDir(cwd, dirName, files = {}) {
const dir = path.join(planningDirOf(cwd), 'phases', dirName);
fs.mkdirSync(dir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), content);
}
return dir;
}
/** A ROADMAP with a real current milestone plus a 999 backlog heading. */
function roadmapWithBacklog() {
return [
'# Roadmap',
'',
'## v1.0 Current 🚧',
'',
'### Phase 1: Foundation',
'',
'**Goal:** lay the foundation',
'',
'### Phase 999.1: Icebox item',
'',
'**Goal:** someday',
'',
];
}
function parseJson(result, label) {
assert.ok(result.success, `${label} should exit 0: ${result.error || ''}`);
try {
return JSON.parse(result.output);
} catch (err) {
throw new Error(`${label} did not emit parseable JSON: ${err.message}`);
}
}
// ═════════════════════════════════════════════════════════════════════════
// Row 36 — `progress` must not list 999.* backlog dirs as milestone phases
// ═════════════════════════════════════════════════════════════════════════
test('progress does not list a 999 backlog directory as a current-milestone phase', (t) => {
const cwd = createTempProject('msd-phase-enum-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithBacklog());
makePhaseDir(cwd, '01-foundation', { '01-PLAN.md': '# Plan\n' });
makePhaseDir(cwd, '999.1-icebox', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('progress json', cwd), 'progress json');
const numbers = (report.phases || []).map((p) => String(p.number));
assert.ok(
!numbers.some((n) => n.startsWith('999')),
`999.* backlog directories must not appear as current-milestone phases; got ${JSON.stringify(numbers)}`,
);
assert.ok(
numbers.includes('01') || numbers.includes('1'),
`the real in-window phase must still be listed; got ${JSON.stringify(numbers)}`,
);
});
// ═════════════════════════════════════════════════════════════════════════
// Row 39 / row 14 — phase-0 sentinel vs the letter-prefixed-decimal family
// ═════════════════════════════════════════════════════════════════════════
test('progress does not list a phase-0 sentinel directory', (t) => {
const cwd = createTempProject('msd-phase-enum-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithBacklog());
makePhaseDir(cwd, '0-prep', { '01-PLAN.md': '# Plan\n' });
makePhaseDir(cwd, '01-foundation', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('progress json', cwd), 'progress json');
const numbers = (report.phases || []).map((p) => String(p.number));
assert.ok(
!numbers.some((n) => /^0+$/.test(n)),
`phase-0 sentinel directories must not appear as milestone phases; got ${JSON.stringify(numbers)}`,
);
});
test('a letter-prefixed decimal directory is NOT read as a phase-0 sentinel', (t) => {
// Negative space (matrix row 14): `P0.0-foundation` is a real phase from the
// #1324 letter-prefixed-decimal family, NOT sentinel milestone 0. A sentinel
// rule that strips a bare letter prefix would silently delete this family
// from three commands.
const cwd = createTempProject('msd-phase-enum-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, [
'# Roadmap',
'',
'## v1.0 Current 🚧',
'',
'### Phase P0.0: Foundation',
'',
'**Goal:** real work',
'',
]);
makePhaseDir(cwd, 'P0.0-foundation', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('progress json', cwd), 'progress json');
const numbers = (report.phases || []).map((p) => String(p.number));
assert.ok(
numbers.length > 0,
'a letter-prefixed decimal phase must survive the sentinel filter, not be dropped as milestone 0',
);
});
// ═════════════════════════════════════════════════════════════════════════
// Row 38 — a 999 ROADMAP heading must not produce a `stats` row
// ═════════════════════════════════════════════════════════════════════════
test('stats does not emit a row for a 999 backlog heading with no directory', (t) => {
const cwd = createTempProject('msd-phase-enum-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithBacklog());
makePhaseDir(cwd, '01-foundation', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('stats json', cwd), 'stats json');
const numbers = (report.phases || []).map((p) => String(p.number));
assert.ok(
!numbers.some((n) => n.startsWith('999')),
`a 999 heading must not seed a stats row; got ${JSON.stringify(numbers)}`,
);
});
test('stats does not list a phase-0 sentinel directory', (t) => {
const cwd = createTempProject('msd-phase-enum-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithBacklog());
makePhaseDir(cwd, '0-prep', { '01-PLAN.md': '# Plan\n' });
makePhaseDir(cwd, '01-foundation', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('stats json', cwd), 'stats json');
const numbers = (report.phases || []).map((p) => String(p.number));
assert.ok(
!numbers.some((n) => /^0+$/.test(n)),
`phase-0 sentinel directories must not appear in stats; got ${JSON.stringify(numbers)}`,
);
});
// ═════════════════════════════════════════════════════════════════════════
// Row 43 — the destructive path: `phases clear` must not delete a sentinel
// ═════════════════════════════════════════════════════════════════════════
test('phases clear preserves a phase-0 sentinel directory, with negative proof on disk', (t) => {
// cmdPhasesClear carries the FIFTH copy of the sentinel rule and the THIRD
// regex variant — `/^999(?:\.|$)/` — which excludes 999 but NOT 0. So a
// `0-*` directory that `roadmap analyze` treats as a preserved sentinel is
// DELETED here. The exit code alone cannot show this, so the load-bearing
// assertion is that the directory still exists afterwards.
const cwd = createTempGitProject('msd-phase-enum-clear-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithBacklog());
const zeroDir = makePhaseDir(cwd, '0-prep', { '01-PLAN.md': '# Plan\n' });
const backlogDir = makePhaseDir(cwd, '999.1-icebox', { '01-PLAN.md': '# Plan\n' });
makePhaseDir(cwd, '01-foundation', { '01-PLAN.md': '# Plan\n' });
runMsdTools('phases clear --confirm --force', cwd);
assert.ok(
fs.existsSync(zeroDir),
'a phase-0 sentinel directory must be preserved by `phases clear`, exactly as the 999 sentinel already is',
);
assert.ok(
fs.existsSync(backlogDir),
'the 999 sentinel directory must remain preserved (unchanged behavior)',
);
});
// ═════════════════════════════════════════════════════════════════════════
// Rows 7-9 — the PASS-ALL degrade, which is where the defect actually lives
//
// When the milestone window yields NO phase headings, getMilestonePhaseFilter
// degrades to a literal `() => true` predicate and stops consulting its
// heading set at all — so its `/^999\b/` heading-side sentinel exclusion
// becomes unreachable and every directory on disk is reported as a
// current-milestone phase. A fixture that contains phase headings keeps the
// filter ACTIVE and never exercises this path, which is why the sibling
// phase-0 test above passes today for the wrong reason.
// ═════════════════════════════════════════════════════════════════════════
/** A ROADMAP whose current milestone declares NO phases — the pass-all path. */
function roadmapWithNoPhaseHeadings() {
return [
'# Roadmap',
'',
'## v1.0 Current 🚧',
'',
'This milestone has been declared but has no phases yet.',
'',
];
}
test('pass-all degrade still excludes a 999 sentinel directory', (t) => {
const cwd = createTempProject('msd-phase-enum-passall-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithNoPhaseHeadings());
makePhaseDir(cwd, '999.1-icebox', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('progress json', cwd), 'progress json');
const numbers = (report.phases || []).map((p) => String(p.number));
assert.ok(
!numbers.some((n) => n.startsWith('999')),
`the pass-all degrade must still filter sentinels; got ${JSON.stringify(numbers)}`,
);
});
test('pass-all degrade still excludes a phase-0 sentinel directory', (t) => {
const cwd = createTempProject('msd-phase-enum-passall-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithNoPhaseHeadings());
makePhaseDir(cwd, '0-prep', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('progress json', cwd), 'progress json');
const numbers = (report.phases || []).map((p) => String(p.number));
assert.ok(
!numbers.some((n) => /^0+$/.test(n)),
`the pass-all degrade must still filter phase-0 sentinels; got ${JSON.stringify(numbers)}`,
);
});
test('pass-all degrade stays over-inclusive for NON-sentinel directories', (t) => {
// Negative space (matrix row 9): the narrowing is sentinel-only. The
// documented "over-inclusive, never under-inclusive" promise in
// getMilestonePhaseFilter's own comment is narrowed minimally, not revoked —
// an ordinary phase directory must still be listed when the window declares
// no phases, or a project mid-migration loses its real phases from view.
const cwd = createTempProject('msd-phase-enum-passall-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithNoPhaseHeadings());
makePhaseDir(cwd, '04-thing', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('progress json', cwd), 'progress json');
const numbers = (report.phases || []).map((p) => String(p.number));
assert.ok(
numbers.length > 0,
'an ordinary phase directory must still be listed under the pass-all degrade',
);
});
// ═════════════════════════════════════════════════════════════════════════
// `phases list` — ADR-3180 Decision 4(c): the identity assertion belongs at
// EACH consumer's own observable output, not only at progress/stats.
// ═════════════════════════════════════════════════════════════════════════
test('phases list excludes a 999 backlog dir and a 0-sentinel dir, keeps the real one', (t) => {
const cwd = createTempProject('msd-phase-enum-list-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithBacklog());
makePhaseDir(cwd, '01-foundation', { '01-PLAN.md': '# Plan\n' });
makePhaseDir(cwd, '999.1-icebox', { '01-PLAN.md': '# Plan\n' });
makePhaseDir(cwd, '0-prep', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('phases list', cwd), 'phases list');
const dirs = report.directories || [];
assert.ok(
!dirs.some((d) => d.startsWith('999')),
`999.* backlog directories must not appear in phases list; got ${JSON.stringify(dirs)}`,
);
assert.ok(
!dirs.some((d) => /^0(?:[-.]|$)/.test(d)),
`phase-0 sentinel directories must not appear in phases list; got ${JSON.stringify(dirs)}`,
);
assert.ok(
dirs.includes('01-foundation'),
`the real in-window phase directory must still be listed; got ${JSON.stringify(dirs)}`,
);
});
test('phases list --phase still finds an out-of-window phase (documented exemption)', (t) => {
const cwd = createTempProject('msd-phase-enum-list-lookup-');
t.after(() => cleanup(cwd));
// v1.0 is current; phase 5 belongs to a different (later) milestone and is
// never mentioned in v1.0's window, so it is genuinely OUT-OF-WINDOW.
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, [
'# Roadmap',
'',
'## v1.0 Current 🚧',
'',
'### Phase 1: Foundation',
'',
'## v2.0 Next',
'',
'### Phase 5: Later Work',
'',
]);
makePhaseDir(cwd, '01-foundation', { '01-PLAN.md': '# Plan\n' });
makePhaseDir(cwd, '05-later-work', { '01-PLAN.md': '# Plan\n' });
const result = runMsdTools('phases list --phase 5', cwd);
const report = parseJson(result, 'phases list --phase 5');
assert.strictEqual(
report.error,
undefined,
`--phase lookup must find an out-of-window phase by design (ADR-3180 Decision 4a); got ${JSON.stringify(report)}`,
);
assert.deepStrictEqual(report.directories, ['05-later-work']);
});
// ═════════════════════════════════════════════════════════════════════════
// `phase_scope` — epic #3180 Done-when #7: the enumeration path can fail
// distinguishably. Mirrors the TRUNCATED fixture shape from
// tests/milestone-window-single-owner.test.cjs's buildTruncatedFixture: an
// ACTIVE milestone heading with no phase headings inside its own window,
// followed by a later milestone heading whose section DOES carry phase
// headings -- the window closes before the phase region.
// ═════════════════════════════════════════════════════════════════════════
function writeTruncatedRoadmap(cwd) {
writeRoadmap(cwd, [
'# Roadmap',
'',
'## v3.0 In Progress 🚧',
'',
'Some preamble notes. No phase headings here.',
'',
'## v4.0 Next',
'',
'### Phase 1: Foo',
'',
'### Phase 2: Bar',
'',
]);
}
test('progress json phase_scope is not complete when the milestone window is truncated', (t) => {
const cwd = createTempProject('msd-phase-enum-scope-truncated-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v3.0' });
writeTruncatedRoadmap(cwd);
makePhaseDir(cwd, '1-foo', { '01-PLAN.md': '# Plan\n' });
makePhaseDir(cwd, '2-bar', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('progress json', cwd), 'progress json');
assert.notStrictEqual(
report.phase_scope,
SCOPE.COMPLETE,
`a truncated window must not report phase_scope: "complete"; got ${JSON.stringify(report.phase_scope)}`,
);
});
test('progress json phase_scope is complete for a healthy, in-window fixture', (t) => {
const cwd = createTempProject('msd-phase-enum-scope-complete-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, roadmapWithBacklog());
makePhaseDir(cwd, '01-foundation', { '01-PLAN.md': '# Plan\n' });
const report = parseJson(runMsdTools('progress json', cwd), 'progress json');
assert.strictEqual(report.phase_scope, SCOPE.COMPLETE);
});
// ═════════════════════════════════════════════════════════════════════════
// isSentinelPhaseId boundary (#3185, reverted 5fcb7a5a2): #2554 and #2949
// are pinned contracts that ask DIFFERENT QUESTIONS about `0.x` ids, so one
// global predicate cannot answer both:
// - #2554 (tests/roadmap-parser.test.cjs) asks "is this directory part of
// the current milestone's phase set?" -- a `00.1-<slug>` directory
// declared as `### Phase 00.1:` MUST be counted there.
// - #2949 (tests/issue-2949-phase-complete-stage3-sentinel.test.cjs) asks
// "must this phase be completed before the milestone can close?" -- a
// `0.x` id IS a sentinel for that question, and must not block
// `is_last_phase=true`.
// isSentinelPhaseId answers the #2949 (completion) question, so `0.x` stays
// a sentinel here. The #2554 (milestone-window) question is answered by a
// narrower, 999-only rule inside getMilestonePhaseFilter in
// src/roadmap-parser.cts, not by this predicate. The two questions are
// deliberately answered at two different layers.
// ═════════════════════════════════════════════════════════════════════════
const SENTINEL_IDS = [
'0',
'00',
'0-prep',
'00-prep',
'0.1',
'00.1',
'0.1-slug',
'999',
'999.1',
'999.1-icebox',
'MSD-999-icebox',
];
const NON_SENTINEL_IDS = ['01-foundation', 'P0.0-foundation', '9990-x', '998-x', '1000-x', '04-thing'];
test('isSentinelPhaseId: sentinel boundary table', () => {
for (const id of SENTINEL_IDS) {
assert.strictEqual(isSentinelPhaseId(id), true, `expected sentinel: ${JSON.stringify(id)}`);
}
});
test('isSentinelPhaseId: NOT-sentinel boundary table', () => {
for (const id of NON_SENTINEL_IDS) {
assert.strictEqual(isSentinelPhaseId(id), false, `expected NOT sentinel: ${JSON.stringify(id)}`);
}
});