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:
@@ -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": [
|
||||
|
||||
@@ -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']);
|
||||
|
||||
115
src/state.cts
115
src/state.cts
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user