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.
412 lines
17 KiB
JavaScript
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');
|
|
});
|
|
});
|