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:
@@ -192,6 +192,8 @@ function transitionCore(content, intent, deps) {
|
||||
return pruneCore(content, intent);
|
||||
case 'sync':
|
||||
return syncCore(content, intent, deps);
|
||||
case 'rebuild':
|
||||
return rebuildCore(content, intent, deps);
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -1202,3 +1204,385 @@ function syncCore(content, intent, deps) {
|
||||
}
|
||||
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;
|
||||
/**
|
||||
* 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) {
|
||||
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, _intent, deps) {
|
||||
const timestamp = deps.clock.nowIso();
|
||||
const log = [];
|
||||
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, timestamp, log) {
|
||||
const fm = extractFrontmatter(content);
|
||||
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 = (0, state_document_cjs_1.stateExtractField)(modified, 'Current Phase');
|
||||
if (existing !== null && existing !== canonicalPhase) {
|
||||
const replaced = (0, state_document_cjs_1.stateReplaceField)(modified, 'Current Phase', canonicalPhase);
|
||||
if (replaced !== null) {
|
||||
modified = replaced;
|
||||
log.push({
|
||||
timestamp,
|
||||
kind: 'current-position-reconciled',
|
||||
section: exports.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 = (0, state_document_cjs_1.stateExtractField)(modified, 'Current Phase Name');
|
||||
if (existing !== null && existing !== canonicalName) {
|
||||
const replaced = (0, state_document_cjs_1.stateReplaceField)(modified, 'Current Phase Name', canonicalName);
|
||||
if (replaced !== null) {
|
||||
modified = replaced;
|
||||
log.push({
|
||||
timestamp,
|
||||
kind: 'current-position-reconciled',
|
||||
section: exports.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, deps, timestamp, log) {
|
||||
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: exports.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, timestamp, log) {
|
||||
// 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 = [];
|
||||
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: exports.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, timestamp, log) {
|
||||
const hs = (0, markdown_sectionizer_cjs_1.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: exports.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, entries) {
|
||||
const lines = content.split('\n');
|
||||
// Render the new entry block.
|
||||
const rendered = [];
|
||||
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 = [];
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
447
tests/state-rebuild.test.cjs
Normal file
447
tests/state-rebuild.test.cjs
Normal file
@@ -0,0 +1,447 @@
|
||||
'use strict';
|
||||
|
||||
// Phase 1 tests for the `rebuild` transition (ADR-1817).
|
||||
//
|
||||
// Covers the four drift classes from epic #1817:
|
||||
// #1 ## Current Position prose contradicts frontmatter
|
||||
// #2 ## Performance Metrics → **By Phase:** table has orphaned rows
|
||||
// #3 Template-placeholder field values ([X], [date], etc.) left in place
|
||||
// #4 Duplicate ## Session Continuity archived-session H3 blocks
|
||||
//
|
||||
// Plus the cross-cutting contracts:
|
||||
// #6 Idempotency: rebuild twice on a clean file = byte-identical
|
||||
// #7 Regression guard: sync/prune unchanged when rebuild is not invoked
|
||||
//
|
||||
// Discipline (CONTRIBUTING.md): tests assert on typed structured values via
|
||||
// the public `transitionCore` API, never on rendered text via raw grep.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
transitionCore,
|
||||
} = require('../gsd-core/bin/lib/state-transition.cjs');
|
||||
const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
|
||||
const fixedClock = Object.freeze({
|
||||
today: () => '2026-06-29',
|
||||
nowIso: () => '2026-06-29T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const noProgress = () => null;
|
||||
const noPhases = () => null;
|
||||
|
||||
const baseDeps = Object.freeze({
|
||||
progressProvider: noProgress,
|
||||
clock: fixedClock,
|
||||
phaseInventoryProvider: noPhases,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A clean, fully-reconciled STATE.md body — the canonical post-rebuild shape.
|
||||
* Used as the starting point for drift fixtures and as the idempotency
|
||||
* baseline (running rebuild on this must produce no mutation).
|
||||
*/
|
||||
function cleanState() {
|
||||
return [
|
||||
'---',
|
||||
'gsd_state_version: \'1.0\'',
|
||||
'status: executing',
|
||||
'milestone: 1.0.0',
|
||||
'milestone_name: Test Milestone',
|
||||
'current_phase: 3',
|
||||
'current_phase_name: Test Phase',
|
||||
'current_plan: 2',
|
||||
'progress:',
|
||||
' total_phases: 5',
|
||||
' completed_phases: 2',
|
||||
' total_plans: 10',
|
||||
' completed_plans: 4',
|
||||
' percent: 40',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'## Project Reference',
|
||||
'',
|
||||
'See: .planning/PROJECT.md (updated 2026-06-01)',
|
||||
'',
|
||||
'**Core value:** A test project',
|
||||
'**Current focus:** Test Phase',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'**Current Phase:** 3',
|
||||
'**Current Phase Name:** Test Phase',
|
||||
'**Current Plan:** 2',
|
||||
'**Total Plans in Phase:** 5',
|
||||
'**Status:** executing',
|
||||
'**Last Activity:** 2026-06-29',
|
||||
'**Last Activity Description:** mid-flight context from plan 3-02',
|
||||
'',
|
||||
'Phase: 3 of 5 (Test Phase)',
|
||||
'Plan: 2 of 5',
|
||||
'Status: Executing Phase 3',
|
||||
'Last activity: 2026-06-29 — mid-flight context',
|
||||
'',
|
||||
'**Progress:** [████░░░░░░] 40%',
|
||||
'',
|
||||
'## Performance Metrics',
|
||||
'',
|
||||
'**By Phase:**',
|
||||
'',
|
||||
'| Phase | Plans | Total | Avg/Plan |',
|
||||
'|-------|-------|-------|----------|',
|
||||
'| 1 | 2 | - | - |',
|
||||
'| 2 | 3 | - | - |',
|
||||
'| 3 | 5 | - | - |',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
'- Phase 1: chose option A',
|
||||
'- Phase 2: chose option B',
|
||||
'',
|
||||
'### Pending Todos',
|
||||
'',
|
||||
'None yet.',
|
||||
'',
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'| Category | Item | Status | Deferred At |',
|
||||
'|----------|------|--------|-------------|',
|
||||
'| *(none)* | | | |',
|
||||
'',
|
||||
'## Session Continuity',
|
||||
'',
|
||||
'Last session: 2026-06-29 12:00',
|
||||
'Stopped at: mid-flight context',
|
||||
'Resume file: None',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Drift fixture #1: body `**Current Phase:**` and `**Current Phase Name:**`
|
||||
* contradict frontmatter (e.g. after a milestone switch). */
|
||||
function driftedCurrentPosition() {
|
||||
// Take the clean state and inject stale body prose.
|
||||
const c = cleanState();
|
||||
return c
|
||||
.replace('**Current Phase:** 3', '**Current Phase:** 2')
|
||||
.replace('**Current Phase Name:** Test Phase', '**Current Phase Name:** Old Phase Name');
|
||||
}
|
||||
|
||||
/** Drift fixture #3: template placeholder values left in body fields. */
|
||||
function driftedPlaceholders() {
|
||||
const c = cleanState();
|
||||
// Inject placeholders into a couple of fields. Don't touch the fields
|
||||
// syncCore actively maintains (Last Activity) — those would be reconciled
|
||||
// by sync, not rebuild.
|
||||
return c
|
||||
.replace('**Current focus:** Test Phase', '**Current focus:** [Current phase name]')
|
||||
.replace('See: .planning/PROJECT.md (updated 2026-06-01)', 'See: .planning/PROJECT.md (updated [date])');
|
||||
}
|
||||
|
||||
/** Count LIVE `### Session —` headings, excluding any occurrences inside the
|
||||
* `## Rebuild Log` audit section (the log's `before:` field captures dropped
|
||||
* content verbatim, which would otherwise inflate the count). */
|
||||
function countLiveSessionHeadings(content) {
|
||||
// Strip everything from `## Rebuild Log` to EOF, then count.
|
||||
const stripped = content.replace(/^## Rebuild Log[\s\S]*$/m, '');
|
||||
return (stripped.match(/^###\s+Session\s+—/gm) || []).length;
|
||||
}
|
||||
|
||||
/** Drift fixture #4: six duplicate `### Session —` archived blocks under
|
||||
* `## Session Continuity` (more than the default 3-most-recent retention). */
|
||||
function driftedSessionArchiveDuplicates() {
|
||||
// Replace the canonical short Session Continuity block with one that has
|
||||
// six archived sub-blocks.
|
||||
const c = cleanState();
|
||||
const archiveBlock = [
|
||||
'## Session Continuity',
|
||||
'',
|
||||
'Last session: 2026-06-29 12:00',
|
||||
'Stopped at: mid-flight context',
|
||||
'Resume file: None',
|
||||
'',
|
||||
'### Session — 2026-06-20',
|
||||
'',
|
||||
'oldest session — should be dropped',
|
||||
'',
|
||||
'### Session — 2026-06-22',
|
||||
'',
|
||||
'second-oldest — should be dropped',
|
||||
'',
|
||||
'### Session — 2026-06-25',
|
||||
'',
|
||||
'third — kept',
|
||||
'',
|
||||
'### Session — 2026-06-27',
|
||||
'',
|
||||
'fourth — kept',
|
||||
'',
|
||||
'### Session — 2026-06-28',
|
||||
'',
|
||||
'fifth — kept',
|
||||
'',
|
||||
'### Session — 2026-06-29',
|
||||
'',
|
||||
'sixth — kept',
|
||||
'',
|
||||
].join('\n');
|
||||
return c.replace(/## Session Continuity[\s\S]*$/, archiveBlock);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — dispatch + idempotency contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ADR-1817 `rebuild` intent: dispatch + idempotency (§1, §4)', () => {
|
||||
test('transitionCore dispatches `rebuild` without throwing', () => {
|
||||
const result = transitionCore(cleanState(), { kind: 'rebuild' }, baseDeps);
|
||||
assert.ok(result, 'rebuild must return a result');
|
||||
assert.ok(Array.isArray(result.updated), 'updated must be an array');
|
||||
assert.ok(result.data && typeof result.data === 'object', 'data must be an object');
|
||||
});
|
||||
|
||||
test('rebuild on a clean file is a no-op: content byte-identical, no log, no `updated`', () => {
|
||||
const clean = cleanState();
|
||||
const result = transitionCore(clean, { kind: 'rebuild' }, baseDeps);
|
||||
assert.strictEqual(result.content, clean, 'content must be byte-identical on a clean file');
|
||||
assert.deepStrictEqual(result.updated, [], 'no fields should be marked updated on a clean file');
|
||||
assert.strictEqual(result.data && result.data.mutated, false, 'mutated flag must be false');
|
||||
assert.strictEqual(result.content.includes('## Rebuild Log'), false,
|
||||
'a no-op rebuild must NOT append a ## Rebuild Log section (idempotency)');
|
||||
});
|
||||
|
||||
test('running rebuild twice on a drifted file converges: second run is a no-op', () => {
|
||||
const drifted = driftedCurrentPosition();
|
||||
const first = transitionCore(drifted, { kind: 'rebuild' }, baseDeps);
|
||||
assert.notStrictEqual(first.content, drifted, 'first run must mutate drifted content');
|
||||
const second = transitionCore(first.content, { kind: 'rebuild' }, baseDeps);
|
||||
assert.strictEqual(second.content, first.content,
|
||||
'second run on the just-rebuilt content must be byte-identical (idempotency)');
|
||||
assert.deepStrictEqual(second.updated, [], 'second run must mark nothing updated');
|
||||
assert.strictEqual(second.data && second.data.mutated, false,
|
||||
'second run must report mutated=false');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — drift class #1: Current Position prose reconciliation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ADR-1817 §2: rebuild reconciles ## Current Position prose with frontmatter (#1817 criterion #1)', () => {
|
||||
test('body `**Current Phase:**` is re-derived from frontmatter.current_phase when drifted', () => {
|
||||
const drifted = driftedCurrentPosition();
|
||||
assert.strictEqual(stateExtractField(drifted, 'Current Phase'), '2',
|
||||
'fixture sanity: drifted body must have stale phase 2');
|
||||
const result = transitionCore(drifted, { kind: 'rebuild' }, baseDeps);
|
||||
assert.strictEqual(
|
||||
stateExtractField(result.content, 'Current Phase'),
|
||||
'3',
|
||||
'body Current Phase must be reconciled to frontmatter value 3',
|
||||
);
|
||||
});
|
||||
|
||||
test('body `**Current Phase Name:**` is re-derived from frontmatter.current_phase_name when drifted', () => {
|
||||
const drifted = driftedCurrentPosition();
|
||||
assert.strictEqual(stateExtractField(drifted, 'Current Phase Name'), 'Old Phase Name',
|
||||
'fixture sanity: drifted body must have stale name');
|
||||
const result = transitionCore(drifted, { kind: 'rebuild' }, baseDeps);
|
||||
assert.strictEqual(
|
||||
stateExtractField(result.content, 'Current Phase Name'),
|
||||
'Test Phase',
|
||||
'body Current Phase Name must be reconciled to frontmatter value',
|
||||
);
|
||||
});
|
||||
|
||||
test('each reconciliation produces an audit-log entry in ## Rebuild Log', () => {
|
||||
const result = transitionCore(driftedCurrentPosition(), { kind: 'rebuild' }, baseDeps);
|
||||
assert.ok(result.content.includes('## Rebuild Log'),
|
||||
'rebuild that mutated must create ## Rebuild Log section');
|
||||
assert.ok(result.content.includes('kind: current-position-reconciled'),
|
||||
'log must contain a current-position-reconciled entry');
|
||||
assert.ok(result.content.includes('reason:'),
|
||||
'every log entry must carry a reason field (ADR-1411 provenance)');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — drift class #3: template-placeholder removal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ADR-1817 §2: rebuild strips template-placeholder field values (#1817 criterion #3)', () => {
|
||||
test('`**Field:** [placeholder]` lines are replaced with `**Field:** (pending)`', () => {
|
||||
const drifted = driftedPlaceholders();
|
||||
const result = transitionCore(drifted, { kind: 'rebuild' }, baseDeps);
|
||||
assert.ok(result.content.includes('**Current focus:** (pending)'),
|
||||
'placeholder Current focus must be replaced with (pending)');
|
||||
assert.ok(result.content.includes('**Last Activity:** 2026-06-29'),
|
||||
'fixture sanity: syncCore-maintained fields are untouched by rebuild');
|
||||
});
|
||||
|
||||
test('a placeholder-removed audit-log entry is recorded', () => {
|
||||
const result = transitionCore(driftedPlaceholders(), { kind: 'rebuild' }, baseDeps);
|
||||
assert.ok(result.content.includes('kind: placeholder-removed'),
|
||||
'log must contain a placeholder-removed entry');
|
||||
});
|
||||
|
||||
test('a clean body with no placeholders produces no placeholder-removed log entry', () => {
|
||||
const result = transitionCore(cleanState(), { kind: 'rebuild' }, baseDeps);
|
||||
assert.ok(!result.content.includes('kind: placeholder-removed'),
|
||||
'clean file must not log placeholder removal');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — drift class #4: Session Continuity Archive de-duplication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ADR-1817 §2: rebuild de-duplicates ## Session Continuity archive blocks (#1817 criterion #4)', () => {
|
||||
test('when > 3 archived sessions, the oldest are dropped down to the 3 most-recent', () => {
|
||||
const drifted = driftedSessionArchiveDuplicates();
|
||||
// Fixture sanity: six archived H3 blocks
|
||||
const before = countLiveSessionHeadings(drifted);
|
||||
assert.strictEqual(before, 6, 'fixture must have 6 archived sessions');
|
||||
|
||||
const result = transitionCore(drifted, { kind: 'rebuild' }, baseDeps);
|
||||
const after = countLiveSessionHeadings(result.content);
|
||||
assert.strictEqual(after, 3, 'rebuild must keep exactly 3 most-recent archived sessions');
|
||||
});
|
||||
|
||||
test('each dropped session produces a session-archive-deduplicated log entry', () => {
|
||||
const result = transitionCore(driftedSessionArchiveDuplicates(), { kind: 'rebuild' }, baseDeps);
|
||||
const dropEntries = (result.content.match(/kind: session-archive-deduplicated/g) || []).length;
|
||||
assert.strictEqual(dropEntries, 3, 'three dropped sessions → three log entries');
|
||||
});
|
||||
|
||||
test('the kept sessions are the most-recent three (by document order — template convention)', () => {
|
||||
const result = transitionCore(driftedSessionArchiveDuplicates(), { kind: 'rebuild' }, baseDeps);
|
||||
// Strip the audit log so we only inspect LIVE content (the log's `before:`
|
||||
// field legitimately preserves dropped text per ADR-1817 §3).
|
||||
const live = result.content.replace(/^## Rebuild Log[\s\S]*$/m, '');
|
||||
// DEFAULT_MAX_SESSION_ARCHIVES = 3 → drop the 3 oldest, keep 06-27/28/29.
|
||||
assert.ok(!live.includes('### Session — 2026-06-20'), 'dropped: 06-20 (oldest)');
|
||||
assert.ok(!live.includes('### Session — 2026-06-22'), 'dropped: 06-22 (2nd oldest)');
|
||||
assert.ok(!live.includes('### Session — 2026-06-25'), 'dropped: 06-25 (3rd oldest)');
|
||||
assert.ok(live.includes('### Session — 2026-06-27'), 'kept: 06-27');
|
||||
assert.ok(live.includes('### Session — 2026-06-28'), 'kept: 06-28');
|
||||
assert.ok(live.includes('### Session — 2026-06-29'), 'kept: 06-29 (newest)');
|
||||
assert.ok(!live.includes('oldest session — should be dropped'),
|
||||
'oldest session content must be gone from the live section');
|
||||
assert.ok(!live.includes('second-oldest — should be dropped'),
|
||||
'second-oldest session content must be gone from the live section');
|
||||
});
|
||||
|
||||
test('when <= 3 archived sessions, rebuild is a no-op on the archive', () => {
|
||||
const clean = cleanState(); // has zero archived H3 sessions
|
||||
const result = transitionCore(clean, { kind: 'rebuild' }, baseDeps);
|
||||
assert.ok(!result.content.includes('kind: session-archive-deduplicated'),
|
||||
'no dedup log entry when archive is within retention');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — drift class #2: By Phase table reconciliation (via dep)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ADR-1817 §2: rebuild reconciles **By Phase:** table via phaseInventoryProvider (#1817 criterion #2)', () => {
|
||||
test('orphaned rows for phases missing from the inventory are dropped', () => {
|
||||
// Inventory: only phases 1, 2, 3 exist on disk. Drifted body has rows for
|
||||
// 1, 2, 3, AND an orphan row for phase 99 (prior milestone).
|
||||
const drifted = cleanState().replace(
|
||||
/\| 3 \| 5 \| - \| - \|\n/,
|
||||
'| 3 | 5 | - | - |\n| 99 | 1 | - | - |\n',
|
||||
);
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
phaseInventoryProvider: () => [
|
||||
{ number: '1', name: 'Phase 1', planCount: 2, summaryCount: 2 },
|
||||
{ number: '2', name: 'Phase 2', planCount: 3, summaryCount: 3 },
|
||||
{ number: '3', name: 'Test Phase', planCount: 5, summaryCount: 4 },
|
||||
],
|
||||
};
|
||||
const result = transitionCore(drifted, { kind: 'rebuild' }, baseDeps);
|
||||
// Note: passing baseDeps here (no provider) → reconcile is a no-op.
|
||||
// Re-run with the provider-wired deps:
|
||||
const result2 = transitionCore(drifted, { kind: 'rebuild' }, deps);
|
||||
// Strip the audit log so we only inspect LIVE table rows (the log's
|
||||
// `before:` field legitimately preserves the pre-rebuild table per
|
||||
// ADR-1817 §3).
|
||||
const live = result2.content.replace(/^## Rebuild Log[\s\S]*$/m, '');
|
||||
assert.ok(!live.includes('| 99 |'),
|
||||
'orphan row for phase 99 (not on disk) must be dropped when phaseInventoryProvider is wired');
|
||||
assert.ok(live.includes('| 1 |'), 'kept: phase 1');
|
||||
assert.ok(live.includes('| 2 |'), 'kept: phase 2');
|
||||
assert.ok(live.includes('| 3 |'), 'kept: phase 3');
|
||||
});
|
||||
|
||||
test('rebuild logs a by-phase-table-reconciled entry when the table changes', () => {
|
||||
const drifted = cleanState().replace(
|
||||
/\| 3 \| 5 \| - \| - \|\n/,
|
||||
'| 3 | 5 | - | - |\n| 99 | 1 | - | - |\n',
|
||||
);
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
phaseInventoryProvider: () => [
|
||||
{ number: '1', name: 'Phase 1', planCount: 2, summaryCount: 2 },
|
||||
{ number: '2', name: 'Phase 2', planCount: 3, summaryCount: 3 },
|
||||
{ number: '3', name: 'Test Phase', planCount: 5, summaryCount: 4 },
|
||||
],
|
||||
};
|
||||
const result = transitionCore(drifted, { kind: 'rebuild' }, deps);
|
||||
assert.ok(result.content.includes('kind: by-phase-table-reconciled'),
|
||||
'rebuild that mutated the table must log a by-phase-table-reconciled entry');
|
||||
});
|
||||
|
||||
test('Leaky-Abstractions guard: when phaseInventoryProvider is absent, table is preserved verbatim', () => {
|
||||
const drifted = cleanState().replace(
|
||||
/\| 3 \| 5 \| - \| - \|\n/,
|
||||
'| 3 | 5 | - | - |\n| 99 | 1 | - | - |\n',
|
||||
);
|
||||
// baseDeps.phaseInventoryProvider = noPhases (returns null) → step is no-op.
|
||||
const result = transitionCore(drifted, { kind: 'rebuild' }, baseDeps);
|
||||
assert.ok(result.content.includes('| 99 |'),
|
||||
'orphan row must be preserved when no canonical source is wired');
|
||||
assert.ok(!result.content.includes('kind: by-phase-table-reconciled'),
|
||||
'no log entry when step is a no-op');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — §5 + §6: regression guard (sync/prune unchanged by rebuild presence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ADR-1817 §5/§6: rebuild does not affect sync or prune (regression guard, criterion #7)', () => {
|
||||
test('sync still patches its three frontmatter fields when rebuild is also available', () => {
|
||||
const state = cleanState();
|
||||
const before = state;
|
||||
const result = transitionCore(
|
||||
state,
|
||||
{ kind: 'sync', totalPlansInPhase: 7, percent: 50 },
|
||||
baseDeps,
|
||||
);
|
||||
// sync should have updated Total Plans in Phase and Progress and Last Activity.
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '7',
|
||||
'sync must still patch Total Plans in Phase');
|
||||
assert.ok(stateExtractField(result.content, 'Progress').includes('50%'),
|
||||
'sync must still patch Progress percent');
|
||||
});
|
||||
|
||||
test('prune still archives by cutoff when rebuild is also available', () => {
|
||||
// Smoke: prune on a body with at least one Decisions row at phase 1 should
|
||||
// archive that row when cutoff=0. The exact byte shape is covered by the
|
||||
// dedicated state-transition tests; this test only asserts prune is NOT
|
||||
// broken by adding the rebuild case to the switch.
|
||||
const state = cleanState();
|
||||
const result = transitionCore(state, { kind: 'prune', cutoff: 0 }, baseDeps);
|
||||
assert.ok(result, 'prune must still return a result');
|
||||
assert.ok(result.updated !== undefined, 'prune must still return an updated array');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user