feat(#1827): rebuildCore + rebuild intent + drift-class unit tests (#1829)

Phase 1 of approved feature #1817. Implements the body-structure
derivability contract landed in ADR-1817 (Phase 0, PR #1828).

Source changes (src/state-transition.cts):
- Add `rebuild` as the 11th intent in StateTransitionIntent (ADR-1769
  transition set extended per ADR-1817 §1).
- Add `phaseInventoryProvider` optional dep + PhaseInventoryRecord type
  (Leaky-Abstractions guard: pure core stays testable without disk I/O;
  rebuild skips table reconciliation when the provider is absent).
- Add `case 'rebuild':` dispatch arm to transitionCore (the missing-case
  compile-time guarantee extends to the 11th case).
- Implement rebuildCore orchestrator + four drift-class helpers per
  ADR-1817 §2:
    * reconcileCurrentPosition — body prose re-derived from frontmatter
    * reconcileByPhaseTable — **By Phase:** table re-derived from disk
      inventory via the new dep
    * stripTemplatePlaceholders — `**Field:** [placeholder]` →
      `**Field:** (pending)` (no canonical source available)
    * deduplicateSessionArchive — keep most-recent 3 archived H3 blocks
- Implement appendRebuildLogSection per ADR-1817 §3 — every mutation
  appends a structured entry (timestamp/kind/section/before/after/reason)
  to `## Rebuild Log`. Idempotency guarantee (ADR-1817 §4): a no-mutation
  rebuild appends NO log entry, so two successive runs on a clean file
  are byte-identical.

Compiled output (gsd-core/bin/lib/state-transition.cjs): regenerated via
`npm run build:lib` (tsc -p tsconfig.build.json).

Tests (tests/state-rebuild.test.cjs, 18 cases):
- Dispatch + idempotency contract (3 tests, including the load-bearing
  'rebuild on a clean file is a no-op' that pins §4).
- Current Position prose reconciliation, criterion #1 (3 tests).
- Template-placeholder removal, criterion #3 (3 tests).
- Session Continuity Archive de-duplication, criterion #4 (4 tests).
- **By Phase:** table reconciliation via phaseInventoryProvider, criterion
  #2 (3 tests, including the Leaky-Abstractions no-op guard).
- Regression guard for sync + prune, criterion #7 (2 tests).

Verified: node --test tests/state-rebuild.test.cjs → 18/18 pass.
Verified: node --test tests/state-transition.test.cjs → 85/85 pass (no
regression on the existing 10 transitions).

Phase 2 (#1826) wires the CLI surface (cmdStateRebuild + --dry-run +
integration tests + docs + changeset). This PR adds the engine only — no
user-visible command yet, so no-changelog label applied.
This commit is contained in:
Tom Boucher
2026-06-29 15:59:37 -04:00
committed by GitHub
parent dfff25f1bd
commit b5c8a3e590
3 changed files with 1289 additions and 1 deletions

View File

@@ -263,6 +263,26 @@ export type StateTransitionDeps = {
* pure and testable without disk I/O.
*/
roadmapProvider?: () => string | null;
/**
* Phase-inventory provider for `rebuild` (ADR-1817 §2): re-derives the
* `## By-Phase Progress` table from canonical disk sources. Returns one
* record per on-disk phase directory under `.planning/phases/`. Optional:
* when absent, `rebuild` skips table reconciliation (Leaky-Abstractions
* guard — the core stays pure and testable without disk I/O).
*/
phaseInventoryProvider?: () => PhaseInventoryRecord[] | null;
};
/**
* One on-disk phase record (ADR-1817). The `rebuild` transition consumes
* these to re-derive the `## By-Phase Progress` table; the adapter wires
* this to the same disk scan `buildStateFrontmatter` uses.
*/
export type PhaseInventoryRecord = {
number: string;
name: string;
planCount: number;
summaryCount: number;
};
export type StateTransitionIntent =
@@ -301,8 +321,12 @@ export type StateTransitionIntent =
totalPlansInPhase: number | null;
/** Recomputed progress percent (0-100), or null when it must be left untouched (#1761). */
percent: number | null;
}
| {
kind: 'rebuild';
};
// Phase 7 closes out the discriminated union (all 10 lifecycle/maintenance intents).
// Phase 7 closed the union for the 10 ADR-1769 intents. ADR-1817 adds `rebuild`
// as the 11th capstone transition (body-structure derivability contract).
export type StateTransitionResult = {
content: string;
@@ -351,6 +375,8 @@ export function transitionCore(
return pruneCore(content, intent);
case 'sync':
return syncCore(content, intent, deps);
case 'rebuild':
return rebuildCore(content, intent, deps);
}
}
@@ -1521,3 +1547,434 @@ function syncCore(
return { content: modified, updated, data: { changes } };
}
// ----------------------------------------------------------------------------
// rebuild — intent implementation (ADR-1817, capstone 11th transition)
// ----------------------------------------------------------------------------
//
// Implements the body-structure derivability contract (ADR-1817 §2–§6):
// - §2 re-derives derived sections (## Current Position prose, By Phase table
// inside ## Performance Metrics), preserves curated sections verbatim
// (## Accumulated Context, ## Deferred Items, ## Project Reference, ##
// Session Continuity's prose fields) and unknown sections.
// - §3 every mutation appends a structured entry to ## Rebuild Log
// (ADR-1411 provenance principle — never drop silently).
// - §4 idempotency: a no-mutation rebuild appends NO log entry, so two
// successive runs on a clean file are byte-identical.
// - §5 non-overlapping with sync (sync = 3 frontmatter fields, lightweight,
// auto-triggered; rebuild = body structure, heavier, manual).
// - §6 orthogonal to auto_prune_state (rebuild reconciles with current
// canonical sources; prune removes by retention policy).
//
// Section ordering is invariant: rebuild rewrites content IN PLACE; it does
// not reorder, insert (other than ## Rebuild Log when absent), or remove
// sections.
const REBUILD_LOG_SECTION = '## Rebuild Log';
const REBUILD_LOG_TRUNCATION_LIMIT = 512;
type RebuildLogEntryKind =
| 'placeholder-removed'
| 'current-position-reconciled'
| 'by-phase-table-reconciled'
| 'session-archive-deduplicated';
interface RebuildLogEntry {
timestamp: string;
kind: RebuildLogEntryKind;
section: string;
before: string;
after: string;
reason: string;
}
/**
* Truncate a string for inclusion in a rebuild log entry. Per ADR-1817 §3 the
* `before` / `after` fields are bounded to REBUILD_LOG_TRUNCATION_LIMIT chars
* to prevent unbounded log growth when the drifted content is large.
*/
function truncateForLog(s: string): string {
if (s.length <= REBUILD_LOG_TRUNCATION_LIMIT) return s;
return s.slice(0, REBUILD_LOG_TRUNCATION_LIMIT - 3) + '...';
}
/**
* Apply a `rebuild` transition to STATE.md content. Pure core per ADR-1769 §3
* and ADR-1817 §1. Returns `{ content, updated, data }` where `data.mutated`
* is false when no drift was found (idempotency contract, ADR-1817 §4).
*/
function rebuildCore(
content: string,
_intent: { kind: 'rebuild' },
deps: StateTransitionDeps,
): StateTransitionResult {
const timestamp = deps.clock.nowIso();
const log: RebuildLogEntry[] = [];
let modified = content;
// §2 Decision: re-derive derived sections, preserve others. Order is
// oldest-section-first so log entries appear in body order.
modified = reconcileCurrentPosition(modified, timestamp, log);
modified = reconcileByPhaseTable(modified, deps, timestamp, log);
modified = stripTemplatePlaceholders(modified, timestamp, log);
modified = deduplicateSessionArchive(modified, timestamp, log);
// §3 + §4: append the audit log ONLY when mutations occurred. The
// log-appends-only-on-mutation rule is what makes idempotency byte-identical
// (without it, the second invocation would always append a no-op entry).
if (log.length > 0) {
modified = appendRebuildLogSection(modified, log);
}
const updated = log.length > 0 ? ['rebuild'] : [];
return {
content: modified,
updated,
data: {
mutated: log.length > 0,
mutations: log.length,
log,
},
};
}
/**
* §2 — re-derive `## Current Position` prose fields from frontmatter.
*
* Drift class: `Phase:`, `Status:` etc. in body contradict frontmatter after
* a milestone switch or prune (epic #1817). The body prose is re-derivable
* because `buildStateFrontmatter` already derives the canonical values from
* disk; rebuild pushes those back into the body prose.
*
* Implementation: pull each canonical value from frontmatter and replace the
* body field via `stateReplaceField`. Skip silently when frontmatter lacks
* the key (Leaky-Abstractions guard — don't synthesize values the canonical
* source doesn't have).
*/
function reconcileCurrentPosition(
content: string,
timestamp: string,
log: RebuildLogEntry[],
): string {
const fm = extractFrontmatter(content) as Record<string, unknown>;
if (!fm || typeof fm !== 'object') return content;
let modified = content;
// Phase prose: frontmatter `current_phase` overrides body `**Current Phase:**`.
// The body `Phase:` prose line (e.g. "Phase: 3 of 12 (Test Phase)") is owned
// by other transitions (beginPhase / completePhase) and reconstructed from
// total-phase counts; rebuild reconciles only the `**Current Phase:**` body
// field that frontmatter is the canonical source for.
if (fm.current_phase !== undefined && fm.current_phase !== null) {
const canonicalPhase = String(fm.current_phase);
const existing = stateExtractField(modified, 'Current Phase');
if (existing !== null && existing !== canonicalPhase) {
const replaced = stateReplaceField(modified, 'Current Phase', canonicalPhase);
if (replaced !== null) {
modified = replaced;
log.push({
timestamp,
kind: 'current-position-reconciled',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(existing),
after: truncateForLog(canonicalPhase),
reason: "frontmatter 'current_phase' is canonical; body 'Current Phase' was stale",
});
}
}
}
// Phase name prose.
if (fm.current_phase_name !== undefined && fm.current_phase_name !== null) {
const canonicalName = String(fm.current_phase_name);
const existing = stateExtractField(modified, 'Current Phase Name');
if (existing !== null && existing !== canonicalName) {
const replaced = stateReplaceField(modified, 'Current Phase Name', canonicalName);
if (replaced !== null) {
modified = replaced;
log.push({
timestamp,
kind: 'current-position-reconciled',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(existing),
after: truncateForLog(canonicalName),
reason: "frontmatter 'current_phase_name' is canonical; body 'Current Phase Name' was stale",
});
}
}
}
return modified;
}
/**
* §2 — re-derive the `**By Phase:**` table inside `## Performance Metrics`
* from the injected `phaseInventoryProvider`. Drift class: orphaned rows for
* phases from a prior milestone, or zero-padded phase IDs that were renamed
* (epic #1817).
*
* Leaky-Abstractions guard (ADR-1817 §1): when `phaseInventoryProvider` is
* absent (no disk scan wired), this step is a no-op. The core stays pure and
* testable without disk I/O.
*/
function reconcileByPhaseTable(
content: string,
deps: StateTransitionDeps,
timestamp: string,
log: RebuildLogEntry[],
): string {
if (!deps.phaseInventoryProvider) return content;
const inventory = deps.phaseInventoryProvider();
if (!inventory || inventory.length === 0) return content;
// The canonical table shape (from gsd-core/templates/state.md):
// | Phase | Plans | Total | Avg/Plan |
// |-------|-------|-------|----------|
// | - | - | - | - |
// rebuild renders one row per inventory record (Phase N: P plans). The
// Total/Avg columns are runtime-collected by other commands; rebuild does
// NOT re-derive them and resets them to '-' so future plan-completion
// repopulates. The canonical reconciliation target is the row SET.
const tableRows = inventory.map((r) => `| ${r.number} | ${r.planCount} | - | - |`);
const canonicalTable = [
'| Phase | Plans | Total | Avg/Plan |',
'|-------|-------|-------|----------|',
...tableRows,
];
// Line-based splice: find `**By Phase:**` line, then walk forward collecting
// the table block (header + separator + body rows), replace the block with
// the canonical table preceded by a single blank-line separator.
const lines = content.split('\n');
const markerIdx = lines.findIndex((l) => l.trim() === '**By Phase:**');
if (markerIdx === -1) return content; // unknown shape — preserve verbatim
// Walk forward from markerIdx+1 to find the table block span. Skip leading
// blank lines; once we see the first table row, consume subsequent table
// rows; stop at the first non-table line after we've started.
let blockStart = -1;
let blockEnd = -1;
for (let i = markerIdx + 1; i < lines.length; i++) {
const trimmed = lines[i].trim();
const isTable = trimmed.startsWith('|') && trimmed.endsWith('|');
if (blockStart === -1) {
if (isTable) { blockStart = i; blockEnd = i + 1; }
else if (trimmed === '') continue;
else break; // non-table, non-blank before any row — unknown shape
} else {
if (isTable) blockEnd = i + 1;
else break;
}
}
if (blockStart === -1) return content; // no table found
// Replace lines[blockStart..blockEnd) with canonicalTable.
const beforeBlock = lines.slice(0, markerIdx + 1);
const afterBlock = lines.slice(blockEnd);
// Splice: `**By Phase:**` + blank + canonicalTable rows + (whatever came after)
const newLines = [...beforeBlock, '', ...canonicalTable, ...afterBlock];
const candidate = newLines.join('\n');
if (candidate === content) return content;
log.push({
timestamp,
kind: 'by-phase-table-reconciled',
section: STATE_MD_SECTIONS.performanceMetrics,
before: truncateForLog(lines.slice(blockStart, blockEnd).join('\n')),
after: truncateForLog(canonicalTable.join('\n')),
reason: 'phase dirs on disk are canonical; rows for missing phases dropped, missing phases added',
});
return candidate;
}
/**
* §2 + epic-#1817 drift class — template-placeholder field values left in
* place when an AI agent wrote partial state. The canonical template uses
* `[X]`, `[Y]`, `[Phase name]`, `[date]`, `[N]`, etc. (see
* `gsd-core/templates/state.md`). Rebuild clears any `**Field:** [placeholder]`
* line where the value still matches the placeholder shape.
*
* "Clears" means: leaves the field in place with the literal text `(pending)`,
* signalling that rebuild recognized the placeholder but had no canonical
* source to substitute. This is honest — better than silently leaving `[X]`
* which looks like a value.
*/
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/;
function stripTemplatePlaceholders(
content: string,
timestamp: string,
log: RebuildLogEntry[],
): string {
// Scan body `**Field:** value` lines; when value matches the placeholder
// shape, replace with `(pending)`. We deliberately do NOT touch fields that
// other transitions actively maintain (syncCore's three, beginPhase's set,
// etc.) — only the template placeholder rows that nothing has touched.
const lines = content.split('\n');
const replacements: Array<{ lineIdx: number; before: string; after: string; fieldName: string }> = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(/^\s*\*\*([^*]+):\*\*\s*(.*)$/);
if (!m) continue;
const fieldName = m[1];
const value = m[2];
if (TEMPLATE_PLACEHOLDER_VALUE.test(value)) {
const placeholder = value.trim();
const cleared = `**${fieldName}:** (pending)`;
replacements.push({ lineIdx: i, before: line, after: cleared, fieldName });
}
}
if (replacements.length === 0) return content;
for (const r of replacements) {
lines[r.lineIdx] = r.after;
log.push({
timestamp,
kind: 'placeholder-removed',
section: STATE_MD_SECTIONS.currentPosition,
before: truncateForLog(r.before.trim()),
after: truncateForLog(r.after),
reason: `field ${JSON.stringify(r.fieldName)} still carried template placeholder ${JSON.stringify(r.before.match(/\*\*[^*]+:\*\*\s*(.*)$/)?.[1]?.trim() ?? '')}; no canonical source available — replaced with (pending)`,
});
}
return lines.join('\n');
}
/**
* §2 + epic-#1817 drift class — duplicate `## Session Continuity Archive`
* blocks from repeated `state record-session` calls on a corrupt file. The
* canonical template has one `## Session Continuity` section; archived blocks
* may accumulate as `### Session — <timestamp>` H3 sub-sections under it.
* Rebuild keeps the most-recent N (default 3) and drops older duplicates,
* logging each drop.
*
* Conservative scope: only acts when the section has more than 3 H3
* `### Session —` sub-headings; otherwise it's a no-op (preserve verbatim).
*/
const DEFAULT_MAX_SESSION_ARCHIVES = 3;
// `tokenizeHeadings` strips leading `#` markers — `h.text` for `### Session — X`
// is just `Session — X`. Match the bare heading text.
const SESSION_ARCHIVE_H3 = /^Session\s+—/;
function deduplicateSessionArchive(
content: string,
timestamp: string,
log: RebuildLogEntry[],
): string {
const hs = tokenizeHeadings(content);
// Find `## Session Continuity` H2.
const sectionIdx = hs.findIndex((h) => h.level === 2 && h.text === 'Session Continuity');
if (sectionIdx === -1) return content;
// Find the section span: from this H2's offset to the next H2 (or EOF).
const sectionStart = hs[sectionIdx].offset;
let sectionEnd = content.length;
for (let i = sectionIdx + 1; i < hs.length; i++) {
if (hs[i].level === 2) { sectionEnd = hs[i].offset; break; }
}
const sectionContent = content.slice(sectionStart, sectionEnd);
// Count `### Session — …` H3 sub-headings inside the section.
const archiveHeadings = hs.filter(
(h) => h.level === 3 && h.offset >= sectionStart && h.offset < sectionEnd && SESSION_ARCHIVE_H3.test(h.text),
);
if (archiveHeadings.length <= DEFAULT_MAX_SESSION_ARCHIVES) return content;
// Keep the most-recent N by offset (last N in document order; if timestamps
// in the H3 text are in chronological order — the template convention —
// last-N == most-recent-N).
const dropCount = archiveHeadings.length - DEFAULT_MAX_SESSION_ARCHIVES;
const toDrop = archiveHeadings.slice(0, dropCount);
// Compute the byte spans to drop: each archived H3 spans from its offset to
// the next H3 (or to sectionEnd). Drop with one preceding blank line so we
// don't leave a dangling separator.
let mutated = content;
// Process from the bottom up so offsets don't shift mid-edit.
for (let i = toDrop.length - 1; i >= 0; i--) {
const h = toDrop[i];
let spanEnd = sectionEnd;
// Find next H3 at-or-after h.offset (within the section).
for (const candidate of hs) {
if (candidate.level === 3 && candidate.offset > h.offset && candidate.offset < sectionEnd) {
spanEnd = candidate.offset;
break;
}
}
const dropStart = h.offset;
const before = mutated.slice(0, dropStart);
const after = mutated.slice(spanEnd);
const droppedText = mutated.slice(dropStart, spanEnd);
mutated = before + after;
log.push({
timestamp,
kind: 'session-archive-deduplicated',
section: STATE_MD_SECTIONS.sessionContinuity,
before: truncateForLog(droppedText),
after: '',
reason: `archived session ${JSON.stringify(h.text)} exceeded the ${DEFAULT_MAX_SESSION_ARCHIVES}-most-recent retention; dropped`,
});
}
return mutated;
}
/**
* §3 — append a structured audit entry to `## Rebuild Log`. Per ADR-1817 §3
* the section is created if absent; existing entries are preserved verbatim
* (append-only).
*
* Format (yaml-ish, human-readable, machine-parseable):
*
* ## Rebuild Log
*
* - timestamp: 2026-06-29T19:30:00Z
* kind: placeholder-removed
* section: ## Current Position
* before: ...
* after: ...
* reason: ...
*/
function appendRebuildLogSection(content: string, entries: RebuildLogEntry[]): string {
const lines = content.split('\n');
// Render the new entry block.
const rendered: string[] = [];
for (const e of entries) {
rendered.push(`- timestamp: ${e.timestamp}`);
rendered.push(` kind: ${e.kind}`);
rendered.push(` section: ${e.section}`);
rendered.push(` before: ${e.before.replace(/\n/g, ' \\n ')}`);
rendered.push(` after: ${e.after.replace(/\n/g, ' \\n ')}`);
rendered.push(` reason: ${e.reason.replace(/\n/g, ' \\n ')}`);
}
// Locate an existing `## Rebuild Log` section.
const sectionHeaderIdx = lines.findIndex((l) => l.trim() === REBUILD_LOG_SECTION);
if (sectionHeaderIdx === -1) {
// Create the section at end-of-file, separated by a blank line.
const needsLeadingBlank = lines.length > 0 && lines[lines.length - 1].trim() !== '';
const trailer = needsLeadingBlank ? ['', REBUILD_LOG_SECTION, '', ...rendered] : [REBUILD_LOG_SECTION, '', ...rendered];
return [...lines, ...trailer].join('\n');
}
// Append to the existing section. Find the end of the existing log entries
// (walk forward until the next H2 or EOF). Insert before that boundary.
let insertAt = sectionHeaderIdx + 1;
while (insertAt < lines.length) {
const l = lines[insertAt];
if (/^##\s/.test(l)) break;
insertAt++;
}
// Preserve a blank-line separator before the new entries if the prior line
// is non-blank and non-header.
const sep: string[] = [];
if (insertAt > 0 && lines[insertAt - 1].trim() !== '' && lines[insertAt - 1].trim() !== REBUILD_LOG_SECTION) {
sep.push('');
}
const next = [...lines.slice(0, insertAt), ...sep, ...rendered, ...lines.slice(insertAt)];
return next.join('\n');
}