Phase 2 of approved feature #1817. Wires the pure `rebuildCore` transition (Phase 1, #1827) to the `gsd state rebuild` CLI subcommand per ADR-1817 §5 (heavy/manual counterpart to lightweight, auto-triggered `state sync`). Source changes: - src/state.cts: implement cmdStateRebuild. Locks via readModifyWriteStateMd (real path) or read-only (dry-run). Wires phaseInventoryProvider to a real .planning/phases/ disk scan (same canonical source buildStateFrontmatter uses). --dry-run emits a structured preview without writing. --verbose tees the audit-log entries to stderr (treated as data-only per ADR-1577). - src/state-command-router.cts: register cmdStateRebuild in the StateModule interface + add the rebuild handler with --dry-run and --verbose flag parsing. - src/command-aliases.cts: register the state.rebuild canonical + 'state rebuild' alias + mutation=true (so the manifest covers the new subcommand for SDK parity / dispatch hub tests). Tests (tests/state-rebuild-cli.test.cjs, 5 cases): - state rebuild with no flags reconciles drifted body + drops orphan table rows + appends audit log (end-to-end #1, #2, audit log). - state rebuild --dry-run computes the diff, writes nothing (criterion #5). - state rebuild --verbose tees the log; audit-log section still written. - Running rebuild twice on the just-rebuilt file is byte-identical (criterion #6 end-to-end). - Missing STATE.md produces a clean 'STATE.md not found' message, no stack trace (CONTRIBUTING QA matrix). Verified locally: - node --test tests/state-rebuild-cli.test.cjs → 5/5 pass - node --test tests/state-rebuild.test.cjs → 18/18 pass (Phase 1 regression) - node --test tests/state-transition.test.cjs → 85/85 pass (ADR-1769 regression) Docs (docs/COMMANDS.md): document `state rebuild [--dry-run] [--verbose]` with the canonical-command block format used by `state sync` / `state prune`. Changeset (.changeset/1817-state-rebuild.md): type=Added, user-facing description of the new subcommand (closes #1817 epic on merge).
218 lines
8.4 KiB
JavaScript
218 lines
8.4 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 { execFileSync } = require('node:child_process');
|
|
|
|
const {
|
|
createTempProject,
|
|
cleanup,
|
|
runGsdTools,
|
|
} = require('./helpers.cjs');
|
|
|
|
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-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 = [
|
|
'---',
|
|
'gsd_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.
|
|
return content.replace(/^## Rebuild Log[\s\S]*$/m, '');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 = runGsdTools('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.ok(live.includes('**Current Phase:** 2'),
|
|
'body Current Phase must be reconciled to frontmatter value 2');
|
|
assert.ok(live.includes('**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).
|
|
assert.ok(!live.includes('| 99 |'),
|
|
'orphan row for phase 99 must be dropped (phaseInventoryProvider wired to disk scan)');
|
|
|
|
// Audit log appended.
|
|
const fullState = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
|
|
assert.ok(fullState.includes('## Rebuild Log'),
|
|
'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 = runGsdTools('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).
|
|
assert.ok(result.output.includes('mutated'),
|
|
`dry-run output should report mutated flag; output: ${result.output}`);
|
|
});
|
|
|
|
test('`state rebuild --verbose` emits audit-log entries to stderr', (t) => {
|
|
const cwd = projectWithDriftedState();
|
|
t.after(() => cleanup(cwd));
|
|
|
|
// runGsdTools returns stdout only on success; --verbose writes to stderr,
|
|
// so invoke gsd-tools directly to capture both streams separately.
|
|
const stdout = execFileSync(
|
|
process.execPath,
|
|
[TOOLS_PATH, 'state', 'rebuild', '--verbose'],
|
|
{ cwd, encoding: 'utf8' },
|
|
);
|
|
// execFileSync does not separate stderr — stderr is inherited by default.
|
|
// 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(after.includes('## Rebuild Log'),
|
|
'--verbose must still produce the audit log section in STATE.md');
|
|
assert.ok(stdout.includes('rebuilt'),
|
|
`--verbose stdout must include the rebuild result; 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 = runGsdTools('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 = runGsdTools('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 = runGsdTools('state rebuild', cwd);
|
|
// The command emits an error result but does not crash the process.
|
|
const combined = `${result.output}\n${result.error || ''}`;
|
|
assert.ok(combined.includes('STATE.md not found'),
|
|
'missing STATE.md should produce a clean "STATE.md not found" message');
|
|
assert.ok(!combined.includes('at Object.'),
|
|
'no raw stack trace should leak into the output (CONTRIBUTING QA Matrix)');
|
|
});
|
|
});
|