refactor(#3469): one composition for the STATE.md write seam (#3501)

* docs(#3469): amend ADR-3408 section 8.3 — the pipeline has sanctioned exceptions

Section 8.3 read 'Every STATE.md write applies the pipeline.' That is false by
design for two commands, and acting on it would have inverted a shipped
feature.

Preservation makes curated frontmatter win over a re-derived body value.
state sync exists to do the opposite — #905's 'body annotation beats existing
frontmatter when both are present'; it re-derives frontmatter FROM the body.
REGENERATE_STATE is a factory reset that rebuilds STATE.md from scratch.
Applying the pipeline to either would re-lock exactly what the command was
invoked to replace.

This issue's own scope line, inherited from the epic, said to route the direct
writeStateMd callers through the pipeline. For cmdStateSync that would have
shipped silently, with every gate green, because no test asserts that sync
LETS the body win. Caught by reading the helper's docstring and then verifying
the claim against the code — a stale comment had already misdirected this epic
once.

Both commands are now named in a closed exception list and are permanent
ratchet entries.

Consequence recorded rather than left to bite Phase 4: the 'drive the ratchet
to 0 and delete the file' target in this ADR and in #3471 is wrong. Two
entries are permanent, so the correct end state is 2, and the honest report is
'0 removable bypasses, 2 sanctioned'. A guard reaching 0 here would only do so
by having stopped looking at two real writers.

* refactor(#3469): one composition for the write seam, not one per caller

Implements ADR-3408 section 8.3 as amended.

syncAndPreserveStateMd is now the single composition of syncStateFrontmatter
and applyPostSyncPreservation. readModifyWriteStateMd and cmdPhaseComplete
both CALL it instead of each assembling the two steps themselves.
cmdPhaseComplete keeps its own writePlanningFileSet envelope — the
composition returns content, it does not take over the write, so STATE.md
still commits atomically with ROADMAP and REQUIREMENTS.

Assembling the stages at a call site is a re-derivation even when every step
calls an owner. Upstream's fix(#3374) routed cmdPhaseComplete through
applyPostSyncPreservation but left it calling syncStateFrontmatter directly
first, so the composition was duplicated and free to diverge with both guards
green. That is ADR-3180 Amendment 2's finding repeating on the write side.

cmdMilestoneComplete gains preservation. It wrote through writeStateMd, so it
got sync and no preservation — the identical shape #3374 reported for
phase.complete, and flagged upstream as a follow-up in the helper's own
docstring. This is that follow-up.

Divergence is now visible: preservation_warnings names each field restored
over a disagreeing derived value. Deliberately NOT named warnings —
cmdPhaseComplete already exposes warnings as a prose string array, and two
sibling commands carrying that name with different element types is
Generative Fix Divergence, the class this epic exists to remove.

patchCore stops running stateReplaceField over the whole document. One
observable consequence, intended per design row 9: a frontmatter-shaped patch
key with no body counterpart now reports failed instead of silently
succeeding, because the old whole-document match was literally hitting the
YAML line case-insensitively.

The guard closes Phase 1's DECLARED KNOWN GAP as promised rather than
re-deferring it: section 8.3(b) detection is tractable now the composition
exists. Scoped by two factors to avoid Phase 1's measured 29-to-1 false
positive rate — a variable field-name argument AND a content argument whose
nearest preceding assignment is not stripFrontmatter. Verified 0 findings and
0 false positives across all 33 call sites, plus 5 synthetic shapes. It also
detects the re-assembly shape above.

Ratchet: 4 entries to 2, both sanctioned-permanent. cmdStateSync's owner
changes from #3471 to sanctioned-permanent per Amendment 2 — routing it
through preservation would invert the #905 contract.

Also fixed inline rather than deferred: cmdMilestoneComplete's STATE.md read
now happens inside withStateLock. It previously read outside any lock before
writeStateMd took its own, leaving a TOCTOU window under concurrent writers.

* test(#3469): characterization coverage for the single write seam

Matrix sections A-E. Criterion 6 was amended by maintainer decision — all five
instances closed by point fixes while Phase 1 was in flight — so these are
characterization tests at the consumer's output per ADR-3180 Decision 4(b)/(c),
paired with the drift guard's count, never either alone.

Section C is the one that earns its keep. cmdStateSync is a sanctioned
permanent exception: state sync exists to re-derive frontmatter FROM the body,
so preservation there re-locks exactly what the command was invoked to
replace. C1 pins that the body wins; C4 pins that this phase left the command
byte-identical. Nothing else in the suite would notice if a future change made
sync start preserving, and the natural reading of 'one write seam' is to make
precisely that change.

Section E pins the guard's false-positive scoping. E4 (updateCore's
strip-then-replace) and E5 (sectionBody-scoped calls) must NOT be reported —
the naive detector measured 29 false positives to 1 true positive in Phase 1.
E7 is the inverse: a sanctioned-permanent entry disappearing must FAIL,
because a guard reaching zero here would only do so by having stopped looking
at two real writers.

Also corrects a stale test that asserted patchCore's old whole-document
behavior, which this phase deliberately changes.

One honest limitation, flagged rather than papered over: A1's 'byte-identical
to pre-refactor' cannot be diffed against real pre-refactor bytes from inside
the suite. It is implemented as the seeded fast-check property that
cmdPhaseComplete's composed output equals readModifyWriteStateMd's for the
same inputs — the strongest available proxy, not the literal claim.

* docs(#3469): refresh the seam glossary entry and add the changeset

Two spec-review gaps, both real.

CONTEXT.md's STATE.md Transition Module entry named three direct writeStateMd
callers including cmdMilestoneComplete. This phase routed that one through the
composition, so the line was false the moment the refactor landed.

Worth recording plainly: I wrote that sentence in Phase 0, correcting an
older stale pointer in it, and my own Phase 2 change invalidated it again
within the same epic. That is the exact drift this epic exists to remove,
demonstrated on the epic's own documentation — and it is why the entry now
ends by saying the whole-repo drift guard, not this line, is the authoritative
count.

The entry now records the composition (syncAndPreserveStateMd) and states that
exactly two direct callers remain, both SANCTIONED PERMANENT rather than debt.

Changeset: type Changed, because milestone complete's observable output moves.
Tier-2 per ADR-3180 Decision 3 — a stale body line no longer wins over fresher
frontmatter, and the command gains preservation_warnings. Docs requirement is
met by the ADR amendment already in this diff.

* test(#3469): register property-test temp-dir cleanup at creation time

Standards review, minor but real: the new fast-check property cleaned up its
temp dirs in a loop AFTER fc.assert returned. A genuine property failure
throws, so that line never ran and every dir from the failing run — including
all of fast-check's shrinking iterations — leaked.

The failure path is exactly when a littered machine hurts most, and a failing
property test is the case the test exists for.

Cleanup is now registered with t.after() at dir-creation time, so teardown
happens however the test exits. Not try/finally — CONTRIBUTING.md:356 bans it
inside test bodies, which is why the after-the-assertion shape existed in the
first place.

Swept the rest of the branch's test diff for the same shape; phase.test.cjs
already uses registered teardown and nothing else matched.

* fix(#3469): patchCore routes frontmatter writes instead of dropping them

Checkpoint returned 10 failures of 33880. One implementation defect, three
test defects, one stale test — all fixed, and the implementation defect is the
one that matters.

patchCore stripped frontmatter and then reconstructed it VERBATIM, applying no
patches to it. An arbitrary custom frontmatter key with no body counterpart and
no FIELD_CLASSIFICATION row — risk_level in the upstream fix(#3351) test —
therefore always reported failed and silently never wrote. It worked before,
via the old whole-document match on the raw YAML line.

That is a regression against this phase's own design row 9, which requires
frontmatter changes to ROUTE THROUGH the seam — still work, policy-governed —
not to stop working. Removing a capability is not routing it. An upstream test
caught it, which is the argument for running the checkpoint before believing
the refactor.

patchCore now partitions by frontmatter shape, decided structurally from the
parsed frontmatter's own keys rather than a naming heuristic:
  - classified keys still report failed — policy owns them and a raw patch may
    not bypass it;
  - unclassified keys apply to the frontmatter object and report updated —
    Phase 1's behavior-table row 19, a field with no row is not this contract's
    business;
  - body-shaped keys are unchanged.

The property 'failure' was my own test breaking the repo's Clock Seams rule.
The two paths agree byte-for-byte; the only difference was last_updated,
stamped from the wall clock on two invocations milliseconds apart, so it could
never pass. Time is now frozen with mock.timers across both — not by excluding
last_updated from the comparison, which would have silently stopped comparing
a field the composition writes.

B4's fixture could not discriminate: normalizeStateStatus maps any text
containing 'complete' to 'completed', and milestone complete's own new body
value derives to exactly that — which was also the fixture's stale value. The
stale value is now 'executing' so the assertion can tell 'body correctly won'
from 'stale survived'.

B5's fixture tripped a pre-existing unstarted-phase guard before reaching any
write-seam code; it now has the matching phase directory.

D9 asserted the old exempt set. readModifyWriteStateMd now calls one symbol
rather than assembling two, so it needs no exemption; syncAndPreserveStateMd
is the sole legitimate composition site.

* fix(#3469): patchCore resolves body-first, so the body wins a name collision

Re-verification returned 2 failures of 33880, both D4 — the hostile row for a
key that exists as BOTH a frontmatter key and a body field.

The partition checked frontmatter first, so 'status' — classified in
FIELD_CLASSIFICATION and also present as a body 'Status:' line — routed to the
frontmatter branch, was rejected as classified, and reported failed.

Wrong order. Patching 'status' means the body field, and upstream fix(#3351)
says so in its own comment: 'the legitimate working case for state.patch is
display-cased BODY fields — Status, Current Plan, Phase.' The body is
authoritative in this model; frontmatter is the projection. D4 asserted
exactly that and was right.

Resolution order is now body, then frontmatter:
  1. resolves to a body field -> apply to body, updated
  2. else an own key of the frontmatter:
       classified   -> failed  (policy owns it)
       unclassified -> apply to frontmatter, updated
  3. else -> failed

Verified by probe against the compiled lib for all four cases rather than
asserted: risk_level (frontmatter-only, unclassified) still lands;
current_phase still fails; display-cased Status unchanged; D4's lower-cased
status now lands via the body with the frontmatter untouched.

The current_phase case was the one that could have regressed silently, so its
fixture was read rather than assumed — D1's body carries 'Phase: 3 (alpha)'
and no 'Current Phase:' line, so body-first cannot reach it.

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

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 16:04:09 -04:00
committed by GitHub
parent 71180983a0
commit e2f4c16d9e
15 changed files with 1443 additions and 152 deletions

View File

@@ -2094,6 +2094,81 @@ test('extractFrontmatter handles large frontmatter blocks without body bleed', (
// The body prose the transition wrote stays as designed.
__assert2736.match(stateContent, /Phase: 2 — Closer-ruling measurement \(D1a\)/);
});
// ADR-3408 §8.3 Matrix A5 (#3469, regression-critical): the OTHER branch
// of phase.cts's #3350 pairing decision — when the body carries NO
// Phase:/Current Phase field at all (narrative prose), authoritativeFm
// pairs BOTH current_phase and current_phase_name so the two frontmatter
// fields never describe different phases. The #2736 re-assertion (applied
// after preservation, via the shared syncAndPreserveStateMd composition
// this phase introduced) must still win over any restore for BOTH keys.
__t2736('A5 (#3469): with no body Phase field, the paired authoritativeFm override wins for BOTH current_phase and current_phase_name', () => {
const planningDir = __path2736.join(tmpDir, '.planning');
const phase1Dir = __path2736.join(planningDir, 'phases', '01-foundation');
__fs2736.mkdirSync(phase1Dir, { recursive: true });
__fs2736.writeFileSync(
__path2736.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Foundation',
`- [ ] Phase 2: ${PAREN_NAME_2736}`,
'',
'### Phase 1: Foundation',
'**Goal:** Setup',
'**Plans:** 1 plans',
'',
`### Phase 2: ${PAREN_NAME_2736}`,
'**Goal:** Measure closer rulings',
'',
].join('\n'),
);
// Deliberately NO `Phase:` / `Current Phase:` line in the body.
__fs2736.writeFileSync(
__path2736.join(planningDir, 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'current_phase: 1',
'current_phase_name: Foundation',
'status: executing',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Currently working through Phase 1 setup tasks.',
'',
].join('\n'),
);
__fs2736.writeFileSync(__path2736.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n');
__fs2736.writeFileSync(__path2736.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n');
__fs2736.writeFileSync(
__path2736.join(phase1Dir, '01-VERIFICATION.md'),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
);
const result = __run2736(['phase', 'complete', '1'], tmpDir);
__assert2736.ok(result.success, `phase complete failed: ${result.error}`);
const stateContent = __fs2736.readFileSync(__path2736.join(planningDir, 'STATE.md'), 'utf-8');
const fm = extractFrontmatter(stateContent);
__assert2736.strictEqual(
fm.current_phase_name,
PAREN_NAME_2736,
`current_phase_name must be the exact next-phase display name; got ${JSON.stringify(fm.current_phase_name)}`,
);
__assert2736.strictEqual(
String(fm.current_phase),
'2',
'#3350: current_phase must be PAIRED with current_phase_name when the body carries no Phase field, ' +
`so the two frontmatter fields never describe different phases; got ${JSON.stringify(fm.current_phase)}`,
);
});
});
__d2736('#2736 sibling: state begin-phase preserves a paren-containing name (e2e)', () => {
@@ -2391,8 +2466,16 @@ test('extractFrontmatter handles large frontmatter blocks without body bleed', (
`first-write population from the (refreshed) body line must keep working; got ${JSON.stringify(fm.stopped_at)}`,
);
});
});
}
// ADR-3408 §8.3 Matrix A4 (#3469): satisfied by the "AC1 preservation leg"
// test above ('with no session Stopped at line to refresh, the fresher
// frontmatter value survives') — that scenario now runs through the ONE
// write-seam composition (`syncAndPreserveStateMd`) instead of the
// hand-assembled sync+preserve pair `applyPostSyncPreservation`'s docstring
// used to describe. No new test added here: duplicating an already-passing,
// identically-shaped assertion adds no coverage.
// ────────────────────────────────────────────────────────────────────────

View File

@@ -111,6 +111,9 @@ describe('milestone complete command', () => {
const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8');
assert.ok(milestones.includes('v1.0 MVP Foundation'));
assert.ok(milestones.includes('Set up project infrastructure'));
// B6 (ADR-3408 §8.3/#3469, independence): the new preservation-warnings
// channel must not disturb archival/roadmap behavior — it is additive.
assert.ok(Array.isArray(output.preservation_warnings), 'preservation_warnings must be an array');
});
test('#2118 — --dry-run does NOT mutate: no archive, no STATE.md rewrite, no phase move', () => {
@@ -514,6 +517,142 @@ describe('milestone complete command', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// ADR-3408 §8.3 Matrix B (#3469): cmdMilestoneComplete now routes through the
// shared write-seam composition (syncAndPreserveStateMd) instead of the bare
// writeStateMd — the "real exposure" Finding 2 identified (the #3374 shape,
// applied to milestone.complete). Test matrix:
// .gsd/phase/refactor-3469-one-write-seam/50-test-matrix.md
// ─────────────────────────────────────────────────────────────────────────────
describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
// Frontmatter + a ## Session Stopped at body line milestoneCompleteCore
// never touches — the delta is therefore always "unchanged" for this
// field, exactly the #3374 shape (a stale body value vs. a curated
// frontmatter value) that Finding 2 says was silently clobbered pre-#3469.
function writeStateWithSession(dir, { fmStoppedAt, sessionStoppedAt, fmStatus = 'executing' } = {}) {
const fmLines = ['---', 'gsd_state_version: 1.0'];
if (fmStatus !== null) fmLines.push(`status: ${fmStatus}`);
if (fmStoppedAt !== undefined && fmStoppedAt !== null) fmLines.push(`stopped_at: "${fmStoppedAt}"`);
fmLines.push('---', '');
const bodyLines = [
'# State',
'',
'**Status:** In progress',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working',
'',
'## Session',
'',
'**Last session:** 2025-01-01T00:00:00.000Z',
];
if (sessionStoppedAt !== undefined && sessionStoppedAt !== null) {
bodyLines.push(`**Stopped at:** ${sessionStoppedAt}`);
}
bodyLines.push('');
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), fmLines.concat(bodyLines).join('\n'));
}
// B1: stale body stopped_at, fresher frontmatter — frontmatter preserved.
test('B1: stale body Stopped at does not clobber a fresher curated frontmatter stopped_at', () => {
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 state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const fm = parseFrontmatter(state);
assert.strictEqual(
fm.stopped_at,
'Phase 7 verified PASS',
`frontmatter stopped_at must be preserved over the stale body value; got ${JSON.stringify(fm.stopped_at)}`,
);
});
// B2 (consumer-level, the criterion-6 substitute): the preserved value is
// observable through a SEPARATE subsequent CLI call reading the persisted
// file — not just this test's own fs.readFileSync of the writer's output.
test('B2: the preserved frontmatter value is observable via a separate `state get` call', () => {
writeStateWithSession(tmpDir, { fmStoppedAt: 'Phase 7 verified PASS', sessionStoppedAt: 'Phase 3 work' });
const complete = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(complete.success, `Command failed: ${complete.error}`);
const got = runGsdTools('state get stopped_at', tmpDir);
assert.ok(got.success, `state get failed: ${got.error}`);
const gotOutput = JSON.parse(got.output);
assert.ok(
typeof gotOutput.stopped_at === 'string' && gotOutput.stopped_at.includes('Phase 7 verified PASS'),
`a second, independent CLI call must observe the preserved value; got ${JSON.stringify(gotOutput)}`,
);
});
// B3: divergence emits a structured, typed warning — never a rendered
// message. `preservation_warnings` (NOT `warnings`) is a distinct field
// shape from cmdPhaseComplete's prose `warnings: string[]` — see
// milestone.cts's own comment on Generative Fix Divergence.
test('B3: a preserved divergence emits preservation_warnings[0].field === "stopped_at"', () => {
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.ok(Array.isArray(output.preservation_warnings));
assert.ok(output.preservation_warnings.length > 0, 'a divergence must produce at least one warning');
assert.strictEqual(output.preservation_warnings[0].field, 'stopped_at');
assert.strictEqual(output.preservation_warnings[0].reason, 'preserved-over-disagreeing-derived');
});
// B4 (the false-positive guard): the body is genuinely newer THIS write —
// milestoneCompleteCore unconditionally rewrites body Status, so the delta
// always fires "changed" for `status`; the stale curated frontmatter value
// must NOT be restored, and no warning is emitted for it.
test('B4: a body field genuinely changed by this write is not preserved, and emits no warning', () => {
// #3469: `normalizeStateStatus` keyword-maps any "complete"-containing
// text to 'completed', and this write's own new body value ("v1.0
// milestone complete") legitimately derives to 'completed' too — so a
// stale curated value of 'completed' cannot discriminate "body won" from
// "stale value survived". Use a stale value that cannot collide with the
// derived result.
writeStateWithSession(tmpDir, { fmStatus: 'executing', fmStoppedAt: undefined, sessionStoppedAt: undefined });
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const statusWarning = output.preservation_warnings.find((w) => w.field === 'status');
assert.strictEqual(statusWarning, undefined, 'status changed this write — must not be reported preserved');
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const fm = parseFrontmatter(state);
assert.notStrictEqual(fm.status, 'executing', 'the stale curated status must not survive — body won');
assert.strictEqual(fm.status, 'completed', 'body-derived status must land');
});
// B5 (boundary): no pre-existing curated frontmatter to diverge from — no
// warning is emitted at all, and output is otherwise unaffected.
test('B5: no divergence at all — preservation_warnings is empty', () => {
writeRoadmap(tmpDir, `# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n`);
// #3469: give Phase 1 a matching phase directory so the pre-existing
// unstarted-phase guard (src/milestone.cts) does not trip before any
// write-seam code runs — keeps this test exercising the real happy path.
mkPhaseDir(tmpDir, '01-foundation', { oneLiner: 'Setup' });
writeState(tmpDir); // no frontmatter at all — nothing curated to diverge from
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, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phases clear command
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -6546,6 +6546,137 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
);
});
});
// ─────────────────────────────────────────────────────────────────────────
// ADR-3408 §8.3 Matrix A (#3469): cmdPhaseComplete now calls the ONE
// write-seam composition (syncAndPreserveStateMd) directly instead of
// hand-assembling syncStateFrontmatter + applyPostSyncPreservation itself
// (Finding 3's re-derivation). Rows A2/A3's general identity claim (the
// composition agrees with itself regardless of caller) is covered by the
// required fast-check property test in tests/state.test.cjs; this block
// covers the ones that need a REAL cmdPhaseComplete run — A6/A7's atomic
// 3-file commit, plus a concrete consumer-level demonstration that BOTH
// sync (a body-derived field advances) and preservation (an untouched
// curated field survives) fire together through the real CLI path.
// ─────────────────────────────────────────────────────────────────────────
describe('ADR-3408 §8.3 Matrix A: cmdPhaseComplete write-seam composition (#3469)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3469-a-'));
});
afterEach(() => {
cleanup(tmpDir);
});
// A2/A3 (concrete, consumer-level): the same real cmdPhaseComplete call
// both advances a body-derived field (progress.completed_phases, via the
// transition) AND restores a CLASSIFIED preserve-when-unchanged field
// (`paused_at`) completePhaseCore's transform never touches — proof the
// seam applies sync AND `applyPostSyncPreservation`'s policy TOGETHER
// through the real adapter, not just the transition alone.
test('A2/A3: phase.complete advances the body-derived phase AND restores an untouched preserve-when-unchanged field, in one write', () => {
setupPhase3517Project(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Seed a curated `paused_at` — completePhaseCore never writes the
// `## Session` `Paused At` body line, so its pre/post body source is
// unchanged (both absent) and the classified preserve-when-unchanged
// row must restore this curated value over the sync's empty derive.
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}`);
const state = fs.readFileSync(statePath, 'utf8');
const fm = extractFrontmatter(state);
// A2/A3: the transition genuinely advanced the body-derived phase...
assert.equal(Number(fm.progress && fm.progress.completed_phases), 2);
// ...while `applyPostSyncPreservation`'s classified restore fired in
// the SAME write — the composition's preservation stage ran, not just
// its sync stage.
assert.equal(
fm.paused_at,
'curated pause note — must survive',
'a classified preserve-when-unchanged field completePhaseCore never touches must survive the same write',
);
});
// A6 (independence): the composition returns content; the adapter's own
// atomic 3-file envelope is unaffected — ROADMAP, REQUIREMENTS, and
// STATE.md all change together as one unit.
test('A6: STATE.md, ROADMAP.md, and REQUIREMENTS.md commit atomically as one unit', () => {
setupPhase3517Project(tmpDir);
const paths = {
state: path.join(tmpDir, '.planning', 'STATE.md'),
roadmap: path.join(tmpDir, '.planning', 'ROADMAP.md'),
};
const before = {
state: fs.readFileSync(paths.state, 'utf8'),
roadmap: fs.readFileSync(paths.roadmap, 'utf8'),
};
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
assert.ok(r.success, `call failed: ${r.error}`);
const after = {
state: fs.readFileSync(paths.state, 'utf8'),
roadmap: fs.readFileSync(paths.roadmap, 'utf8'),
};
assert.notStrictEqual(after.state, before.state, 'STATE.md must change');
assert.notStrictEqual(after.roadmap, before.roadmap, 'ROADMAP.md must change');
});
// A7 (IO failure, filesystem-failure category): a mid-commit failure on
// the FIRST file in the atomic set (ROADMAP.md) leaves NONE of the three
// partially written — including STATE.md, which now flows through the
// shared syncAndPreserveStateMd composition before writePlanningFileSet
// ever sees it. `t.mock.method` auto-restores at test end — never
// chmod 0o000, which root bypasses under Docker/CI.
test('A7: a failure writing ROADMAP.md (first in the atomic set) leaves STATE.md and REQUIREMENTS.md untouched', (t) => {
setupPhase3517Project(tmpDir);
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const before = {
roadmap: fs.readFileSync(roadmapPath, 'utf8'),
req: fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf8') : null,
state: fs.readFileSync(statePath, 'utf8'),
};
writePassedVerificationForPhase(tmpDir, '5');
const phaseModule = require('../gsd-core/bin/lib/phase.cjs');
const originalWriteFileSync = fs.writeFileSync;
t.mock.method(fs, 'writeFileSync', function injectedRoadmapWriteFailure(target, ...args) {
const targetPath = String(target);
if (targetPath === roadmapPath || targetPath === `${roadmapPath}.tmp.${process.pid}`) {
const err = new Error('injected ROADMAP.md write failure');
err.code = 'EIO';
throw err;
}
return originalWriteFileSync.call(this, target, ...args);
});
assert.throws(
() => phaseModule.cmdPhaseComplete(tmpDir, '5', false),
/injected ROADMAP\.md write failure/,
);
const after = {
roadmap: fs.readFileSync(roadmapPath, 'utf8'),
req: fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf8') : null,
state: fs.readFileSync(statePath, 'utf8'),
};
assert.strictEqual(after.roadmap, before.roadmap, 'ROADMAP.md must be unchanged');
assert.strictEqual(after.req, before.req, 'REQUIREMENTS.md must be unchanged');
assert.strictEqual(after.state, before.state, 'STATE.md must be unchanged — none of the three partially written');
});
});
}
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -1145,13 +1145,148 @@ describe('ADR-1769 Phase 6: patch transition — field updates', () => {
assert.deepStrictEqual(result.data && result.data.failed, ['Nonexistent']);
});
test('patching a frontmatter YAML key directly updates the YAML line', () => {
// patch operates on the full content (body + frontmatter), so a lowercase
// frontmatter key like `stopped_at` is matched and replaced.
// ADR-3408 §8.3(b) / #3469: STALE as of this phase — patch now strips
// frontmatter FIRST (matching updateCore), so a lowercase frontmatter key
// like `stopped_at` can no longer reach the YAML block through this path.
// The old assertion here ("patch operates on the full content... a
// lowercase frontmatter key is matched and replaced") is the exact bypass
// ADR-3408 §8.3(b) closes: it let an arbitrary caller-supplied patch key
// rewrite YAML frontmatter outside FIELD_CLASSIFICATION, undetected by the
// write-seam guard's Axis 2 (every step called an owner). See the Matrix D
// section below for the corrected contract.
test('patching a frontmatter-shaped key no longer reaches the YAML line (ADR-3408 §8.3(b) / #3469)', () => {
const input = ['---', 'status: executing', 'stopped_at: 2026-01-01', '---', '', '# State', ''].join('\n');
const result = transitionCore(input, { kind: 'patch', patches: { stopped_at: '2026-06-27' } }, deps);
assert.ok(/^stopped_at: 2026-06-27$/m.test(result.content), 'YAML stopped_at must be patched');
assert.deepStrictEqual(result.data && result.data.updated, ['stopped_at']);
assert.ok(!/^stopped_at: 2026-06-27$/m.test(result.content), 'YAML stopped_at must NOT be patched directly');
assert.ok(/^stopped_at: 2026-01-01$/m.test(result.content), 'YAML stopped_at must survive unchanged');
assert.deepStrictEqual(result.data && result.data.updated, []);
assert.deepStrictEqual(result.data && result.data.failed, ['stopped_at']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// ADR-3408 §8.3(b) Matrix D (#3469): patchCore strips frontmatter first,
// matching updateCore — closes Phase 1's declared known gap (a
// frontmatter-shaped patch key could rewrite the YAML block outside
// FIELD_CLASSIFICATION). Test matrix: .gsd/phase/refactor-3469-one-write-seam/50-test-matrix.md
// ─────────────────────────────────────────────────────────────────────────────
describe('ADR-3408 §8.3(b) Matrix D: patchCore strips frontmatter first (#3469)', () => {
const deps = { clock: fixedClock };
// D1: a frontmatter-shaped key (snake_case, no Title-Case body counterpart)
// can never match — patch is body-only now, so the write "routes through
// the seam": a frontmatter change can only happen via FIELD_CLASSIFICATION's
// own preservation/sync machinery, never via a direct patch bypass.
test('D1: a frontmatter-shaped key (current_phase) is reported failed, and the frontmatter is untouched', () => {
const input = [
'---',
'current_phase: "3"',
'---',
'',
'# State',
'',
'## Current Position',
'',
'Phase: 3 (alpha)',
'',
].join('\n');
const result = transitionCore(input, { kind: 'patch', patches: { current_phase: '9' } }, deps);
assert.deepStrictEqual(result.data && result.data.updated, []);
assert.deepStrictEqual(result.data && result.data.failed, ['current_phase']);
assert.ok(/^current_phase: "3"$/m.test(result.content), 'frontmatter current_phase must be unchanged');
assert.strictEqual(result.content, input, 'no-op: content returned verbatim when nothing in the body matched');
});
// D2: a body-shaped key (Title-Case) is the legitimate case and is
// unaffected by the strip-first fix — it was always matched against the
// body, and still is.
test('D2: a body-shaped key (Status) still updates the body — the legitimate, unaffected case', () => {
const input = ['---', 'status: executing', '---', '', '# State', '', '**Status:** Planning', ''].join('\n');
const result = transitionCore(input, { kind: 'patch', patches: { Status: 'Paused' } }, deps);
assert.deepStrictEqual(result.data && result.data.updated, ['Status']);
assert.deepStrictEqual(result.data && result.data.failed, []);
assert.ok(result.content.includes('**Status:** Paused'), 'body Status must be updated');
assert.ok(/^status: executing$/m.test(result.content), 'frontmatter status is untouched by patchCore itself');
});
// D3 (boundary, extend #3351 variants A/B): a display-cased key matching
// the body field succeeds; the SAME field's lower-cased/frontmatter-shaped
// spelling fails — same content, two spellings, two outcomes.
test('D3: display-cased "Current Phase" succeeds; lower-cased "current_phase" fails, on the same content', () => {
const input = [
'---',
'current_phase: "1"',
'---',
'',
'# State',
'',
'**Current Phase:** 1',
'',
].join('\n');
const displayCased = transitionCore(input, { kind: 'patch', patches: { 'Current Phase': '2' } }, deps);
assert.deepStrictEqual(displayCased.data && displayCased.data.updated, ['Current Phase']);
assert.ok(displayCased.content.includes('**Current Phase:** 2'));
const lowerCased = transitionCore(input, { kind: 'patch', patches: { current_phase: '2' } }, deps);
assert.deepStrictEqual(lowerCased.data && lowerCased.data.updated, []);
assert.deepStrictEqual(lowerCased.data && lowerCased.data.failed, ['current_phase']);
});
// D4 (hostile): a key that is simultaneously frontmatter-shaped AND
// case-insensitively matches a body field (stateReplaceField's `^field:`
// pattern is case-insensitive) — the body is the ONE deterministic winner,
// asserted explicitly, because frontmatter is stripped out of the matching
// surface before any pattern ever runs.
test('D4: a key matching both a frontmatter key and a body field — body wins deterministically, frontmatter inert', () => {
const input = ['---', 'status: executing', '---', '', '# State', '', '**Status:** In progress', ''].join('\n');
const result = transitionCore(input, { kind: 'patch', patches: { status: 'Aborted' } }, deps);
assert.deepStrictEqual(result.data && result.data.updated, ['status']);
assert.ok(result.content.includes('**Status:** Aborted'), 'body Status must be the one that changed');
assert.ok(/^status: executing$/m.test(result.content), 'frontmatter status key must never be touched by patchCore');
});
// D5 (boundary, extend): an empty patch is a true no-op — no write, both
// report arrays empty.
test('D5: an empty patch {} is a no-op — content returned verbatim, both arrays empty', () => {
const input = '# State\n\n**Status:** Planning\n';
const result = transitionCore(input, { kind: 'patch', patches: {} }, deps);
assert.strictEqual(result.content, input);
assert.deepStrictEqual(result.data && result.data.updated, []);
assert.deepStrictEqual(result.data && result.data.failed, []);
});
// D6 (hostile): __proto__ / constructor as patch keys must not pollute
// Object.prototype. `intent.patches` is built via JSON.parse (the shape a
// real `state.patch` JSON payload takes) specifically because object-
// literal syntax special-cases `__proto__` — JSON.parse does not, and is
// the classic prototype-pollution vector this test must exercise for real.
test('D6: __proto__ / constructor patch keys do not pollute Object.prototype', () => {
const patches = JSON.parse('{"__proto__":"evil","constructor":"evil2"}');
const input = '# State\n\n**Status:** Planning\n';
const result = transitionCore(input, { kind: 'patch', patches }, deps);
// Object.prototype itself must be untouched.
assert.strictEqual(Object.getPrototypeOf({}), Object.prototype);
assert.strictEqual(({}).polluted, undefined);
assert.strictEqual(typeof ({}).constructor, 'function');
// Behaves like any other unmatched field — no body line named
// `__proto__` or `constructor` exists, so both are reported failed.
assert.deepStrictEqual((result.data && result.data.updated) || [], []);
assert.deepStrictEqual((result.data && result.data.failed || []).sort(), ['__proto__', 'constructor']);
assert.strictEqual(result.content, input);
});
// D7 (independence, extend): updateCore is unchanged — it already strips
// frontmatter first, the correct shape patchCore now matches. A
// frontmatter-shaped `field` still cannot reach the YAML block through it.
test('D7: updateCore is unchanged — a frontmatter-shaped field still cannot reach the YAML block', () => {
const input = ['---', 'current_phase: "3"', '---', '', '# State', '', '**Status:** Planning', ''].join('\n');
const result = transitionCore(input, { kind: 'update', field: 'current_phase', value: '9' }, deps);
assert.strictEqual(result.content, input);
assert.strictEqual(result.data && result.data.updated, false);
assert.ok(/^current_phase: "3"$/m.test(result.content));
});
});

View File

@@ -50,9 +50,11 @@ const {
findSeamBypasses,
findPromptSeamUses,
findPolicyDispatchDrift,
findUnstrippedContentWrites,
applyRatchet,
loadBaseline,
buildBaselineEntries,
collect,
SEAM_OWNER_FILE,
SEAM_OWNER_EXEMPT_FUNCTIONS,
EXECUTOR_FILE,
@@ -255,18 +257,23 @@ describe('D8 — comments are not drift', () => {
describe('D9 — owner functions are exempt', () => {
test('guard: owner functions are exempt', () => {
assert.ok(SEAM_OWNER_EXEMPT_FUNCTIONS.includes('readModifyWriteStateMd'));
// #3469: `readModifyWriteStateMd` now calls the single
// `syncAndPreserveStateMd` symbol rather than assembling the two seam
// calls itself, so it needs no exemption — `syncAndPreserveStateMd` is
// the sole legitimate place `syncStateFrontmatter(` and
// `applyPostSyncPreservation(` appear together (the composition every
// OTHER caller, including `readModifyWriteStateMd`, now routes through).
assert.ok(SEAM_OWNER_EXEMPT_FUNCTIONS.includes('syncAndPreserveStateMd'));
const text = [
'function readModifyWriteStateMd(cwd) {',
' const modified = compute();',
' writeStateMd(statePath, modified, cwd);',
' return modified;',
'function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, resync) {',
' const synced = syncStateFrontmatter(transformedContent, cwd);',
' return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, resync);',
'}',
].join('\n');
// The seam's own internal plumbing (the I/O wrapper calling the pure
// sync stage) is not a bypass.
// The seam's own internal plumbing (the one owned composition —
// sync then post-sync preservation) is not a bypass.
assert.deepStrictEqual(findSeamBypasses(SEAM_OWNER_FILE, text), []);
});
});
@@ -564,3 +571,190 @@ describe('D15 — file (and field) values are sanitized at construction', () =>
assert.ok(!out[0].field.includes(C1_CSI));
});
});
// ─────────────────────────────────────────────────────────────────────────
// Phase 2 (#3469) — ADR-3408 §8.3 Matrix section E: guard rows closing
// Phase 1's declared known gap (Axis 3, §8.3(b)) and pinning the ratchet's
// new 2-permanent-entry shape (Amendment 2). Test matrix:
// .gsd/phase/refactor-3469-one-write-seam/50-test-matrix.md
//
// E4/E5 are the false-positive guards — the exact shape that measured 29
// false positives to 1 true positive in Phase 1's naive co-occurrence
// approximation (see this guard's own header, Axis 3). E7 is the inverse: a
// sanctioned-permanent entry vanishing from the observed tree must FAIL, not
// silently reach zero — a guard reaching zero here would only do so by
// having stopped looking at a real writer.
// ─────────────────────────────────────────────────────────────────────────
describe('E1 — a re-assembled composition at a new call site is detected', () => {
test('guard: a call site invoking syncStateFrontmatter and applyPostSyncPreservation directly (bypassing syncAndPreserveStateMd) is caught on BOTH calls', () => {
// Finding 3's exact shape (ADR-3408 Amendment 2): every step calls an
// owner, so neither call alone is undeclared — but assembling the PAIR
// at a call site outside the seam composition is the re-derivation §8.3
// forbids by name. Synthetic: the real instance of this shape
// (cmdPhaseComplete's pre-#3469 adapter) was fixed by this same phase.
const text = [
'function cmdReassembledAdapter(cwd, statePath, stateContent) {',
' let synced = syncStateFrontmatter(stateContent, cwd, authoritativeFm);',
' synced = applyPostSyncPreservation(originalStateContent, stateContent, synced, statePath, true, authoritativeFm);',
' return synced;',
'}',
].join('\n');
const observed = findSeamBypasses(OTHER_FILE, text);
assert.strictEqual(observed.length, 2, 'both re-assembled stages must be caught, not just one');
assert.deepStrictEqual(observed.map((f) => f.symbol).sort(), ['applyPostSyncPreservation', 'syncStateFrontmatter']);
const findings = applyRatchet(observed, { entries: [] });
assert.strictEqual(findings.length, 2);
assert.ok(findings.every((f) => f.reason === REASON.SEAM_BYPASS_UNRECORDED));
});
});
describe('E2 — a legitimate single call to the composition is not detected', () => {
test('guard: calling syncAndPreserveStateMd (the ONE write-seam composition) is not a bypass', () => {
// Verbatim from src/milestone.cts's real cmdMilestoneComplete call site
// (ADR-3408 Amendment 2's third caller).
const text = [
' const finalContent = syncAndPreserveStateMd(',
' originalStateContent,',
' result.content,',
' statePath,',
' cwd,',
' true,',
' undefined,',
' undefined,',
' divergedFields,',
' );',
].join('\n');
assert.deepStrictEqual(findSeamBypasses(OTHER_FILE, text), []);
});
});
describe('E3 — a patchCore-style frontmatter write is detected (closes the Phase 1 declared gap)', () => {
test('guard: stateReplaceField over unstripped content with a variable field name is caught', () => {
// The pre-Phase-2 shape #3469 fixed: patchCore ran stateReplaceField
// over content that was never stripped of frontmatter, letting a
// lowercase/frontmatter-shaped patch key rewrite the YAML block
// directly, outside FIELD_CLASSIFICATION.
const text = [
'function patchCoreOld(content, intent) {',
' let modified = content;',
' for (const [field, value] of Object.entries(intent.patches)) {',
' const replaced = stateReplaceField(modified, field, value);',
' if (replaced !== null) modified = replaced;',
' }',
' return { content: modified };',
'}',
].join('\n');
const out = findUnstrippedContentWrites(EXECUTOR_FILE, text);
assert.strictEqual(out.length, 1);
assert.strictEqual(out[0].reason, REASON.UNSTRIPPED_CONTENT_WRITE);
assert.strictEqual(out[0].line, 4);
});
});
describe('E4 — updateCore\'s strip-then-replace is NOT detected', () => {
test('guard: the real updateCore call site (content stripped first) is not flagged', () => {
// Verbatim from src/state-transition.cts's real updateCore — the shape
// Phase 1 measured a naive co-occurrence detector at 29 false positives
// to 1 true positive against; this is one of the 29.
const text = [
'function updateCore(content, intent) {',
' const existingFm = extractFrontmatter(content) as Record<string, unknown>;',
' const hasFrontmatter = Object.keys(existingFm).length > 0;',
' const body = stripFrontmatter(content);',
' const result = stateReplaceField(body, intent.field, intent.value);',
' if (result === null) {',
' return { content, updated: [], data: { updated: false } };',
' }',
'}',
].join('\n');
assert.deepStrictEqual(findUnstrippedContentWrites(EXECUTOR_FILE, text), []);
});
});
describe('E5 — sectionBody-scoped stateReplaceField calls are NOT detected', () => {
test('guard: a literal field name against a non-stripFrontmatter-derived section slice is not flagged', () => {
// Verbatim from src/state-transition.cts's real mutateCurrentPositionFirstTime:
// sectionBody is a Current-Position section slice (frontmatter-free by
// construction — it comes from body.slice(...), never from raw content),
// and the field name is a fixed Title-Case literal that can never
// collide with a lowercase/snake_case YAML key. One of the ~20 calls
// Phase 1's naive detector over-reported.
const text = [
'function mutateCurrentPositionFirstTime(body, intent, today, updated) {',
' const span = locateCurrentPosition(body);',
' if (span === null) return body;',
' let sectionBody = body.slice(span.start, span.end);',
' const phaseLabel = `${intent.phaseNumber} — EXECUTING`;',
' if (/^Phase:/m.test(sectionBody)) {',
' sectionBody = sectionBody.replace(/^Phase:.*$/m, `Phase: ${phaseLabel}`);',
' } else {',
" const replaced = stateReplaceField(sectionBody, 'Phase', phaseLabel);",
' if (replaced !== null) sectionBody = replaced;',
' }',
'}',
].join('\n');
assert.deepStrictEqual(findUnstrippedContentWrites(EXECUTOR_FILE, text), []);
});
});
describe('E6 — ratchet: exactly 2 sanctioned-permanent entries remain (limit)', () => {
test('guard: the real baseline has exactly 2 permanent entries, and the real tree matches it with zero findings', () => {
const baseline = loadBaseline();
assert.strictEqual(
baseline.entries.length,
2,
'ADR-3408 Amendment 2: the ratchet holds exactly 2 sanctioned-permanent entries, not 0 — ' +
'Phase 4 does not drive this baseline to empty',
);
for (const entry of baseline.entries) {
assert.strictEqual(entry.owner, 'sanctioned-permanent');
}
const { seamFindings } = collect();
const findings = applyRatchet(seamFindings, baseline);
assert.deepStrictEqual(findings, [], 'the real tree must match the 2-entry baseline exactly');
});
});
describe('E7 — ratchet: a sanctioned-permanent entry disappearing fails (limit-1)', () => {
test('guard: removing one of the two permanent entries from the observed tree is reported STALE, not silently accepted', () => {
const baseline = loadBaseline();
assert.strictEqual(baseline.entries.length, 2);
// Simulate one sanctioned entry (cmdStateSync's writeStateMd call)
// vanishing from the observed tree — exactly the shape §8.3's closed
// exception list forbids: a sanctioned exception may not silently
// disappear (a guard reaching zero here would only do so by having
// stopped looking at a real writer).
const vanished = baseline.entries[0];
const stillPresent = baseline.entries[1];
const observed = [{ file: stillPresent.file, source: stillPresent.source, symbol: stillPresent.symbol, line: 1 }];
const findings = applyRatchet(observed, baseline);
assert.strictEqual(findings.length, 1);
assert.strictEqual(findings[0].reason, REASON.BASELINE_ENTRY_STALE);
assert.strictEqual(findings[0].file, vanished.file);
assert.strictEqual(findings[0].observed, 0);
assert.strictEqual(findings[0].acknowledged, 1);
});
});
describe('E8 — ratchet: a 3rd bypass beside the 2 sanctioned entries fails as unrecorded (limit+1)', () => {
test('guard: a new, unacknowledged writeStateMd call alongside the 2 sanctioned entries fails', () => {
const baseline = loadBaseline();
assert.strictEqual(baseline.entries.length, 2);
const matchingObserved = baseline.entries.map((e) => ({ file: e.file, source: e.source, symbol: e.symbol, line: 1 }));
const newBypass = { file: OTHER_FILE, source: 'writeStateMd(statePath, modified, cwd);', symbol: 'writeStateMd', line: 42 };
const observed = [...matchingObserved, newBypass];
const findings = applyRatchet(observed, baseline);
assert.strictEqual(findings.length, 1);
assert.strictEqual(findings[0].reason, REASON.SEAM_BYPASS_UNRECORDED);
assert.strictEqual(findings[0].file, OTHER_FILE);
});
});

View File

@@ -13,6 +13,9 @@ const os = require('os');
const path = require('path');
const { runGsdTools, createTempDir, createTempProject, cleanup } = require('./helpers.cjs');
const { createFixture, seedWorkstream } = require('./fixtures/index.cjs');
// ADR-3408 §8.3 Matrix A2/A3 (#3469): required fast-check property test — the
// composed cmdPhaseComplete/readModifyWriteStateMd write-seam identity.
const fc = require('fast-check');
// #3187 (ADR-3180 §7.7) matrix sections B/C: in-process access to the chain
// owner and its raw inputs, needed to compute the "owner's answer" a
// consumer's OBSERVABLE output is compared against (Decision 4c) — never the
@@ -4907,6 +4910,151 @@ describe('state sync command', () => {
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(before, after, 'File should not be modified in verify mode');
});
// ADR-3408 §8.3 Matrix C3 (#3469, extend): `--verify` stays a true dry run
// for the sanctioned-exception path too — no write happens even though the
// (unwritten) sync would have let the body win over a curated frontmatter
// value.
test('C3 (#3469): --verify does not write even when the body would win over a curated frontmatter value', () => {
const content = [
'---',
'gsd_state_version: 1.0',
'stopped_at: "curated stale value"',
'---',
'',
'# Project State',
'',
'## Session',
'',
'**Stopped at:** fresh body value',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools('state sync --verify', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.dry_run, true);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(after, content, '--verify must not write, regardless of what a real sync would change');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// ADR-3408 §8.3 Matrix C (#3469): cmdStateSync is a SANCTIONED EXCEPTION.
// The most important section of this phase's matrix — a regression here
// silently INVERTS a shipped feature (#905: "body annotation beats existing
// frontmatter when both are present") with every OTHER gate green. state
// sync exists to re-derive frontmatter FROM the body; routing it through
// preservation would defeat the command. Test matrix:
// .gsd/phase/refactor-3469-one-write-seam/50-test-matrix.md
// ─────────────────────────────────────────────────────────────────────────────
describe('ADR-3408 §8.3 Matrix C: cmdStateSync — the sanctioned exception (#3469)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createFixture(); });
afterEach(() => { cleanup(tmpDir); });
// C1 — the whole point: body annotation vs existing frontmatter, both
// present, differing. `state sync` re-derives frontmatter FROM the body
// (#905) — the body must win, never the curated frontmatter.
test('C1: body annotation wins over existing (differing) frontmatter — the whole point of `state sync`', () => {
const content = [
'---',
'gsd_state_version: 1.0',
'stopped_at: "curated stale value — must NOT survive"',
'---',
'',
'# Project State',
'',
'## Session',
'',
'**Stopped at:** fresh body value — must win',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools('state sync', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const fm = frontmatterLib.extractFrontmatter(state);
assert.strictEqual(
fm.stopped_at,
'fresh body value — must win',
`body must win over the curated frontmatter value; a preservation regression here would ` +
`silently invert #905 with every other gate green; got ${JSON.stringify(fm.stopped_at)}`,
);
});
// C2: a stale-LOOKING frontmatter value (a different field than C1, to pin
// the contract on a second, independent field) loses to a fresh body value.
test('C2: a stale-looking frontmatter current_phase_name loses to a fresh body Phase: name', () => {
const content = [
'---',
'gsd_state_version: 1.0',
'current_phase: "1"',
'current_phase_name: Old Stale Name',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 (Fresh Correct Name)',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools('state sync', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const fm = frontmatterLib.extractFrontmatter(state);
assert.strictEqual(
fm.current_phase_name,
'Fresh Correct Name',
`body must win over the stale-looking curated name; got ${JSON.stringify(fm.current_phase_name)}`,
);
});
// C4 — identity: cmdStateSync's output is byte-identical to pre-refactor —
// no preservation artifact of any kind reaches it. Proven two ways: (a) the
// command's own JSON report carries no `preservation_warnings` key (the
// field ONLY cmdPhaseComplete/cmdMilestoneComplete now expose), and (b) the
// SAME divergence C1 exercises resolves the SAME way (body wins, nothing
// restored) — proving this phase's refactor did not quietly wire the seam
// in here.
test('C4: cmdStateSync carries no preservation_warnings key and never restores a curated value (unchanged by #3469)', () => {
const content = [
'---',
'gsd_state_version: 1.0',
'stopped_at: "curated stale value — must NOT survive"',
'---',
'',
'# Project State',
'',
'## Session',
'',
'**Stopped at:** fresh body value — must win',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
const result = runGsdTools('state sync', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!Object.prototype.hasOwnProperty.call(output, 'preservation_warnings'),
'cmdStateSync must not gain the preservation_warnings channel this phase added to phase.complete/milestone.complete',
);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const fm = frontmatterLib.extractFrontmatter(state);
assert.strictEqual(fm.stopped_at, 'fresh body value — must win', 'no curated value may be restored');
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -9927,6 +10075,105 @@ describe('bug #1230: readModifyWriteStateMd preserves frontmatter status/stopped
});
}
// ─────────────────────────────────────────────────────────────────────────────
// ADR-3408 §8.3 Matrix A1/A2/A3 (#3469): the ONE write-seam composition
// (`syncAndPreserveStateMd`) is now the single owner both `readModifyWriteStateMd`
// and `cmdPhaseComplete`'s atomic-commit adapter call — "the two compositions
// agree because they are one." Required fast-check property, per the matrix's
// closing bullet: for any (content, transform, resync, authoritativeFm),
// cmdPhaseComplete's composed output equals readModifyWriteStateMd's for the
// same inputs. Both sides persist to a REAL file and are compared by reading
// the file back (ADR-3180 Decision 4(c) — the consumer's output, never the
// owner's in-memory return value compared directly). Seed pinned, runs
// bounded, full replay data printed on failure (mirrors tests/state-transition
// .test.cjs's #3468 matrix C3 property).
// ─────────────────────────────────────────────────────────────────────────────
describe('ADR-3408 §8.3 Matrix A1/A2/A3 (property): the shared write-seam composition', () => {
function baseStateMd(phaseNum, phaseName) {
return [
'---',
'gsd_state_version: 1.0',
`current_phase: "${phaseNum}"`,
`current_phase_name: ${phaseName}`,
'status: executing',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
`Phase: ${phaseNum} (${phaseName})`,
'Plan: 1 of 1',
'Status: Executing',
'',
].join('\n');
}
test('property: syncAndPreserveStateMd persists the same bytes whether reached via cmdPhaseComplete\'s shape or readModifyWriteStateMd', (t) => {
// Clock Seam: both paths stamp `last_updated` from `realClock.nowIso()`
// (Date.now() under the hood — src/clock.cts). Without freezing `Date`,
// the two invocations inside each property run happen milliseconds
// apart and `last_updated` genuinely differs, defeating the byte-for-byte
// comparison regardless of the composition itself. Both paths run
// in-process (required directly, not subprocessed), so mocking the
// global `Date` reaches `realClock` without any production change —
// same pattern as tests/commands.test.cjs's HTTP-date pin.
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);
fc.assert(
fc.property(
fc.integer({ min: 1, max: 99 }),
fc.constantFrom('Foundation', 'Execution', 'Wrap-up (final)'),
fc.boolean(), // touchPhaseLine — A2 (false, Phase: unchanged) vs A3 (true, Phase: changed)
fc.boolean(), // resync
fc.boolean(), // withAuthoritativeFm
(phaseNum, phaseName, touchPhaseLine, resync, withAuthoritativeFm) => {
const tmp = createTempDir('gsd-a1a2a3-');
t.after(() => cleanup(tmp));
const original = baseStateMd(phaseNum, phaseName);
// Always change SOMETHING (Status) so neither path's own no-op
// guard short-circuits the comparison — the property is about the
// shared COMPOSITION, not the two different no-op-skip policies
// each adapter legitimately layers around it.
let transformed = original.replace(/^Status: Executing$/m, `Status: Executing phase ${phaseNum}`);
if (touchPhaseLine) {
transformed = transformed.replace(/^Phase: .*/m, `Phase: ${phaseNum} (${phaseName}) — COMPLETE`);
}
const authoritativeFm = withAuthoritativeFm ? { current_phase_name: phaseName } : undefined;
// Path A: cmdPhaseComplete's real adapter shape (phase.cts) —
// syncAndPreserveStateMd, then the adapter's own write.
const pathA = path.join(tmp, 'A.md');
fs.writeFileSync(pathA, original);
const composed = stateLib.syncAndPreserveStateMd(original, transformed, pathA, tmp, resync, authoritativeFm);
fs.writeFileSync(pathA, composed);
// Path B: readModifyWriteStateMd's owner shape — same composition,
// reached through the RMW wrapper every OTHER caller uses.
const pathB = path.join(tmp, 'B.md');
fs.writeFileSync(pathB, original);
stateLib.readModifyWriteStateMd(pathB, () => transformed, tmp, { resync, authoritativeFm });
const bytesA = fs.readFileSync(pathA, 'utf8');
const bytesB = fs.readFileSync(pathB, 'utf8');
if (bytesA !== bytesB) {
throw new Error(
`composition diverged: phaseNum=${phaseNum} phaseName=${JSON.stringify(phaseName)} ` +
`touchPhaseLine=${touchPhaseLine} resync=${resync} withAuthoritativeFm=${withAuthoritativeFm}\n` +
`--- pathA (cmdPhaseComplete shape) ---\n${bytesA}\n--- pathB (readModifyWriteStateMd) ---\n${bytesB}`,
);
}
return true;
},
),
{ seed: 3469, numRuns: 50 },
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-948-state-noop-write-guard.test.cjs — consolidation epic #1969 (B2 #1971)