Files
msd-core/tests/state-rebuild-cli.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

412 lines
17 KiB
JavaScript

'use strict';
// Phase 2 integration tests for the `state rebuild` CLI subcommand (#1826).
//
// These tests exercise the CLI dispatch path (routeStateCommand → cmdStateRebuild),
// the --dry-run flag (no write), and the --verbose flag (stderr emit). The
// underlying rebuildCore logic is covered by tests/state-rebuild.test.cjs (Phase 1).
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const {
createTempProject,
cleanup,
runMsdTools,
captureFdSync,
} = require('./helpers.cjs');
const { withFaultyFs } = require('./helpers/faulty-deps.cjs');
const stateMod = require('../msd-core/bin/lib/state.cjs');
const { stateExtractField } = require('../msd-core/bin/lib/state-document.cjs');
const { parseMarkdownTable } = require('../msd-core/bin/lib/markdown-table.cjs');
const { collectSection } = require('../msd-core/bin/lib/markdown-sectionizer.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'msd-core', 'bin', 'msd-tools.cjs');
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
/**
* Write a STATE.md with one drift signature (stale Current Phase in body vs
* frontmatter) into a freshly-created temp project. Returns the temp dir.
*/
function projectWithDriftedState() {
const cwd = createTempProject('state-rebuild-cli');
const planningPath = path.join(cwd, '.planning');
// Drop two phase dirs so phaseInventoryProvider has something to scan.
fs.mkdirSync(path.join(planningPath, 'phases', '01-phase-one'), { recursive: true });
fs.writeFileSync(path.join(planningPath, 'phases', '01-phase-one', '01-PLAN.md'), '# Plan');
fs.mkdirSync(path.join(planningPath, 'phases', '02-phase-two'), { recursive: true });
fs.writeFileSync(path.join(planningPath, 'phases', '02-phase-two', '01-PLAN.md'), '# Plan');
// STATE.md with a drift: body says Current Phase 1, frontmatter says 2.
const stateContent = [
'---',
'msd_state_version: \'1.0\'',
'status: executing',
'milestone: 1.0.0',
'milestone_name: Test',
'current_phase: 2',
'current_phase_name: Phase Two',
'current_plan: 1',
'progress:',
' total_phases: 2',
' completed_phases: 1',
' total_plans: 2',
' completed_plans: 1',
' percent: 50',
'---',
'',
'# Project State',
'',
'## Project Reference',
'',
'**Core value:** A test project',
'**Current focus:** Phase Two',
'',
'## Current Position',
'',
'**Current Phase:** 1',
'**Current Phase Name:** Phase One',
'**Current Plan:** 1',
'**Total Plans in Phase:** 1',
'**Status:** executing',
'**Last Activity:** 2026-06-29',
'**Last Activity Description:** mid-flight',
'',
'Phase: 2 of 2 (Phase Two)',
'Plan: 1 of 1',
'Status: Executing Phase 2',
'Last activity: 2026-06-29 — mid-flight',
'',
'**Progress:** [█████░░░░░] 50%',
'',
'## Performance Metrics',
'',
'**By Phase:**',
'',
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
'| 99 | 1 | - | - |',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'None yet.',
'',
'## Session Continuity',
'',
'Last session: 2026-06-29 12:00',
'Stopped at: mid-flight',
'Resume file: None',
'',
].join('\n');
fs.writeFileSync(path.join(planningPath, 'STATE.md'), stateContent);
return cwd;
}
/** Read the live STATE.md from a project (strips the audit log so assertions
* check the canonical body, not the appended ## Rebuild Log entries). */
function readLiveState(cwd) {
const statePath = path.join(cwd, '.planning', 'STATE.md');
const content = fs.readFileSync(statePath, 'utf8');
// Strip ## Rebuild Log and everything after for shape assertions.
// eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test wrote via a fixture, fixed-size test-controlled content
return content.replace(/^## Rebuild Log[\s\S]*$/m, '');
}
/**
* A project whose ONLY drift is the `**By Phase:**` table (an orphan row for
* phase 99, which isn't on disk). Every other body/frontmatter field is
* already canonical, so `rebuildCore`'s other reconciliation steps are all
* no-ops — the ONLY thing that can add a `## Rebuild Log` entry / flip
* `mutated` is whether the phase-inventory disk scan actually reconciles the
* table. This isolates the phase-inventory-scan outcome for #3057 B1.
*/
function projectWithOnlyPhaseTableDrift() {
const cwd = createTempProject('state-rebuild-cli-scan');
const planningPath = path.join(cwd, '.planning');
fs.mkdirSync(path.join(planningPath, 'phases', '01-phase-one'), { recursive: true });
fs.writeFileSync(path.join(planningPath, 'phases', '01-phase-one', '01-PLAN.md'), '# Plan');
const stateContent = [
'---',
'msd_state_version: \'1.0\'',
'status: executing',
'milestone: 1.0.0',
'milestone_name: Test',
'current_phase: 1',
'current_phase_name: Phase One',
'current_plan: 1',
'progress:',
' total_phases: 1',
' completed_phases: 0',
' total_plans: 1',
' completed_plans: 0',
' percent: 0',
'---',
'',
'# Project State',
'',
'## Project Reference',
'',
'**Core value:** A test project',
'**Current focus:** Phase One',
'',
'## Current Position',
'',
'**Current Phase:** 1',
'**Current Phase Name:** Phase One',
'**Current Plan:** 1',
'**Total Plans in Phase:** 1',
'**Status:** executing',
'**Last Activity:** 2026-06-29',
'**Last Activity Description:** mid-flight',
'',
'Phase: 1 of 1 (Phase One)',
'Plan: 1 of 1',
'Status: Executing Phase 1',
'Last activity: 2026-06-29 — mid-flight',
'',
'**Progress:** [░░░░░░░░░░] 0%',
'',
'## Performance Metrics',
'',
'**By Phase:**',
'',
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
'| 1 | 1 | - | - |',
'| 99 | 1 | - | - |',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'None yet.',
'',
'## Session Continuity',
'',
'Last session: 2026-06-29 12:00',
'Stopped at: mid-flight',
'Resume file: None',
'',
].join('\n');
fs.writeFileSync(path.join(planningPath, 'STATE.md'), stateContent);
return cwd;
}
/**
* Typed presence check for the `## Rebuild Log` audit-log section, shared by
* both call sites that need it (CONTRIBUTING: no raw-text `.includes()` on
* produced STATE.md text). Built on the existing `collectSection` seam
* (markdown-sectionizer.cjs) — a `Section | null`, not string matching.
*/
function hasRebuildLogSection(content) {
return collectSection(content, (h) => h.text.trim() === 'Rebuild Log') !== null;
}
/**
* Run `fn` while capturing every fd-1 write `cmdStateRebuild`'s `output()`
* performs (it writes via a raw `fs.writeSync(1, ...)`, never
* `console.log`/`process.stdout.write` — same seam `tests/io.test.cjs`
* exercises for bug #1008). Delegates to the shared, safe helper (#4306) —
* see `tests/helpers.cjs`'s `captureFdSync` for why a hand-rolled version
* that fabricates a byte count instead of forwarding is unsafe.
*/
function captureStdout(fn) {
return captureFdSync(1, fn);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ADR-1817 Phase 2: `state rebuild` CLI subcommand dispatch (criterion #5 + end-to-end)', () => {
test('`state rebuild` with no flags reconciles drifted body fields and writes', (t) => {
const cwd = projectWithDriftedState();
t.after(() => cleanup(cwd));
const result = runMsdTools('state rebuild', cwd);
assert.ok(result.success, `state rebuild should succeed; stderr: ${result.stderr || result.error || ''}`);
// Body fields reconciled with frontmatter.
const live = readLiveState(cwd);
assert.strictEqual(stateExtractField(live, 'Current Phase'), '2',
'body Current Phase must be reconciled to frontmatter value 2');
assert.strictEqual(stateExtractField(live, 'Current Phase Name'), 'Phase Two',
'body Current Phase Name must be reconciled to frontmatter value');
// Orphan table row dropped (phase 99 is not on disk).
const byPhaseTable = parseMarkdownTable(live);
assert.ok(byPhaseTable.ok, `By Phase table must parse; reason: ${byPhaseTable.ok ? '' : byPhaseTable.reason}`);
const phaseIds = byPhaseTable.value.rows.map((r) => r.Phase);
assert.ok(!phaseIds.includes('99'),
`orphan row for phase 99 must be dropped (phaseInventoryProvider wired to disk scan); rows: ${JSON.stringify(phaseIds)}`);
// Audit log appended.
const fullState = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
assert.ok(hasRebuildLogSection(fullState),
'audit log section must be appended');
});
test('`state rebuild --dry-run` computes the diff but writes nothing', (t) => {
const cwd = projectWithDriftedState();
t.after(() => cleanup(cwd));
const before = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
const result = runMsdTools('state rebuild --dry-run', cwd);
assert.ok(result.success, `state rebuild --dry-run should succeed; error: ${result.error || ''}`);
const after = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
assert.strictEqual(after, before,
'--dry-run must NOT modify STATE.md on disk');
// The structured output should signal mutations would occur (the fixture
// has drift, so mutated=true in dry-run preview). `state rebuild --dry-run`
// emits pure JSON to stdout (src/state.cts:3221 `cmdStateRebuild`'s dry-run
// branch: `emit({ ..., mutated, ... })`).
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.mutated, true,
`dry-run output should report mutated:true; output: ${result.output}`);
});
test('`state rebuild --verbose` emits audit-log entries to stderr', (t) => {
const cwd = projectWithDriftedState();
t.after(() => cleanup(cwd));
// runMsdTools returns stdout only on success; --verbose writes to stderr,
// so invoke msd-tools directly to capture both streams separately.
const r = runNode(
[TOOLS_PATH, 'state', 'rebuild', '--verbose'],
{ cwd, timeoutMs: PROBE_TIMEOUT_MS },
);
throwIfFailed(r, 'msd-tools state rebuild --verbose');
const stdout = r.stdout;
// The seam's runNode captures stderr separately (unlike execFileSync's
// default inherited stderr) but this test does not need it — see below.
// Assert via the canonical record written to STATE.md: the audit log
// section is always appended (mutated fixture), and --verbose merely
// tees the same entries to stderr. The functional guarantee (audit log
// written) is what matters; the stderr tee is a convenience.
const after = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
assert.ok(hasRebuildLogSection(after),
'--verbose must still produce the audit log section in STATE.md');
// The real (non-dry-run) path emits `{ rebuilt: capturedMutated, ... }`
// to stdout as pure JSON (src/state.cts:3254 `cmdStateRebuild`). The
// fixture has drift, so `rebuilt` must be true.
const parsedStdout = JSON.parse(stdout);
assert.strictEqual(parsedStdout.rebuilt, true,
`--verbose stdout must report rebuilt:true; got: ${stdout.slice(0, 200)}`);
});
test('`state rebuild` on a clean STATE.md is a no-op (idempotency, end-to-end)', (t) => {
const cwd = projectWithDriftedState();
t.after(() => cleanup(cwd));
// First run: reconcile the drift.
const first = runMsdTools('state rebuild', cwd);
assert.ok(first.success, 'first rebuild should succeed');
const afterFirst = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
// Second run: should detect no drift, write nothing beyond what's there.
const second = runMsdTools('state rebuild', cwd);
assert.ok(second.success, 'second rebuild should succeed');
const afterSecond = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
assert.strictEqual(afterSecond, afterFirst,
'second rebuild on the just-rebuilt file must be byte-identical (idempotency)');
});
test('`state rebuild` on a missing STATE.md emits a clean error, no stack trace', (t) => {
const cwd = createTempProject('state-rebuild-missing');
t.after(() => cleanup(cwd));
// No STATE.md written.
const result = runMsdTools('state rebuild', cwd);
// The command emits a typed JSON error result and exits 0 — it does not
// crash the process (src/state.cts:3136 `cmdStateRebuild`'s missing-file
// guard: `emit({ error: 'STATE.md not found' }, raw); return;`, no
// `process.exit`). `result.success` proves the clean exit (a crash would
// flip it to false and populate `result.error` with a raw stack trace
// instead); `JSON.parse` proves stdout is exactly the structured payload
// with nothing else — including no leaked stack-trace text — mixed in.
assert.ok(result.success,
`state rebuild on a missing STATE.md should exit cleanly (no crash); stderr: ${result.error || ''}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.error, 'STATE.md not found',
`missing STATE.md should produce a typed error field; output: ${result.output}`);
});
});
// ---------------------------------------------------------------------------
// #3057 B1: the PRODUCTION `phaseInventoryProvider` closure (state.cts:3106),
// not just the pure `reconcileByPhaseTable` core, must distinguish a real
// disk-scan failure from a genuinely-empty/reconciled scan. In-process fault
// injection via `withFaultyFs` on the real `fs.readdirSync` the closure
// calls — never chmod, never the subprocess seam (both banned per
// tests/helpers/faulty-deps.cjs's module doc).
// ---------------------------------------------------------------------------
describe('#3057 B1: cmdStateRebuild (production adapter) surfaces a real phase-inventory scan failure', () => {
test('FAILURE path: readdirSync(.planning/phases) faulted → phase_inventory_scan_failed:true, table left untouched, mutated:false', (t) => {
const cwd = projectWithOnlyPhaseTableDrift();
t.after(() => cleanup(cwd));
const phasesDir = path.join(cwd, '.planning', 'phases');
const originalReaddirSync = fs.readdirSync;
const stdout = withFaultyFs({
readdirSync: (p, ...rest) => {
if (String(p) === phasesDir) {
throw Object.assign(new Error('EACCES: permission denied, scandir ' + phasesDir), { code: 'EACCES' });
}
return originalReaddirSync(p, ...rest);
},
}, () => captureStdout(() => {
stateMod.cmdStateRebuild(cwd, { dryRun: true }, false);
}));
const parsed = JSON.parse(stdout);
assert.strictEqual(parsed.phase_inventory_scan_failed, true,
`a faulted disk scan must report phase_inventory_scan_failed:true; got: ${stdout}`);
assert.strictEqual(parsed.mutated, false,
'nothing else drifted in this fixture, so mutated must stay false — the failure is carried ONLY by the dedicated field');
assert.strictEqual(parsed.phase_inventory_scan_reason,
'EACCES: permission denied, scandir ' + phasesDir,
`phase_inventory_scan_reason must carry the exact fault message from the injected readdirSync throw; got: ${JSON.stringify(parsed.phase_inventory_scan_reason)}`);
// The orphan row must survive untouched — a failed scan is not a
// trustworthy inventory to reconcile the table against.
const live = readLiveState(cwd);
const byPhaseTable = parseMarkdownTable(live);
assert.ok(byPhaseTable.ok, `By Phase table must parse; reason: ${byPhaseTable.ok ? '' : byPhaseTable.reason}`);
const phaseIds = byPhaseTable.value.rows.map((r) => r.Phase);
assert.ok(phaseIds.includes('99'),
`orphan row for phase 99 must be preserved when the scan failed; rows: ${JSON.stringify(phaseIds)}`);
});
test('BENIGN path: the same fixture with an unfaulted disk scan reconciles the table and reports no failure', (t) => {
const cwd = projectWithOnlyPhaseTableDrift();
t.after(() => cleanup(cwd));
const stdout = captureStdout(() => {
stateMod.cmdStateRebuild(cwd, { dryRun: true }, false);
});
const parsed = JSON.parse(stdout);
assert.strictEqual(parsed.phase_inventory_scan_failed, false,
`an unfaulted disk scan must report phase_inventory_scan_failed:false; got: ${stdout}`);
assert.strictEqual(parsed.mutated, true,
'the real disk scan finds phase 1 only, so the orphan row 99 is real drift the (dry-run) rebuild would fix');
assert.strictEqual(parsed.phase_inventory_scan_reason, undefined,
'a clean scan must leave phase_inventory_scan_reason absent, distinguishing it from a faulted scan');
});
});