refactor(#3471): one enforcement point for the empty case, and reports that match the disk (#3519)

* refactor(#3471): one enforcement point for the empty case, and reports that match the disk

Implements ADR-3408 section 8.5 and section 8.4's residue (folded in when Phase
3 closed as subsumed). Four items, and two findings the design did not predict.

FINDING 1 — the guards could not simply be deleted, as the design instructed.
state sync and REGENERATE_STATE never run applyStatePreservation at all, so
those six conditions were their ONLY empty-field fallback. A baseline probe on
the unedited tree confirmed unconditional deletion drops current_phase,
current_phase_name, current_plan, stopped_at and paused_at from a blank-body
STATE.md on state sync — breaking the byte-identical requirement section 8.3
grants those two sanctioned-permanent exceptions. They are now GATED, not
deleted: on for the exceptions, off for the write seam, where an empty derived
value finally reaches the executor unmolested.

FINDING 2, the more serious one — there was a FOURTH encoding of this policy.
The pre-existing #2202 unknown-key carry-forward loop independently restored
the same six fields whenever derivedFm lacked the key, completely neutralizing
the fix. It is named nowhere in the ADR, the design, or three prior phases. It
was found only because a probe that should have passed did not: the first
attempt reported divergedFields: [] and silently restored both fields,
reproducing the exact bug this phase exists to close.

That is worth stating plainly. This epic's thesis is 'policy declared in one
table, enforcement hand-rolled per call site.' The final phase found one more
call site than anyone had counted — which is the fourth consecutive time a copy
count in this epic proved to be a lower bound.

Also: divergedFields could only observe fields the executor actively RESTORED,
by diffing postFm. A discard-to-empty is absent both before and after, so it
was invisible. A second pass now reports it, which is what makes section 8.5's
'preservation is visible' true for the delete-the-body-line case rather than
aspirational.

cmdPhaseComplete now reports what it preserved — #3374 was filed against that
command and its complaint was warnings: [], silence.

cmdStateJson's private third copy of the guards is routed onto the executor's
preserve-when-unchanged rule. A read is definitionally not a write, so the
#1230 delta is 'unchanged' and curated wins over a stale annotation.
shouldPreserveExistingProgress is a different rule and is untouched.

Report reconciliation is ONE shared helper across seven commands, not five
copies of fix(#3351)'s block. Five copies of a reconciliation is precisely the
shape this epic removes, and introducing it in the final phase would have been
a poor joke. Both untraced commands were traced rather than assumed:
cmdStatePlannedPhase matched cmdStateBeginPhase exactly; cmdStateCompletePhase
turned out to be a different legacy hand-rolled path reporting a mix of field
names AND a section name, where the naive helper would have dropped 'Current
Position' as a false negative every time.

* test(#3471): characterization coverage for one enforcement point and reconciled reports

Matrix sections A-E, asserted at the consumer's output per ADR-3180 Decision
4(b)/(c) — this phase owes Decision 5's outcome metric, the one the drift
guard's zero may never be reported without.

Three walls matter more than the new coverage:

  A2 is SIX separately named tests, one per gated guard, not one parameterised
  assertion over a list. A list is trivially shortened later; six named tests
  are not, and six guards is exactly where a field gets silently dropped.

  A6 pins what Phases 1-3 already fixed — non-empty stale body, delta
  unchanged, losing to fresher curated frontmatter, with the divergence
  reported. If A6 reddens, this phase broke the thing the epic was for.

  D1/D2 pin state sync byte-identical. The implementation had to GATE the six
  guards rather than delete them precisely because state sync has no executor,
  and a baseline probe showed unconditional deletion drops five fields.
  Nothing else in the suite would notice that regression.

E6 covers #3345's direction — a field preservation restored that the intent
never named IS reported. Nothing has ever tested that direction.

Assertions were empirically verified against the compiled lib and the real CLI
before being written, since the suite cannot be executed locally. That caught
two type bugs in the draft: fm.current_phase after a quoted-YAML round-trip is
the string '5', not the number 5.

E5 is recorded as structurally unreachable rather than weakened or faked. Those
four commands report body Title-Case labels, which cannot string-collide with a
frontmatter snake_case key the way cmdStatePatch's arbitrary field names can —
which is why fix(#3351) targeted only cmdStatePatch. Testing it directly would
need reconcileReportedFields exported from private scope; the helper is
exercised through E6 and all seven commands instead.

* docs(#3471): amend ADR-3408 section 8.5 — a fourth enforcement point, and guards that could not be deleted

Amendment 3. The contract held; two of section 8.5's own statements did not.

It said the six empty-only guards are DELETED. They cannot be. writeStateMd is
the sole path for both section 8.3 sanctioned-permanent exceptions and never
runs applyStatePreservation, so those guards were their only empty-field
fallback. A baseline probe on the unedited tree confirmed unconditional
deletion drops five fields from a blank-body STATE.md on state sync, breaking
the byte-identical guarantee section 8.3 grants it. They are gated instead.

It also mis-located cmdStateJson's guards, describing them as living in
syncStateFrontmatter. They were a separate private copy on the read path with
no delta check at all, so a stale body annotation always beat fresher curated
frontmatter in state.json — #3395's shape entirely outside the write seam.

THE FINDING: a fourth enforcement point nobody had counted. The pre-existing
#2202 unknown-key carry-forward loop independently restored the same six
fields, silently neutralizing the fix. It is named nowhere in this ADR, in the
phase design, or in three prior phases, and was found only because a probe that
should have passed did not.

Fourth consecutive time a copy count in this epic proved a lower bound: 2
write-seam bypasses became 4, three preservation encodings became four, and the
estimate was wrong every time. ADR-3180's standing rule has earned itself in
every phase — read the code, not the write-up.

Records the Row 2 decision (a discard-to-empty wins per the delta rule and is
reported, not silent — the sharpest Hyrum exposure in the epic), section 8.4's
residue landing as ONE shared reconcileReportedFields across seven commands
rather than five copies, and the parity assertion added because
FRONTMATTER_KEY_TO_BODY_LABEL was itself a second table that failed silently —
this epic's shape in miniature, in its final phase.

* fix(#3471): repair four regressions the checkpoint caught

Checkpoint returned 16 failures of 34389: six real regressions in pre-existing
tests, plus seven of my own test bugs.

My hypothesis was wrong and is recorded as such. I predicted the #2202
carry-forward skip was the cause, reasoning it had removed a load-bearing
fallback the way the six guards nearly were. It was not implicated in any of
the six. Three unrelated causes:

#2111 — current_phase came back undefined from milestone complete, which is
the epic's own defect class reintroduced by its final phase. Root cause is
Row 2 working exactly as designed: milestoneCompleteCore rewrites the body
Phase: line to a closure message, so current_phase's #1230 delta reads
CHANGED and the new rule correctly discards the curated value. The transition
never declared any intent to touch that field. Fixed by re-asserting
current_phase and current_phase_name through authoritativeFm — the existing
#2736 mechanism beginPhaseCore and completePhaseCore already use — rather
than by weakening Row 2, which A5 pins.

That interaction is worth naming: a rule that keys on 'did this write change
the body source' will fire on a transition that moves the body line for an
entirely unrelated reason. The design did not anticipate it.

#1264 / #3242 / the state.patch progress report — reconcileReportedFields
folded EVERY divergedFields entry into updated, including preserve-always
progress restores no caller asked about. Now scoped to preserve-when-unchanged
rows only.

#1162 / case-insensitive table fields — valueOf checked frontmatter before
body, so a lowercase table field name exact-matched the lowercase frontmatter
key sync always derives, comparing stale pre-sync body text against a
post-sync frontmatter enum. Flipped to body-first.

That last one is the SAME lesson as Phase 2's patchCore, recurring in a
different function two phases later: in this model the body is authoritative
and frontmatter is the projection, so a name that could mean either resolves
body-first. Twice now.

Test bugs: a stray unused parameter shifted every argument at six call sites,
so body arrived undefined; and A4 compared nested progress scalars against
numbers when extractFrontmatter returns raw YAML strings. The string-vs-number
YAML round-trip has now been caught three times in this phase alone.

* test(#3471): one helper for the progress coercion that bit four times

A2f failed on the string-vs-number YAML round-trip: extractFrontmatter returns
nested progress scalars as raw YAML strings, so a comparison against numeric
literals can never pass.

This is the FOURTH time this exact class has been caught in this phase — twice
during test authoring, once as A4 in the previous checkpoint, now as A2f.
Patching it a fourth time by hand would guarantee a fifth.

Added numericProgress() with a comment saying why it exists, and routed every
progress-reading assertion in the #3471 block through it. Swept the block:
C3 needed no change, because cmdStateJson's output already runs through
normalizeProgressNumbers.

Deliberately NOT shared with frontmatter.test.cjs's readPersistedProgress:
that one is path-based and re-reads from disk, while these assert on an
in-memory string that is never written. Sharing would have meant either a
disk round-trip these tests do not do, or duplicating half the helper — so
the coercion pattern is mirrored locally and the reason recorded, rather
than manufacturing a dependency to satisfy the letter of consolidation.

* chore(#3471): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 21:06:01 -04:00
committed by GitHub
parent fba7c90327
commit 411196bc3a
10 changed files with 1397 additions and 113 deletions

View File

@@ -651,6 +651,26 @@ describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.preservation_warnings, []);
});
// ADR-3408 §8.5 Matrix B3 (#3471, regression-only): D1's guard deletion and
// D3's cmdStateJson change both scope explicitly to `state.cts`/the write
// seam — `cmdMilestoneComplete` (src/milestone.cts) is untouched by this
// phase (per the implementation report: "No changes to src/milestone.cts").
// Re-runs the exact B1/B3 shape above as this phase's own pin, so a future
// change cannot silently regress it without a Phase-4-owned test noticing.
test('B3 (#3471 regression pin): preservation_warnings shape and content unchanged by Phase 4', () => {
writeStateWithSession(tmpDir, { fmStoppedAt: 'Phase 7 verified PASS', sessionStoppedAt: 'Phase 3 work' });
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(
output.preservation_warnings,
[{ field: 'stopped_at', reason: 'preserved-over-disagreeing-derived' }],
'cmdMilestoneComplete\'s preservation_warnings shape must be unaffected by Phase 4 (#3471)',
);
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -6677,6 +6677,61 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
assert.strictEqual(after.state, before.state, 'STATE.md must be unchanged — none of the three partially written');
});
});
// ─────────────────────────────────────────────────────────────────────────
// ADR-3408 §8.5 Matrix B (#3471): cmdPhaseComplete's preservation is now
// visible via `preservation_warnings` (D2 — #3374's "warnings: []"
// complaint, on the exact command it was filed against). Design:
// .gsd/phase/refactor-3471-stale-but-present/40-design.md. Matrix:
// .gsd/phase/refactor-3471-stale-but-present/50-test-matrix.md section B.
// Reuses setupPhase3517Project/runSdkQuery/writePassedVerificationForPhase
// from this same folded block (bug #3517 fixture).
// ─────────────────────────────────────────────────────────────────────────
describe('ADR-3408 §8.5 Matrix B (#3471): cmdPhaseComplete preservation visibility (D2)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3471-b-'));
});
afterEach(() => {
cleanup(tmpDir);
});
// B1 — consumer-level (ADR-3180 Decision 4(c)): a curated field the
// transition never touches (its body source is unchanged this write)
// must be named in `preservation_warnings`, closing #3374's exact
// "warnings: []" silence for the command it was filed against.
test('B1: cmdPhaseComplete names the field it preserved in preservation_warnings', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const before = fs.readFileSync(statePath, 'utf8');
fs.writeFileSync(statePath, before.replace(/^gsd_state_version: 1\.0$/m, 'gsd_state_version: 1.0\npaused_at: "curated pause note — must survive"'));
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
assert.ok(Array.isArray(r.data.preservation_warnings), 'preservation_warnings must be an array');
assert.deepStrictEqual(
r.data.preservation_warnings,
[{ field: 'paused_at', reason: 'preserved-over-disagreeing-derived' }],
);
});
// B2 — negative: no false alarm. When nothing is preserved (no
// disagreeing curated field), `preservation_warnings` must be empty, not
// populated with a phantom entry.
test('B2: cmdPhaseComplete emits an empty preservation_warnings when nothing was preserved', () => {
setupPhase3517Project(tmpDir);
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
assert.deepStrictEqual(r.data.preservation_warnings, []);
});
});
}
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -21,6 +21,7 @@ const fc = require('fast-check');
// consumer's OBSERVABLE output is compared against (Decision 4c) — never the
// owner's return value against itself.
const stateLib = require('../gsd-core/bin/lib/state.cjs');
const stateTransitionMod = require('../gsd-core/bin/lib/state-transition.cjs');
const stateDocument = require('../gsd-core/bin/lib/state-document.cjs');
const frontmatterLib = require('../gsd-core/bin/lib/frontmatter.cjs');
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
@@ -5057,6 +5058,745 @@ describe('ADR-3408 §8.3 Matrix C: cmdStateSync — the sanctioned exception (#3
});
});
// ═════════════════════════════════════════════════════════════════════════════
// ADR-3408 §8.5 Matrix (#3471): stale-but-present, and the report residue.
// Design: .gsd/phase/refactor-3471-stale-but-present/40-design.md
// Matrix: .gsd/phase/refactor-3471-stale-but-present/50-test-matrix.md
//
// As-built (probe-verified against gsd-core/bin/lib/state.cjs, not guessed):
// - The six #905 empty-only guards in `syncStateFrontmatter` are GATED behind
// a `sanctionedPermanentEmptyFallback` param, not deleted outright.
// `writeStateMd` (state sync / REGENERATE_STATE) passes `true`;
// `syncAndPreserveStateMd` (the write seam) passes nothing — so an empty
// derived value reaches `applyStatePreservation` unmolested there.
// - `cmdStateJson` routes exactly six fields (status, stopped_at, paused_at,
// current_phase, current_plan, current_phase_name — NOT last_activity_desc)
// through `applyPreserveWhenUnchanged` with a synthetic {pre,post} delta
// (same value twice — a read is never a "write").
// ═════════════════════════════════════════════════════════════════════════════
describe('ADR-3408 §8.5 Matrix (#3471): stale-but-present, and the report residue', () => {
// #3471: extractFrontmatter's mini-YAML parser returns nested `progress.*`
// scalars as raw strings unless a caller runs them through
// normalizeProgressNumbers (the sanctioned-permanent guard path does; the
// write-seam merge exercised throughout this block does not). This exact
// string-vs-number round-trip has bitten test authoring FOUR times in this
// phase alone (twice self-caught before shipping, once as A4, now as
// A2f) — route every progress-reading assertion in this block through this
// helper rather than comparing against numeric literals. Mirrors
// `tests/frontmatter.test.cjs`'s `readPersistedProgress` (which is
// file-path based, reading from disk); this variant operates on an
// already-extracted `fm.progress` object since not every case in this
// block round-trips through a file.
function numericProgress(fmProgress) {
assert.ok(fmProgress, 'frontmatter must have a progress block');
return Object.fromEntries(
Object.entries(fmProgress).map(([key, value]) => [key, Number(value)]),
);
}
// ─── Section A — deleting the six guards (D1) ──────────────────────────────
// A1-A4/A7 assert on the write seam's own returned content (not required to
// be consumer-level by the matrix's assertion rule). A5/A6 ARE in the
// consumer-level list (ADR-3180 Decision 4(c)) so they write the result to a
// real file and read it back, mirroring the existing A1/A2/A3 fast-check
// property's own pattern (tests/state.test.cjs "ADR-3408 §8.3 Matrix
// A1/A2/A3").
describe('Section A — deleting the six guards (D1)', () => {
test('A1: derived empty, body source UNCHANGED, curated frontmatter present — restored via the executor', (t) => {
const tmp = createTempDir('gsd-3471-a1-');
t.after(() => cleanup(tmp));
const original = [
'---', 'gsd_state_version: 1.0', 'current_phase: "5"', 'current_phase_name: Curated Name', '---', '',
'# Project State', '', '## Current Position', '', 'Status: Executing', '',
].join('\n');
// Change something OTHER than the Phase line, so this is a real write
// whose Phase-line body source is unchanged pre/post.
const transformed = original.replace('Status: Executing', 'Status: Verifying');
const statePath = path.join(tmp, 'STATE.md');
const divergedFields = [];
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, undefined, divergedFields);
const fm = frontmatterLib.extractFrontmatter(out);
assert.strictEqual(fm.current_phase, '5', 'current_phase must be restored by the executor, not lost');
assert.strictEqual(fm.current_phase_name, 'Curated Name', 'current_phase_name must be restored by the executor, not lost');
assert.deepStrictEqual(
divergedFields.slice().sort(),
['current_phase', 'current_phase_name'],
'both restores must be reported — preservation is visible',
);
});
// A2 — deliberately SIX separate named tests, one per gated guard. A list
// over these six field names is easy to quietly shorten; six named tests
// are not (matrix's own stated rationale).
function a2Case(curatedFrontmatterLine, body) {
const original = ['---', 'gsd_state_version: 1.0', curatedFrontmatterLine, '---', '', '# Project State', '', ...body].join('\n');
const transformed = original.replace('Executing', 'Verifying');
const tmp = createTempDir('gsd-3471-a2-');
const statePath = path.join(tmp, 'STATE.md');
const divergedFields = [];
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, undefined, divergedFields);
cleanup(tmp);
return { fm: frontmatterLib.extractFrontmatter(out), divergedFields };
}
test('A2a: stopped_at — restored', () => {
const { fm, divergedFields } = a2Case(
'stopped_at: "Phase 3, curated stop"',
['## Session', '', '**Last session:** 2026-01-01', ''],
);
assert.strictEqual(fm.stopped_at, 'Phase 3, curated stop');
assert.deepStrictEqual(divergedFields, ['stopped_at']);
});
test('A2b: paused_at — restored', () => {
const { fm, divergedFields } = a2Case(
'paused_at: "Phase 3, curated pause"',
['## Session', '', '**Last session:** 2026-01-01', ''],
);
assert.strictEqual(fm.paused_at, 'Phase 3, curated pause');
assert.deepStrictEqual(divergedFields, ['paused_at']);
});
test('A2c: current_phase — restored', () => {
const { fm, divergedFields } = a2Case(
'current_phase: "5"',
['## Current Position', '', 'Status: Executing', ''],
);
assert.strictEqual(fm.current_phase, '5');
assert.deepStrictEqual(divergedFields, ['current_phase']);
});
test('A2d: current_phase_name — restored', () => {
const { fm, divergedFields } = a2Case(
'current_phase_name: Curated Name',
['## Current Position', '', 'Status: Executing', ''],
);
assert.strictEqual(fm.current_phase_name, 'Curated Name');
assert.deepStrictEqual(divergedFields, ['current_phase_name']);
});
test('A2e: current_plan — restored', () => {
const { fm, divergedFields } = a2Case(
'current_plan: 03-02-curated',
['## Current Position', '', 'Status: Executing', ''],
);
assert.strictEqual(fm.current_plan, '03-02-curated');
assert.deepStrictEqual(divergedFields, ['current_plan']);
});
test('A2f: progress — restored (resync:false — preserve-always is resync-gated, unlike the other five)', () => {
const { fm, divergedFields } = a2Case(
['progress:', ' total_phases: 3', ' completed_phases: 2', ' total_plans: 5', ' completed_plans: 4', ' percent: 80'].join('\n'),
['## Current Position', '', 'Status: Executing', ''],
);
assert.deepStrictEqual(
numericProgress(fm.progress),
{ total_phases: 3, completed_phases: 2, total_plans: 5, completed_plans: 4, percent: 80 },
);
assert.deepStrictEqual(divergedFields, ['progress']);
});
test('A3: derived empty, no curated value — stays empty, no throw', (t) => {
const tmp = createTempDir('gsd-3471-a3-');
t.after(() => cleanup(tmp));
const original = ['---', 'gsd_state_version: 1.0', '---', '', '# Project State', '', '## Current Position', '', 'Status: Executing', ''].join('\n');
const transformed = original.replace('Status: Executing', 'Status: Verifying');
const statePath = path.join(tmp, 'STATE.md');
const divergedFields = [];
assert.doesNotThrow(() => {
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, undefined, divergedFields);
const fm = frontmatterLib.extractFrontmatter(out);
assert.strictEqual(fm.current_phase, undefined);
assert.strictEqual(fm.current_phase_name, undefined);
});
assert.deepStrictEqual(divergedFields, [], 'nothing curated existed, so nothing can have diverged');
});
// A4 — the subtlest row: `progress` is a sub-object. A block the disk scan
// only PARTIALLY derived (here: only total_phases, from a body "Total
// Phases:" line, no phase dirs on disk) is not "empty" — the deleted
// empty-only guard's own condition (`!derivedFm['progress']`) never fired
// for a partial block even before this phase, so a partial block is
// governed purely by `applyPreserveAlways`'s existing #2440/#2969 merge
// (deriveProgressKeys:true): the derive-flagged keys (total_phases here)
// always take the freshly-derived value ("the derived block wins"), while
// the ratchet-protected keys (completed_phases/completed_plans) keep the
// curated value when the derived side lacks a greater one ("a partial
// block must NOT clobber curated" — #3242). Empirically verified against
// the compiled lib before writing this assertion.
test('A4: progress partially derived (block present, some keys missing) vs curated — derived wins for derive-flagged keys, curated survives for ratchet-protected keys', (t) => {
const tmp = createTempDir('gsd-3471-a4-');
t.after(() => cleanup(tmp));
const original = [
'---', 'gsd_state_version: 1.0',
'progress:', ' total_phases: 3', ' completed_phases: 2', ' total_plans: 5', ' completed_plans: 4', ' percent: 80',
'---', '', '# Project State', '', '## Current Position', '', 'Total Phases: 3', '',
].join('\n');
const transformed = original.replace('Total Phases: 3', 'Total Phases: 10');
const statePath = path.join(tmp, 'STATE.md');
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, true);
const fm = frontmatterLib.extractFrontmatter(out);
// #3471 review: extractFrontmatter's mini-YAML parser returns nested
// `progress.*` scalars as raw strings — see `numericProgress` above
// for why. Compare numerically, not by strict type.
const progress = numericProgress(fm.progress);
assert.strictEqual(progress.total_phases, 10, 'total_phases (derive-flagged) must take the freshly-derived disk value');
assert.strictEqual(progress.completed_phases, 2, 'completed_phases (ratchet-protected) must keep curated — the partial derive must not clobber it');
assert.strictEqual(progress.completed_plans, 4, 'completed_plans (ratchet-protected) must keep curated — the partial derive must not clobber it');
});
// A5 — the D1 defect itself, at the CONSUMER's output (ADR-3180 Decision
// 4(c)): the transform DELETES the body Phase line (delta CHANGED, not
// merely absent-and-unchanged like A1/A3) — derived (empty) must win per
// the delta rule, AND the discard must be reported in `divergedFields`,
// never silently persist the stale curated value (the bug D1 closes).
test('A5: derived empty because the transform DELETED the body line (delta CHANGED) — derived wins, and the discard is reported', (t) => {
const tmp = createTempDir('gsd-3471-a5-');
t.after(() => cleanup(tmp));
const original = [
'---', 'gsd_state_version: 1.0', 'current_phase: "5"', 'current_phase_name: Curated Name', '---', '',
'# Project State', '', '## Current Position', '', 'Phase: 5 (Curated Name)', '',
].join('\n');
const transformed = original.replace('Phase: 5 (Curated Name)\n', '');
const statePath = path.join(tmp, 'STATE.md');
fs.writeFileSync(statePath, original);
const divergedFields = [];
const written = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, undefined, divergedFields);
fs.writeFileSync(statePath, written);
// Consumer-level: read the real file back, never compare the owner's
// return value to itself.
const onDisk = fs.readFileSync(statePath, 'utf8');
const fm = frontmatterLib.extractFrontmatter(onDisk);
assert.strictEqual(fm.current_phase, undefined, 'the deleted body line must not leave a stale curated current_phase behind');
assert.strictEqual(fm.current_phase_name, undefined, 'the deleted body line must not leave a stale curated current_phase_name behind');
assert.deepStrictEqual(
divergedFields.slice().sort(),
['current_phase', 'current_phase_name'],
'the discard-to-empty must be visible in divergedFields — this is the D1 bug\'s exact silent-persistence shape, now closed',
);
});
// A6 — the regression wall, at the CONSUMER's output: Phases 1-3 already
// fixed the headline "non-empty stale body value, delta unchanged, loses
// to fresher curated frontmatter" case. If this test goes red, Phase 4
// broke what the epic was for.
test('A6: derived non-empty and STALE, delta unchanged, fresher frontmatter wins — must not regress', (t) => {
const tmp = createTempDir('gsd-3471-a6-');
t.after(() => cleanup(tmp));
const original = [
'---', 'gsd_state_version: 1.0', 'current_phase_name: Fresh Curated Name', '---', '',
'# Project State', '', '## Current Position', '', 'Phase: 3 (Stale Body Name)', '',
].join('\n');
const statePath = path.join(tmp, 'STATE.md');
fs.writeFileSync(statePath, original);
const divergedFields = [];
// No transform this write — delta unchanged (transformedContent === originalContent).
const written = stateLib.syncAndPreserveStateMd(original, original, statePath, tmp, false, undefined, undefined, divergedFields);
fs.writeFileSync(statePath, written);
const onDisk = fs.readFileSync(statePath, 'utf8');
const fm = frontmatterLib.extractFrontmatter(onDisk);
assert.strictEqual(fm.current_phase_name, 'Fresh Curated Name', 'fresher curated frontmatter must win over the stale body value — unchanged from Phases 1-3');
const onDiskBody = frontmatterLib.stripFrontmatter(onDisk);
const originalBody = frontmatterLib.stripFrontmatter(original);
assert.strictEqual(onDiskBody, originalBody, 'the stale body text itself is untouched (frontmatter wins, body prose is not rewritten)');
assert.deepStrictEqual(divergedFields, ['current_phase_name'], 'the restore must be reported');
});
test('A7: #2202 unknown-key carry-forward still works on the write seam', (t) => {
const tmp = createTempDir('gsd-3471-a7-');
t.after(() => cleanup(tmp));
const original = [
'---', 'gsd_state_version: 1.0', 'custom_unknown_key: preserved-value', '---', '',
'# Project State', '', '## Current Position', '', 'Status: Executing', '',
].join('\n');
const transformed = original.replace('Executing', 'Verifying');
const statePath = path.join(tmp, 'STATE.md');
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false);
const fm = frontmatterLib.extractFrontmatter(out);
assert.strictEqual(fm.custom_unknown_key, 'preserved-value', 'an unknown/custom frontmatter key must still carry forward — a different mechanism from the six deleted guards');
});
});
// ─── Section C — cmdStateJson (D3) ─────────────────────────────────────────
describe('Section C — cmdStateJson (D3)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createFixture(); });
afterEach(() => { cleanup(tmpDir); });
// C1 — the D3 defect, at the CONSUMER's output: a stale non-empty body
// annotation must no longer automatically beat a fresher curated
// frontmatter value in `state json` — governed by the SAME
// preserve-when-unchanged executor the write path uses.
test('C1a: stale non-empty body current_phase_name loses to fresher curated frontmatter in `state json`', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'current_phase_name: Fresh Curated Name', '---', '',
'# Project State', '', '## Current Position', '', 'Phase: 3 (Stale Body Name)', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'json'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.current_phase_name, 'Fresh Curated Name', '#3395\'s shape, now closed outside the write seam too');
});
// C1b — the report's own call-out: `status` is a MATERIALLY LARGER change
// than the design's examples (which only used current_phase). Before D3,
// status only fell back on the literal sentinel 'unknown'; now curated
// wins over ANY disagreeing derived value, same as the other five fields.
test('C1b: a real (non-"unknown") curated status wins over a disagreeing derived body Status in `state json`', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'status: verifying', '---', '',
'# Project State', '', '## Current Position', '', 'Status: Executing', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'json'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.status,
'verifying',
'curated status must win over ANY disagreeing derived value now, not just the literal \'unknown\' sentinel',
);
});
test('C2: derived empty, curated present — falls back, unchanged', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'current_phase: "5"', 'current_phase_name: Curated Name', 'current_plan: 05-02', '---', '',
'# Project State', '', '## Current Position', '', '(nothing recognizable here)', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'json'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.current_phase, '5');
assert.strictEqual(output.current_phase_name, 'Curated Name');
assert.strictEqual(output.current_plan, '05-02');
});
// C3 — independence: `shouldPreserveExistingProgress`'s cross-milestone
// rule is a DIFFERENT policy from the six empty-only/D3 guards and must
// survive untouched. Exercised in the SAME `state json` call as one of
// D3's new six fields, proving the two coexist without interference.
test('C3: shouldPreserveExistingProgress cross-milestone progress preservation survives D3 untouched, alongside a D3-governed field', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'stopped_at: "curated stop must survive"',
'progress:', ' total_phases: 3', ' completed_phases: 2', ' total_plans: 20', ' completed_plans: 18', ' percent: 90',
'---', '', '# Project State', '', '## Session', '', '**Last session:** 2026-01-01', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'json'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.progress.completed_plans, 18, 'shouldPreserveExistingProgress must still preserve the higher curated count');
assert.strictEqual(output.stopped_at, 'curated stop must survive', 'D3\'s new six-field policy must still fire in the same read');
});
test('C4: `state json` never writes — STATE.md is byte-identical before and after', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'current_phase_name: Fresh Curated Name', '---', '',
'# Project State', '', '## Current Position', '', 'Phase: 3 (Stale Body Name)', '',
].join('\n');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, content);
const before = fs.readFileSync(statePath, 'utf8');
const result = runGsdTools(['state', 'json'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const after = fs.readFileSync(statePath, 'utf8');
assert.strictEqual(after, before, '`state json` must never write STATE.md, even when D3\'s policy decides a value');
});
});
// ─── Section D — the sanctioned exceptions: the regression wall for §8.3 ──
// D4 (ratchet: exactly 2 sanctioned-permanent entries, not 0) is already
// covered by tests/state-write-path-drift-guard.test.cjs's E6/E7/E8 (the
// boundary triple 2/1/3) — not duplicated here.
describe('Section D — the sanctioned exceptions (regression wall for §8.3)', () => {
// D1 — consumer-level: `state sync` must still let a fresh body value win
// over a stale-but-EMPTY-only-fallback-eligible curated frontmatter value
// — proven here on a body that has NO annotation at all for six curated
// fields, so only the gated `sanctionedPermanentEmptyFallback` fallback
// (not the deleted-on-the-write-seam guards) can be keeping them alive.
test('D1: `state sync` still restores all six curated fields (plus last_activity_desc) from a blank body — the sanctioned fallback must survive D1\'s guard deletion', (t) => {
const tmp = createTempDir('gsd-3471-d1-');
t.after(() => cleanup(tmp));
const statePath = path.join(tmp, 'STATE.md');
const original = [
'---', 'gsd_state_version: 1.0',
'current_phase: "5"', 'current_phase_name: Curated Name', 'current_plan: 05-02-plan',
'stopped_at: Phase 5, curated stop', 'paused_at: Phase 5, curated pause',
'last_activity_desc: curated activity desc',
'---', '', '# Project State', '', '## Current Position', '', '## Session', '',
].join('\n');
fs.writeFileSync(statePath, original);
stateLib.writeStateMd(statePath, original, tmp);
const onDisk = fs.readFileSync(statePath, 'utf8');
const fm = frontmatterLib.extractFrontmatter(onDisk);
assert.strictEqual(fm.current_phase, '5');
assert.strictEqual(fm.current_phase_name, 'Curated Name');
assert.strictEqual(fm.current_plan, '05-02-plan');
assert.strictEqual(fm.stopped_at, 'Phase 5, curated stop');
assert.strictEqual(fm.paused_at, 'Phase 5, curated pause');
assert.strictEqual(fm.last_activity_desc, 'curated activity desc');
});
// D2 — identity: `state sync`'s output is pinned byte-for-byte (frozen
// clock, per CONTRIBUTING's clock-seam rule — mirrors the existing
// "ADR-3408 §8.3 Matrix A1/A2/A3" property test's own PINNED_MS pattern).
// Empirically derived from the compiled lib before being hardcoded here.
test('D2: `state sync` output is byte-identical for a fixed input under a frozen clock', (t) => {
const PINNED_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
t.mock.timers.enable(['Date']);
t.mock.timers.setTime(PINNED_MS);
const tmp = createTempDir('gsd-3471-d2-');
t.after(() => cleanup(tmp));
const statePath = path.join(tmp, 'STATE.md');
const original = [
'---', 'gsd_state_version: 1.0',
'current_phase: "5"', 'current_phase_name: Curated Name', 'current_plan: 05-02-plan',
'stopped_at: Phase 5, curated stop', 'paused_at: Phase 5, curated pause',
'last_activity_desc: curated activity desc',
'---', '', '# Project State', '', '## Current Position', '', '## Session', '',
].join('\n');
fs.writeFileSync(statePath, original);
stateLib.writeStateMd(statePath, original, tmp);
const expected = [
'---', 'gsd_state_version: 1.0', 'status: unknown', 'last_updated: "2023-11-14T22:13:20.000Z"',
'stopped_at: Phase 5, curated stop', 'paused_at: Phase 5, curated pause',
'current_phase: 5', 'current_phase_name: Curated Name', 'current_plan: 05-02-plan',
'last_activity_desc: curated activity desc',
'---', '', '# Project State', '', '## Current Position', '', '## Session', '',
].join('\n');
assert.strictEqual(fs.readFileSync(statePath, 'utf8'), expected);
});
// D3 — REGENERATE_STATE's exact call shape (health-diagnostic.cts:328-337):
// a wholly fresh `stateContent` with NO frontmatter block, passed to
// `writeStateMd` — it never re-reads the OLD statePath's frontmatter, so
// none of the discarded curated values can leak through, regardless of
// `sanctionedPermanentEmptyFallback`.
test('D3: REGENERATE_STATE\'s writeStateMd shape discards ALL prior curated values — factory reset, no preservation', (t) => {
const tmp = createTempDir('gsd-3471-d3-');
t.after(() => cleanup(tmp));
const statePath = path.join(tmp, 'STATE.md');
const oldCurated = [
'---', 'gsd_state_version: 1.0', 'current_phase: "5"',
'current_phase_name: Curated Name To Discard', 'stopped_at: should not survive regenerate',
'paused_at: should not survive regenerate', 'current_plan: should not survive regenerate',
'---', '', '# Project State', '',
].join('\n');
fs.writeFileSync(statePath, oldCurated);
// The regenerated content health-diagnostic.cts builds: no frontmatter
// block at all.
const regenerated = [
'# Session State', '', '## Position', '',
'**Current phase:** (determining...)', '**Status:** Resuming', '',
].join('\n');
stateLib.writeStateMd(statePath, regenerated, tmp);
const onDisk = fs.readFileSync(statePath, 'utf8');
const fm = frontmatterLib.extractFrontmatter(onDisk);
assert.notStrictEqual(fm.current_phase_name, 'Curated Name To Discard', 'the factory reset must discard the old curated name');
assert.notStrictEqual(fm.stopped_at, 'should not survive regenerate');
assert.notStrictEqual(fm.paused_at, 'should not survive regenerate');
assert.notStrictEqual(fm.current_plan, 'should not survive regenerate');
});
});
// ─── Section E — report reconciliation (D4 — §8.4's residue) ──────────────
// E7 (cmdStatePatch, independence — fix(#3351) not regressed) is already
// covered by the existing "#3351: state.patch report reconciled against
// persisted STATE.md" describe block above — not duplicated here.
describe('Section E — report reconciliation (D4)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createFixture(); });
afterEach(() => { cleanup(tmpDir); });
test('E1: cmdStateUpdate — `updated` reflects the persisted change, and `preserved` names a field the update never touched but preservation restored', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'current_phase_name: Curated Name', '---', '',
'# Project State', '', '## Current Position', '', 'Status: Executing', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'update', 'Status', 'Verifying'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true);
assert.deepStrictEqual(output.preserved, ['Current Phase Name'], '#3345\'s direction: a field the update never named that preservation restored');
});
test('E2: cmdStateAdvancePlan — `updated` names only the fields whose persisted value actually changed (happy path)', () => {
const content = [
'---', 'gsd_state_version: 1.0', '---', '',
'# Project State', '', '## Current Position', '', 'Current Plan: 1', 'Total Plans in Phase: 3', 'Status: Executing', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated.slice().sort(), ['Current Plan', 'Status']);
});
// E6 (#3345's direction — the direction nothing has ever tested): a field
// preservation restored that advancePlanCore's OWN transform never
// touched at all IS in `updated`. Also demonstrates the report's item 4:
// cmdStateAdvancePlan previously exposed NO `updated` array whatsoever.
test('E6: cmdStateAdvancePlan — a field preservation restored, that the transform never touched, IS in `updated`', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'current_phase: "99"', 'current_phase_name: Curated Stale Name', '---', '',
'# Project State', '', '## Current Position', '', 'Phase: 1 (Old Name)', 'Current Plan: 3', 'Total Plans in Phase: 3', 'Status: Executing', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
// Last plan in the phase — advance-plan reports readiness without
// touching the body Phase line, so its delta is unchanged and the
// curated current_phase/current_phase_name must be restored and
// reported, even though advancePlanCore's own intent never named them
// (confirmed empirically: the SAME fixture minus the curated conflict
// reports `updated: ["Status"]` only).
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.updated.includes('Current Phase'), `expected 'Current Phase' in updated: ${JSON.stringify(output.updated)}`);
assert.ok(output.updated.includes('Current Phase Name'), `expected 'Current Phase Name' in updated: ${JSON.stringify(output.updated)}`);
const fm = frontmatterLib.extractFrontmatter(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'));
assert.strictEqual(fm.current_phase, '99', 'the reported field must match what was actually persisted');
assert.strictEqual(fm.current_phase_name, 'Curated Stale Name');
});
test('E3: cmdStateBeginPhase — `updated` names only the fields whose persisted value actually changed (happy path)', () => {
const content = [
'---', 'gsd_state_version: 1.0', '---', '',
'# Project State', '', '## Current Position', '', 'Status: Not started', '', '## Session', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'begin-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated, ['Status']);
});
test('E3 (preservation): cmdStateBeginPhase — a curated field the transition never touches IS in `updated`', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'stopped_at: "curated stop must survive"', '---', '',
'# Project State', '', '## Current Position', '', 'Status: Not started', '', '## Session', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'begin-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated.slice().sort(), ['Status', 'Stopped At']);
});
test('E4: cmdStateRecordSession — `updated` names only the fields whose persisted value actually changed (happy path)', () => {
const content = [
'---', 'gsd_state_version: 1.0', '---', '',
'# Project State', '', '## Session', '', '**Last session:** old', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'record-session', '--stopped-at', 'Phase 1 complete'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated.slice().sort(), ['Last session', 'Stopped At']);
});
test('E4 (preservation): cmdStateRecordSession — a curated field the transform never touches IS in `updated`', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'current_phase_name: Curated Name', '---', '',
'# Project State', '', '## Session', '', '**Last session:** old', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'record-session', '--stopped-at', 'Phase 1 complete'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated.slice().sort(), ['Current Phase Name', 'Last session', 'Stopped At']);
});
// E8 — untraced in the design's own analysis pass: cmdStatePlannedPhase
// and cmdStateCompletePhase (the DIFFERENT legacy hand-rolled one) are
// traced here rather than assumed, per the design's own instruction.
test('E8a: cmdStatePlannedPhase reconciles the same way as cmdStateBeginPhase (traced, not assumed)', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'stopped_at: "curated stop must survive"', '---', '',
'# Project State', '', '## Current Position', '', 'Status: Not started', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.updated, ['Stopped At']);
});
// E8b — cmdStateCompletePhase (the legacy hand-rolled path, NOT the
// transitionCore-based one cmdPhaseComplete uses): its `updated` mixes
// FIELD names with the SECTION name 'Current Position'. Reconciliation
// must apply only to the field-shaped entries and pass 'Current Position'
// through unconditionally, never dropping it as a false negative.
test('E8b: cmdStateCompletePhase (legacy) reconciles field entries and passes the "Current Position" section entry through unconditionally', () => {
const content = [
'---', 'gsd_state_version: 1.0', 'paused_at: "curated pause must survive"', 'current_phase: 1', '---', '',
'# Project State', '', '## Current Position', '', 'Phase: 1', 'Status: Executing', 'Last activity: 2026-01-01', '',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools(['state', 'complete-phase'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.updated.includes('Current Position'), 'the whole-section entry must not be dropped as a false negative by field-shaped reconciliation');
assert.ok(output.updated.includes('Paused At'), '#3345\'s direction must also apply to this legacy path');
const fm = frontmatterLib.extractFrontmatter(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'));
assert.strictEqual(fm.paused_at, 'curated pause must survive');
});
});
// ─── Required fast-check property: D3's copy really is gone ───────────────
// For any (curated frontmatter value, body-source value) pair on one of the
// six D3-governed fields, with the body delta held UNCHANGED (cmdStateJson
// is a read, never a write, so its synthetic delta is definitionally
// unchanged — {pre:v, post:v}) — the value the write seam would PERSIST
// equals the value `state json` REPORTS for the identical document. Both
// sides now dispatch through the exact same `applyPreserveWhenUnchanged`
// executor (state-transition.cts), so this is the "D3's copy really is
// gone" identity the matrix requires, at the consumer's output on both
// sides (write: the real persisted file; read: the real `state json` CLI).
// Seed pinned, runs bounded, replay data printed on failure, frozen clock
// (Phase 2's property could never pass without this — `last_updated` moves
// between the two invocations otherwise).
describe('Required property: write-seam persistence and `state json` agree for the same document (D3)', () => {
const FIELD_BODY = {
current_phase: (v) => ({ section: '## Current Position', line: `Current Phase: ${v}` }),
current_phase_name: (v) => ({ section: '## Current Position', line: `Phase: 1 (${v})` }),
current_plan: (v) => ({ section: '## Current Position', line: `Current Plan: ${v}` }),
stopped_at: (v) => ({ section: '## Session', line: `Stopped At: ${v}` }),
paused_at: (v) => ({ section: '## Session', line: `Paused At: ${v}` }),
status: (v) => ({ section: '## Current Position', line: `Status: ${v}` }),
};
function buildDoc(field, curated, derived) {
const fmLines = ['gsd_state_version: 1.0'];
if (curated !== null) fmLines.push(`${field}: ${JSON.stringify(curated)}`);
const spec = derived !== null ? FIELD_BODY[field](derived) : null;
const sections = spec
? [spec.section, '', spec.line, '']
: ['## Current Position', '', '(no annotation)', ''];
return ['---', ...fmLines, '---', '', '# Project State', '', ...sections].join('\n');
}
test('property: write-seam persisted value equals `state json` reported value', (t) => {
const PINNED_MS = 1_700_000_000_000;
t.mock.timers.enable(['Date']);
t.mock.timers.setTime(PINNED_MS);
const safeString = fc.array(
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-'.split('')),
{ minLength: 1, maxLength: 12 },
).map((chars) => chars.join(''));
fc.assert(
fc.property(
fc.constantFrom('current_phase', 'current_phase_name', 'current_plan', 'stopped_at', 'paused_at', 'status'),
fc.option(safeString, { nil: null }),
fc.option(safeString, { nil: null }),
(field, curated, derived) => {
const tmp = createFixture();
t.after(() => cleanup(tmp));
const statePath = path.join(tmp, '.planning', 'STATE.md');
const content = buildDoc(field, curated, derived);
fs.writeFileSync(statePath, content);
// Write-side: syncAndPreserveStateMd with an UNCHANGED delta
// (transformedContent === originalContent) — the same regime
// cmdStateJson's synthetic {pre,post} delta represents.
const written = stateLib.syncAndPreserveStateMd(content, content, statePath, tmp, false);
const writeFm = frontmatterLib.extractFrontmatter(written);
const writeVal = writeFm[field] !== undefined && writeFm[field] !== null ? String(writeFm[field]) : null;
// Read-side: the real `state json` CLI, on the SAME on-disk document.
const jsonResult = runGsdTools(['state', 'json'], tmp);
if (!jsonResult.success) {
throw new Error(`state json failed: field=${field} curated=${JSON.stringify(curated)} derived=${JSON.stringify(derived)}\n${jsonResult.error}`);
}
const readFm = JSON.parse(jsonResult.output);
const readVal = readFm[field] !== undefined && readFm[field] !== null ? String(readFm[field]) : null;
if (writeVal !== readVal) {
throw new Error(
`D3 copy divergence: field=${field} curated=${JSON.stringify(curated)} derived=${JSON.stringify(derived)}\n` +
`write-seam persisted=${JSON.stringify(writeVal)} vs state-json reported=${JSON.stringify(readVal)}\n` +
`document:\n${content}`,
);
}
return true;
},
),
{ seed: 3471, numRuns: 40 },
);
});
});
// ─── Parity: FRONTMATTER_KEY_TO_BODY_LABEL vs FIELD_CLASSIFICATION (#3471 review) ─
// A second hand-maintained table beside FIELD_CLASSIFICATION is exactly the
// "policy declared in one table, a second table beside it drifting quietly"
// shape this epic exists to remove — CLAUDE.md's "Generative Fix
// Divergence" entry requires a parity assertion for any two surfaces
// sharing a constant. `bodyLabelFor` (state.cts) throws for a
// preserve-when-unchanged field missing here (mirrors `throwUnwiredRow` in
// state-transition.cts) rather than silently falling back to the raw
// snake_case name — this test is what keeps that throw unreachable.
describe('Parity: every preserve-when-unchanged row has a FRONTMATTER_KEY_TO_BODY_LABEL entry (#3471 review)', () => {
test('FRONTMATTER_KEY_TO_BODY_LABEL has a label for every FIELD_CLASSIFICATION preserve-when-unchanged row', () => {
const preserveWhenUnchangedFields = Object.keys(stateTransitionMod.FIELD_CLASSIFICATION)
.filter((field) => stateTransitionMod.FIELD_CLASSIFICATION[field].preservation === 'preserve-when-unchanged');
// Sanity: the table this test pins is non-empty — a passing loop over
// zero fields would be a vacuous-truth false green (CLAUDE.md's Test
// Cleanup rule).
assert.ok(preserveWhenUnchangedFields.length > 0, 'expected at least one preserve-when-unchanged row to pin');
const missing = preserveWhenUnchangedFields.filter(
(field) => !Object.prototype.hasOwnProperty.call(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL, field),
);
assert.deepStrictEqual(missing, [], `preserve-when-unchanged field(s) with no body label, would hit bodyLabelFor's throw: ${JSON.stringify(missing)}`);
});
// Reverse direction: every label row IS a real field, and is either
// preserve-when-unchanged (the contract this table documents) or absent
// from FIELD_CLASSIFICATION entirely — never a preserve-always /
// preserve-if-placeholder field masquerading with a stale label.
test('every FRONTMATTER_KEY_TO_BODY_LABEL row is a preserve-when-unchanged FIELD_CLASSIFICATION field', () => {
const wrongPolicy = Object.keys(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL).filter((field) => {
const cls = stateTransitionMod.getFieldClassification(field);
return cls !== null && cls.preservation !== 'preserve-when-unchanged';
});
assert.deepStrictEqual(wrongPolicy, [], `FRONTMATTER_KEY_TO_BODY_LABEL row(s) whose FIELD_CLASSIFICATION policy is not preserve-when-unchanged: ${JSON.stringify(wrongPolicy)}`);
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #2444: stopped_at frontmatter must not be overwritten by historical body prose
// ─────────────────────────────────────────────────────────────────────────────