fix(#3699): report why a derived frontmatter key was not written, and repair a missing body source (#3846)

* test(#3699): failing-first coverage for derived-key reporting and the case-D fallback

* fix(#3699): report why a derived frontmatter key was not written, and repair a missing body source

* fix(#3699): scope session-field writes to ## Session so an archived line cannot absorb the update

* fix(#3699): resolve the session writer from body labels only, so a frontmatter key never writes the body

* chore(#3699): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-24 23:20:42 -04:00
committed by GitHub
parent 36375513b9
commit de95c03f72
7 changed files with 785 additions and 11 deletions

View File

@@ -1278,16 +1278,53 @@ describe('ADR-3408 §8.3(b) Matrix D: patchCore strips frontmatter first (#3469)
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');
// D7 (independence, extend): updateCore strips frontmatter first — the correct
// shape patchCore now matches — so a frontmatter-shaped `field` cannot reach
// the YAML block by text replacement.
//
// NARROWED BY #3699, deliberately. The original assertion was "a
// frontmatter-shaped field can NEVER reach the YAML block", which was a true
// characterisation of updateCore when this test was written as an independence
// guard for #3469 — but it is broader than the rule ADR-3408 actually states.
// §8.3(b)'s invariant is "no transition core calls `stateReplaceField` on
// unstripped content" (ADR-3408 line 318), and #3699's repair path honours it:
// it strips frontmatter, edits the PARSED object, and re-serialises via
// `reconstructFrontmatter` — it never runs the body-field text replacer over
// YAML, which is the dangerous shape the rule exists to forbid.
//
// So the invariant is re-pinned here at the ADR's actual boundary, in both
// directions, rather than deleted.
test('D7: a frontmatter-shaped field cannot reach the YAML block while a body source exists', () => {
// The body carries `Current Phase`, so the body IS the writable route and
// the frontmatter key must be refused exactly as before.
const input = [
'---', 'current_phase: "3"', '---', '',
'# State', '', '**Current Phase:** 3', '**Status:** Planning', '',
].join('\n');
const result = transitionCore(input, { kind: 'update', field: 'current_phase', value: '9' }, deps);
assert.strictEqual(result.content, input);
assert.strictEqual(result.content, input, 'no write may occur through the frontmatter key');
assert.strictEqual(result.data && result.data.updated, false);
assert.ok(/^current_phase: "3"$/m.test(result.content));
});
test('D7b: the #3699 repair path is the ONLY way frontmatter is written, and it never text-replaces over YAML', () => {
// Body source absent — the case-D repair shape. The write is permitted here,
// and `updated` is the field name rather than `false`.
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.data && result.data.updated, true);
assert.strictEqual(result.data && result.data.wroteFrontmatter, true, 'the repair path must announce itself');
assert.ok(/^current_phase: 9$/m.test(result.content), 'the frontmatter key carries the new value');
// ADR-3408 §8.3(b) still holds: the body is untouched and the frontmatter
// block was REBUILT from the parsed object, not text-patched in place. A
// `stateReplaceField` pass over unstripped content would have left the rest
// of the document's frontmatter formatting alone; re-serialisation is what
// proves the parsed-object route was taken.
assert.ok(result.content.includes('**Status:** Planning'), 'the body must be untouched');
assert.ok(!/current_phase: "9"/.test(result.content), 'the value went through the YAML serialiser, not a text splice');
});
});
// ADR-1769 Phase 7: update, prune, sync

View File

@@ -29,6 +29,8 @@ const fc = require('fast-check');
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');
// #3699: the repo's one metacharacter-escape helper (local/no-adhoc-regex-escape).
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const frontmatterLib = require('../gsd-core/bin/lib/frontmatter.cjs');
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
const workstreamInventory = require('../gsd-core/bin/lib/workstream-inventory.cjs');
@@ -16490,3 +16492,406 @@ describe('#3468 B8: a drifted / malformed / unparseable STATE.md never reaches t
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// #3699 — `state update` told the truth about failure.
//
// A frontmatter key like `stopped_at` is a PROJECTION of a body field, and the
// body is the source of truth. Asking to update the key used to return
// `Field "stopped_at" not found in STATE.md` — byte-identical to what a
// genuinely absent field returns, and pointing away from the route that works.
//
// Case D is the one real capability gap: frontmatter carries the key, the body
// has no source line, and neither route can write. `updateCore` now falls back
// to writing the frontmatter key directly there (and only there).
// ─────────────────────────────────────────────────────────────────────────────
describe('#3699 state update — derived frontmatter keys explain themselves', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
const FM = [
'---',
'gsd_state_version: 1.0',
'current_phase: 1',
'current_phase_name: alpha',
'status: executing',
'stopped_at: "original value"',
'---',
'',
];
const BODY = ['# Project State', '', '## Current Position', '', 'Phase: 1 (alpha)', 'Status: Executing', ''];
const SESSION = ['## Session Continuity', '', 'Stopped at: original value', ''];
function writeState(lines, opts = {}) {
const eol = opts.crlf ? '\r\n' : '\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join(eol));
}
function update(field, value) {
const result = runGsdTools(['state', 'update', field, value], tmpDir);
return { result, output: JSON.parse(result.output) };
}
function stateText() {
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
}
function frontmatterStoppedAt() {
const m = stateText().match(/^stopped_at:.*$/m);
return m ? m[0] : null;
}
// ── the headline defect: present-but-derived vs genuinely absent ───────────
test('a body-derived frontmatter key is reported as derived, and names its body source', () => {
writeState([...FM, ...BODY, ...SESSION]);
const { output } = update('stopped_at', 'NEW VALUE');
assert.strictEqual(output.updated, false);
assert.match(output.reason, /not directly writable/i);
assert.match(output.reason, /Stopped At/i, 'the reason must name the body source that DOES work');
assert.doesNotMatch(output.reason, /not found in STATE\.md/i, 'the key is present — reporting absence is the bug');
assert.match(frontmatterStoppedAt(), /original value/, 'a refused update must not write');
});
test('a genuinely absent field still reports absence', () => {
// The control that keeps the fix honest: if EVERY failure now says
// "derived", the defect has been inverted, not closed.
writeState([...FM, ...BODY, ...SESSION]);
const { output } = update('definitely_not_a_field', 'NEW VALUE');
assert.strictEqual(output.updated, false);
assert.strictEqual(output.reason, 'Field "definitely_not_a_field" not found in STATE.md');
});
test('a present-but-derived key and a genuinely absent field no longer produce the same message', () => {
// #3699 stated as a test: the two were byte-identical apart from the name.
writeState([...FM, ...BODY, ...SESSION]);
const derived = update('stopped_at', 'NEW VALUE').output.reason;
writeState([...FM, ...BODY, ...SESSION]);
const absent = update('definitely_not_a_field', 'NEW VALUE').output.reason;
assert.notStrictEqual(
derived.replace(/"stopped_at"/g, 'X'),
absent.replace(/"definitely_not_a_field"/g, 'X'),
'the two failures must be distinguishable by more than the field name',
);
});
test('the body source route still works and still syncs to frontmatter', () => {
writeState([...FM, ...BODY, ...SESSION]);
const { output } = update('Stopped at', 'NEW VALUE');
assert.strictEqual(output.updated, true);
assert.match(frontmatterStoppedAt(), /NEW VALUE/);
});
// ── case D: the capability gap ────────────────────────────────────────────
test('case D: with no body source, the frontmatter key becomes directly writable', () => {
// Frontmatter carries stopped_at; the body has no `Stopped at:` line and no
// `## Session` section. Before this, BOTH routes failed and the stale value
// survived — the document was unrepairable through `state update`.
writeState([...FM, ...BODY]);
const { output } = update('stopped_at', 'NEW VALUE');
assert.strictEqual(output.updated, true);
assert.strictEqual(output.wrote, 'frontmatter');
assert.match(
frontmatterStoppedAt(),
/NEW VALUE/,
'the value must survive syncStateFrontmatter + applyStatePreservation, not just be written by the transition',
);
});
test('case D: the fallback is idempotent across repeated writes', () => {
writeState([...FM, ...BODY]);
update('stopped_at', 'FIRST');
assert.match(frontmatterStoppedAt(), /FIRST/);
const { output } = update('stopped_at', 'SECOND');
assert.strictEqual(output.updated, true);
assert.match(frontmatterStoppedAt(), /SECOND/);
});
test('case D: preserved does not claim a restore that authoritativeFm overrode', () => {
// Preservation DOES restore stopped_at's snapshot here (its body source is
// unchanged — absent), and authoritativeFm then overrides it. Listing the
// field in `preserved` would report a restore that did not survive: the
// same unfalsifiable-success shape this issue is about, one field over.
writeState([...FM, ...BODY]);
const { output } = update('stopped_at', 'NEW VALUE');
const claimed = (output.preserved || []).map((p) => String(p).toLowerCase());
assert.ok(
!claimed.includes('stopped at') && !claimed.includes('stopped_at'),
`preserved must not claim this field; got: ${JSON.stringify(output.preserved)}`,
);
});
test('case D via the body field name names the frontmatter key that still holds a value', () => {
writeState([...FM, ...BODY]);
const { output } = update('Stopped at', 'NEW VALUE');
assert.strictEqual(output.updated, false);
assert.match(output.reason, /stopped_at/, 'the reason must name the frontmatter key carrying the value');
});
// ── negative space: where the fallback must NOT fire ──────────────────────
test('the fallback does not fire when frontmatter does not carry the key', () => {
// Nothing to repair — inventing a key here would be fabricating state.
writeState([...FM.filter((l) => !l.startsWith('stopped_at:')), ...BODY]);
const { output } = update('stopped_at', 'NEW VALUE');
assert.strictEqual(output.updated, false);
assert.strictEqual(frontmatterStoppedAt(), null, 'no frontmatter key may be invented');
});
test('a stale body-source line OUTSIDE ## Session is not treated as the source', () => {
// Reversed from this change's first cut, on evidence. That cut suppressed the
// repair whenever ANY body line existed, reasoning "prefer a line the user can
// edit". But `buildStateFrontmatter` harvests Stopped At from `## Session`
// ONLY, so an archive line is not a source — suppressing on it left the
// document unrepairable AND pointed the user at a command that rewrote the
// wrong line. Read scope, write scope and probe scope now all agree.
writeState([
...FM, ...BODY,
'## Session', '', 'Notes: none', '',
'## Session Continuity Archive', '', 'Stopped At: 2025-01-01 (old session)', '',
]);
const { output } = update('stopped_at', '2026-08-24');
assert.strictEqual(output.updated, true, 'an archive line must not block the repair');
assert.strictEqual(output.wrote, 'frontmatter');
assert.match(
stateText(),
/Stopped At: 2025-01-01 \(old session\)/,
'the archived line is a historical record and must be left alone',
);
});
test('updating a session field never rewrites a line outside ## Session', () => {
// The defect this guards: `stateReplaceField` matches the FIRST occurrence
// anywhere in the body, so with no `Stopped At:` in `## Session` and a stale
// one in the archive, the update reported success while silently rewriting
// the archived record and leaving the real field untouched. #3374 established
// the scoped writer for exactly this; `updateCore` had not adopted it.
writeState([
...FM, ...BODY,
'## Session', '', 'Notes: none', '',
'## Session Continuity Archive', '', 'Stopped At: 2025-01-01 (old session)', '',
]);
const { output } = update('Stopped At', '2026-08-24');
assert.strictEqual(output.updated, false, 'there is no Stopped At line in ## Session to write');
assert.match(
stateText(),
/Stopped At: 2025-01-01 \(old session\)/,
'the archived line must be byte-identical after a refused update',
);
assert.doesNotMatch(stateText(), /Stopped At: 2026-08-24/, 'nothing may have been written anywhere');
});
test('a session field inside ## Session is still writable and still syncs', () => {
// The complement: scoping must not break the normal route.
writeState([...FM, ...BODY, '## Session', '', 'Stopped at: original value', '']);
const { output } = update('Stopped at', 'NEW VALUE');
assert.strictEqual(output.updated, true);
assert.match(stateText(), /^Stopped at: NEW VALUE$/m, 'the session line is the one that moved');
assert.match(frontmatterStoppedAt(), /NEW VALUE/, 'and it synced to frontmatter');
});
test('case D behaves identically on a CRLF document', () => {
writeState([...FM, ...BODY], { crlf: true });
const { output } = update('stopped_at', 'NEW VALUE');
assert.strictEqual(output.updated, true);
assert.match(frontmatterStoppedAt(), /NEW VALUE/);
});
// ── keys with no body source must not be given one ───────────────────────
test('keys derived from the clock, ROADMAP.md, or a disk scan say so instead of naming a body field', () => {
const cases = [
['last_updated', /recomputed on every write/i],
['state_head', /recomputed on every write/i],
['gsd_state_version', /recomputed on every write/i],
['milestone', /ROADMAP\.md/i],
['milestone_name', /ROADMAP\.md/i],
['progress.percent', /scan of \.planning\/phases/i],
['progress.total_plans', /scan of \.planning\/phases/i],
];
for (const [field, expected] of cases) {
writeState([...FM, ...BODY, ...SESSION]);
const { output } = update(field, 'X');
assert.strictEqual(output.updated, false, `${field} must not be writable`);
assert.match(output.reason, expected, `${field}: wrong derivation named`);
assert.doesNotMatch(output.reason, /Update its body source/i, `${field} has no body source to name`);
}
});
// ── the map cannot silently drift from the builder ───────────────────────
test('every FRONTMATTER_BODY_SOURCE entry actually round-trips from its body field', () => {
// Real parity, per key. An earlier cut asserted only SET MEMBERSHIP against
// the emitted frontmatter — near-vacuous, because buildStateFrontmatter emits
// the whole schema key set regardless of body derivation, so a wrong mapping
// would still pass.
//
// This drives each mapped key's own BODY LABEL to a distinct value and
// asserts that value arrives in that frontmatter key. A mapping naming the
// wrong body field cannot survive it.
//
// Two fixtures, because `paused_at` is not independent: normalizeStateStatus
// forces `status: paused` whenever Paused At is set, so a single fixture
// could not assert both `status` and `paused_at`.
const expected = {
current_phase: '7',
current_phase_name: 'sentinel-name',
current_plan: '3',
status: 'executing', // normalized from the update below
stopped_at: 'sentinel-stopped',
last_activity: '2026-08-19',
last_activity_desc: 'sentinel-desc',
};
writeState([
'---', 'gsd_state_version: 1.0', '---', '',
'# Project State', '',
'## Current Position', '',
'Current Phase: 7',
'Current Phase Name: sentinel-name',
'Current Plan: 3',
'Status: Planning', // deliberately != the update below, or the #948 no-op guard skips the sync
'Last Activity: 2026-08-19',
'Last Activity Description: sentinel-desc',
'',
'## Session', '',
'Stopped at: sentinel-stopped',
'',
]);
update('Status', 'Executing');
let fm = stateText().split('---')[1];
assert.match(fm, /^last_updated:/m, 'precondition: the update must have actually synced frontmatter');
for (const [key, want] of Object.entries(expected)) {
const hit = new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(fm);
assert.ok(hit, `${key} was not emitted from its mapped body field — the mapping is wrong`);
assert.match(
hit[1],
new RegExp(escapeRegex(want)),
`${key} did not carry the value written to its mapped body field`,
);
}
// paused_at, in its own fixture for the reason above.
writeState([
'---', 'gsd_state_version: 1.0', '---', '',
'# Project State', '',
'## Current Position', '',
'Current Phase: 7',
'Status: Planning',
'',
'## Session', '',
'Paused At: sentinel-paused',
'',
]);
update('Status', 'Executing');
fm = stateText().split('---')[1];
const paused = /^paused_at:\s*(.+)$/m.exec(fm);
assert.ok(paused, 'paused_at was not emitted from its mapped body field');
assert.match(paused[1], /sentinel-paused/);
// And the fixtures above must have covered the whole map — otherwise a key
// added to FRONTMATTER_BODY_SOURCE could go untested here forever.
const covered = new Set([...Object.keys(expected), 'paused_at']);
for (const key of Object.keys(stateTransitionMod.FRONTMATTER_BODY_SOURCE)) {
assert.ok(covered.has(key), `FRONTMATTER_BODY_SOURCE maps "${key}" but this round-trip test does not exercise it`);
}
});
test('no body-derived frontmatter key escapes FRONTMATTER_BODY_SOURCE', () => {
// The reverse direction. Every key buildStateFrontmatter emits must be either
// mapped, or a declared non-body-derived key. A NEW body-derived key added to
// the builder without a map entry fails here.
//
// Known limit, stated rather than hidden: someone could add a key to the
// exclusion set below instead of the map. That is a smaller and far more
// visible edit than silently forgetting the map, which is what this guards.
const NOT_BODY_DERIVED = new Set([
'gsd_state_version', // schema constant
'last_updated', 'state_head', // recomputed every write
'milestone', 'milestone_name', // ROADMAP.md
'progress', // disk scan
]);
writeState([
'---', 'gsd_state_version: 1.0', '---', '',
'# Project State', '',
'## Current Position', '',
'Current Phase: 2', 'Current Phase Name: beta', 'Current Plan: 1',
'Status: Planning',
'Last Activity: 2026-08-19 — did a thing',
'',
'## Session', '', 'Stopped at: somewhere', 'Paused At: elsewhere', '',
]);
update('Status', 'Executing');
const fm = stateText().split('---')[1];
assert.match(fm, /^last_updated:/m, 'precondition: the write must have synced frontmatter');
const emitted = fm.split('\n')
.filter((l) => /^[a-z_]+:/.test(l))
.map((l) => l.split(':')[0].trim());
const mapped = new Set(Object.keys(stateTransitionMod.FRONTMATTER_BODY_SOURCE));
for (const key of emitted) {
assert.ok(
mapped.has(key) || NOT_BODY_DERIVED.has(key),
`buildStateFrontmatter emits "${key}", which is neither mapped in FRONTMATTER_BODY_SOURCE nor declared non-body-derived — `
+ 'if it is body-derived, `state update` cannot name its body source',
);
}
// And the map may not carry a key the builder never emits.
for (const key of mapped) {
assert.ok(emitted.includes(key), `FRONTMATTER_BODY_SOURCE maps "${key}", which the builder did not emit — the map has drifted`);
}
});
test('property: every body label round-trips back to its frontmatter key', () => {
const entries = Object.entries(stateTransitionMod.FRONTMATTER_BODY_SOURCE);
fc.assert(
fc.property(fc.integer({ min: 0, max: entries.length - 1 }), fc.boolean(), (i, upper) => {
const [key, labels] = entries[i];
for (const label of labels) {
const probe = upper ? label.toUpperCase() : label.toLowerCase();
assert.strictEqual(
stateTransitionMod.frontmatterKeyForBodyField(probe),
key,
`"${probe}" must resolve back to "${key}"`,
);
}
}),
{ numRuns: 25 },
);
});
test('inherited prototype members are not treated as fields', () => {
// Both lookups are own-property only; a prototype member must not produce a
// bogus "is a derived key" reason.
for (const probe of ['toString', 'constructor', 'valueOf', '__proto__', 'hasOwnProperty']) {
assert.strictEqual(stateTransitionMod.getFrontmatterBodySource(probe), null, `${probe} is not a frontmatter key`);
assert.strictEqual(stateTransitionMod.frontmatterKeyForBodyField(probe), null, `${probe} is not a body field`);
}
});
});