feat(#1826): cmdStateRebuild CLI + dry-run + verbose + integration tests + docs (#1830)

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).
This commit is contained in:
Tom Boucher
2026-06-29 16:15:55 -04:00
committed by GitHub
parent b5c8a3e590
commit bad2c76c5e
6 changed files with 372 additions and 0 deletions

View File

@@ -170,6 +170,14 @@ export const STATE_COMMAND_ALIASES: CommandAlias[] = [
"subcommand": "prune",
"mutation": true
},
{
"canonical": "state.rebuild",
"aliases": [
"state rebuild"
],
"subcommand": "rebuild",
"mutation": true
},
{
"canonical": "state.milestone-switch",
"aliases": [

View File

@@ -50,6 +50,7 @@ interface StateModule {
cmdStateValidate(cwd: string, raw: boolean): void;
cmdStateSync(cwd: string, opts: { verify: string | boolean | null | undefined }, raw: boolean): void;
cmdStatePrune(cwd: string, opts: { keepRecent: string; dryRun: boolean }, raw: boolean): void;
cmdStateRebuild(cwd: string, opts: { dryRun: boolean; verbose: boolean }, raw: boolean): void;
cmdStateCompletePhase(cwd: string, raw: boolean, phase: string | null | undefined): void;
cmdStateMilestoneSwitch(cwd: string, milestone: string | null | undefined, name: string | null | undefined, raw: boolean): void;
}
@@ -190,6 +191,10 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
const a = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
},
rebuild: () => {
const a = parseNamedArgs(args, [], ['dry-run', 'verbose']);
state.cmdStateRebuild(cwd, { dryRun: a['dry-run'] === true, verbose: a['verbose'] === true }, raw);
},
// complete-phase: CJS-only — no SDK counterpart.
'complete-phase': () => {
const a = parseNamedArgs(args, ['phase']);

View File

@@ -35,6 +35,7 @@ import stateTransitionMod = require('./state-transition.cjs');
const { transitionCore, applyStatePreservation } = stateTransitionMod;
type StateTransitionIntent = stateTransitionMod.StateTransitionIntent;
type StateTransitionDeps = stateTransitionMod.StateTransitionDeps;
type PhaseInventoryRecord = stateTransitionMod.PhaseInventoryRecord;
import {
computeProgressPercent,
normalizeProgressNumbers,
@@ -109,6 +110,12 @@ interface StatePruneOptions {
silent?: boolean;
}
interface StateRebuildOptions {
dryRun?: boolean;
verbose?: boolean;
silent?: boolean;
}
interface StateSyncOptions {
verify?: boolean;
}
@@ -2571,6 +2578,113 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v
}, raw, totalPruned > 0 ? 'true' : 'false');
}
/**
* Rebuild STATE.md body structure from canonical sources (ADR-1817).
*
* Implements the `gsd state rebuild` subcommand (issue #1817 Phase 2, #1826).
* Wires the pure `rebuildCore` transition (Phase 1, #1827) to the CLI:
* - Locks via `readModifyWriteStateMd` (real path) or reads-only (dry-run).
* - Wires `phaseInventoryProvider` to a real `.planning/phases/` disk scan.
* - `--dry-run`: computes the rebuild, emits a structured diff, writes nothing.
* - `--verbose`: emits the audit-log entries to stderr (in addition to the
* `## Rebuild Log` section that `rebuildCore` already appends to STATE.md).
*
* Per ADR-1817 §5 this is the heavy/manual counterpart to the lightweight,
* auto-triggered `state sync` (3 frontmatter fields). The two compose
* non-overlappingly.
*/
function cmdStateRebuild(cwd: string, options: StateRebuildOptions, raw: boolean): void {
const silent = !!options.silent;
const emit = silent ? () => {} : (result: Record<string, unknown>, r: boolean, v?: string) => output(result, r, v);
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; }
const dryRun = !!options.dryRun;
const verbose = !!options.verbose;
// Wire phaseInventoryProvider to a real `.planning/phases/` disk scan. This
// is the same canonical source `buildStateFrontmatter` consults; the Leaky-
// Abstractions guard in `rebuildCore` (ADR-1817 §1) keeps the pure core
// testable without this dep — here we provide it.
const phaseInventoryProvider = (): PhaseInventoryRecord[] | null => {
try {
const phasesDir = path.join(planningPaths(cwd).planning, 'phases');
if (!fs.existsSync(phasesDir) || !fs.statSync(phasesDir).isDirectory()) return null;
const entries = fs.readdirSync(phasesDir);
const records: PhaseInventoryRecord[] = [];
for (const entry of entries) {
const full = path.join(phasesDir, entry);
let stat: fs.Stats;
try { stat = fs.statSync(full); } catch { continue; }
if (!stat.isDirectory()) continue;
// Directory-name convention: `<NN>-<slug>` (e.g. `03-test-phase`).
const m = entry.match(/^(\d+)-(.+)$/);
if (!m) continue;
const files = fs.readdirSync(full);
const planCount = files.filter(f => /-PLAN\.md$/i.test(f)).length;
const summaryCount = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
records.push({ number: m[1], name: m[2], planCount, summaryCount });
}
return records;
} catch {
return null;
}
};
const deps: StateTransitionDeps = {
progressProvider: () => null,
clock: realClock,
phaseInventoryProvider,
};
const runRebuild = (content: string) => transitionCore(content, { kind: 'rebuild' }, deps);
const emitVerboseLog = (log: unknown): void => {
if (!verbose || !Array.isArray(log)) return;
for (const entry of log) {
// Treat user-data as data-only (ADR-1577 untrusted-input-boundary).
process.stderr.write(`[rebuild] ${JSON.stringify(entry)}\n`);
}
};
if (dryRun) {
const content = fs.readFileSync(statePath, 'utf-8');
const result = runRebuild(content);
const data = (result.data ?? {}) as { log?: unknown[]; mutated?: boolean };
emitVerboseLog(data.log);
const mutated = data.mutated === true;
emit({
rebuilt: false,
dry_run: true,
mutations: Array.isArray(data.log) ? data.log.length : 0,
mutated,
note: mutated ? 'Run without --dry-run to apply changes' : 'Nothing to rebuild',
}, raw, mutated ? 'true' : 'false');
return;
}
// Real path: lock + RMW via the existing seam. The rebuild log is captured
// so we can emit it to stderr under --verbose (the section is also written
// to STATE.md by rebuildCore itself, per ADR-1817 §3).
let capturedLog: unknown[] = [];
let capturedMutated = false;
readModifyWriteStateMd(statePath, (content: string) => {
const result = runRebuild(content);
const data = (result.data ?? {}) as { log?: unknown[]; mutated?: boolean };
capturedLog = Array.isArray(data.log) ? data.log : [];
capturedMutated = data.mutated === true;
return result.content;
}, cwd);
emitVerboseLog(capturedLog);
emit({
rebuilt: capturedMutated,
mutations: capturedLog.length,
note: capturedMutated ? 'STATE.md rebuilt; see ## Rebuild Log section for the audit trail' : 'Nothing to rebuild',
}, raw, capturedMutated ? 'true' : 'false');
}
/**
* Mark the current phase as COMPLETE in STATE.md.
* Updates Status, Last Activity, and the Current Position section to reflect
@@ -2749,6 +2863,7 @@ export = {
cmdStateValidate,
cmdStateSync,
cmdStatePrune,
cmdStateRebuild,
cmdStateMilestoneSwitch,
cmdSignalWaiting,
cmdSignalResume,