* fix(#3258): honor all field-classification preservation rows * chore(#3258): set changeset pr to 3447 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/quick-lynx-wander.md
Normal file
5
.changeset/quick-lynx-wander.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3447
|
||||
---
|
||||
Six STATE.md frontmatter fields whose preservation policy is declared in the field-classification table were not honored by the table-driven preservation pass — `last_activity_desc`, `paused_at`, `current_phase`, `current_plan` (preserve-when-unchanged) and `milestone`, `milestone_name` (preserve-if-placeholder). The pass now implements every declared row, so editing a preservation row is a one-row table edit as the table's own contract documents. Curated frontmatter values for `paused_at` / `current_phase` / `current_plan` now survive a body-only write (e.g. `state update`) even when the body carries a stale-but-present derived value — previously only an absent derived value triggered the fallback, so a stale body value silently overwrote the curated frontmatter value. `last_activity_desc` is now governed by a single rule (the table row) rather than a separate date-comparison guard that could disagree with it. (#3258)
|
||||
@@ -158,6 +158,19 @@ export type StatePreservationInput = {
|
||||
* leave this false — the #3242 wholesale protection stays in force.
|
||||
*/
|
||||
deriveProgressKeys?: boolean;
|
||||
/**
|
||||
* #3258: pre/post body-source values for the preserve-when-unchanged (#1230
|
||||
* delta heuristic) family, keyed by frontmatter field. The caller snapshots
|
||||
* each field's body source before/after the transform (mirroring how
|
||||
* buildStateFrontmatter derives it); applyStatePreservation consults
|
||||
* FIELD_CLASSIFICATION and, for every row whose policy is preserve-when-
|
||||
* unchanged that is NOT already on a dedicated channel (status, stopped_at),
|
||||
* restores the pre-write frontmatter value when that body source was left
|
||||
* unchanged by this write. Adding a preserve-when-unchanged row is a one-row
|
||||
* table edit PLUS a bodyDeltas entry from the caller; the invariant test in
|
||||
* tests/state-transition.test.cjs fails if either is missing.
|
||||
*/
|
||||
bodyDeltas?: Record<string, { pre: string | null; post: string | null }>;
|
||||
};
|
||||
|
||||
export type StatePreservationResult = {
|
||||
@@ -271,6 +284,81 @@ export function applyStatePreservation(input: StatePreservationInput): StatePres
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
// ─── #3258: the remaining preserve-when-unchanged rows ────────────────────
|
||||
//
|
||||
// status and stopped_at (above) carry field-specific guards (the 'unknown'
|
||||
// sentinel; ## Session scoping done by the caller) and stay on their
|
||||
// dedicated blocks. The other preserve-when-unchanged rows —
|
||||
// last_activity_desc, paused_at, current_phase, current_plan — share a
|
||||
// uniform #1230 delta heuristic with no extra sentinel, so they are driven
|
||||
// from the table in one loop. This is the reconciliation the maintainer
|
||||
// rescope to #3258 asked for: the row is honored by the table-consuming pass
|
||||
// itself, not only by a weaker absent-value fallback elsewhere.
|
||||
//
|
||||
// For last_activity_desc this replaces preferNewerLastActivity's date-based
|
||||
// desc writes (removed from syncStateFrontmatter + cmdStateJson): the field
|
||||
// is now governed by exactly one rule, this one. For paused_at / current_phase
|
||||
// / current_plan the declared delta heuristic is strictly stronger than the
|
||||
// #905 absent-fallback that syncStateFrontmatter still applies on the
|
||||
// writeStateMd / cmdStateJson paths this pass does not reach — same policy,
|
||||
// two enforcement points, they agree by construction (both restore the
|
||||
// pre-write snapshot value when they fire).
|
||||
const bodyDeltas = input.bodyDeltas;
|
||||
if (bodyDeltas) {
|
||||
for (const field of Object.keys(FIELD_CLASSIFICATION)) {
|
||||
const cls = getFieldClassification(field);
|
||||
if (!cls || cls.preservation !== 'preserve-when-unchanged') continue;
|
||||
// status / stopped_at stay on their dedicated blocks above.
|
||||
if (field === 'status' || field === 'stopped_at') continue;
|
||||
const delta = bodyDeltas[field];
|
||||
if (!delta) continue; // caller did not wire this field's body source → cannot decide
|
||||
const snapshot = preFmSnapshot[field];
|
||||
if (typeof snapshot !== 'string' || snapshot.length === 0) continue;
|
||||
if (delta.pre !== delta.post) continue; // body source changed this write → derived wins
|
||||
if (postFm[field] === snapshot) continue; // already correct, no-op
|
||||
postFm[field] = snapshot;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── #3258: preserve-if-placeholder — milestone / milestone_name ──────────
|
||||
//
|
||||
// The derived milestone_name (from ROADMAP.md via buildStateFrontmatter) must
|
||||
// not clobber a curated name when it resolves to the template placeholder
|
||||
// 'milestone' or a punctuation-led fragment. Mirrors the #948/#2135 guard in
|
||||
// syncStateFrontmatter so the table pass enforces the same policy on the RMW
|
||||
// path; syncStateFrontmatter remains the enforcement point on the writeStateMd
|
||||
// / cmdStateJson paths this pass does not reach (same policy, two points).
|
||||
// The milestone VERSION is restored alongside the name, exactly as the sync
|
||||
// guard does, so the two stay consistent.
|
||||
const milestoneNameCls = getFieldClassification('milestone_name');
|
||||
if (milestoneNameCls !== null && milestoneNameCls.preservation === 'preserve-if-placeholder') {
|
||||
const MILESTONE_PLACEHOLDER = 'milestone';
|
||||
const derivedName = postFm['milestone_name'];
|
||||
const derivedLooksLikeName = typeof derivedName === 'string'
|
||||
&& derivedName.length > 0
|
||||
&& derivedName !== MILESTONE_PLACEHOLDER
|
||||
&& !/^[\s—–:-]/.test(derivedName);
|
||||
const snapshotName = preFmSnapshot['milestone_name'];
|
||||
const snapshotNameIsReal = typeof snapshotName === 'string'
|
||||
&& snapshotName.length > 0
|
||||
&& snapshotName !== MILESTONE_PLACEHOLDER;
|
||||
if (!derivedLooksLikeName && snapshotNameIsReal) {
|
||||
if (postFm['milestone_name'] !== snapshotName) {
|
||||
postFm['milestone_name'] = snapshotName;
|
||||
mutated = true;
|
||||
}
|
||||
const snapshotVersion = preFmSnapshot['milestone'];
|
||||
if (
|
||||
typeof snapshotVersion === 'string' && snapshotVersion.length > 0 &&
|
||||
postFm['milestone'] !== snapshotVersion
|
||||
) {
|
||||
postFm['milestone'] = snapshotVersion;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { postFm, mutated };
|
||||
}
|
||||
|
||||
|
||||
@@ -1466,18 +1466,18 @@ function preferNewerLastActivity(
|
||||
const exDate = exRaw.slice(0, 10);
|
||||
const derDate = derRaw.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate)) return;
|
||||
// #3258: this guard now protects only `last_activity` (a `derive` row) against
|
||||
// the stale-archive regression (#2567). `last_activity_desc` used to be
|
||||
// restored here too (both the older-date and the #3052 same-date branches),
|
||||
// but that was a date-comparison rule — a DIFFERENT policy from the
|
||||
// `preserve-when-unchanged` row its FIELD_CLASSIFICATION entry declares.
|
||||
// Keeping both was two rules that could disagree. last_activity_desc is now
|
||||
// governed by exactly one rule: its table row, enforced by
|
||||
// applyStatePreservation's #1230 delta heuristic on the RMW path (where every
|
||||
// desc-preserving transition — planned-phase / advance / complete / milestone
|
||||
// — runs). The #3052 same-date contract still holds via that delta rule.
|
||||
if (derDate < exDate) {
|
||||
derivedFm['last_activity'] = exRaw;
|
||||
if (existingFm['last_activity_desc'] !== undefined) {
|
||||
derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
|
||||
}
|
||||
} else if (derDate === exDate) {
|
||||
// #3052: same-date — frontmatter is authoritative for this date, so
|
||||
// preserve its last_activity_desc rather than letting the derived body
|
||||
// prose (which may be stale) overwrite it.
|
||||
if (existingFm['last_activity_desc'] !== undefined) {
|
||||
derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2687,6 +2687,24 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
||||
// table's preserve-always row so the rule lives in one place.
|
||||
const preBodyPhaseSource = stateExtractField(preBody, 'Phase');
|
||||
|
||||
// #3258: snapshot the body sources for the additional preserve-when-unchanged
|
||||
// rows applyStatePreservation now honors (last_activity_desc, paused_at,
|
||||
// current_phase, current_plan). Each mirrors buildStateFrontmatter's
|
||||
// derivation so the #1230 delta ("did THIS write change the source?") is
|
||||
// accurate: current_phase combines `Current Phase` with the prose `Phase:`
|
||||
// fallback (parseProsePhaseField, scoped to ## Current Position); paused_at
|
||||
// is session-scoped (mirrors stopped_at); last_activity_desc combines the
|
||||
// `Last Activity Description` field with the prose desc fallback.
|
||||
const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
|
||||
const preBodyCurrentPlan = stateExtractField(preBody, 'Current Plan');
|
||||
const preBodyCurrentPhase = stateExtractField(preBody, 'Current Phase')
|
||||
?? parseProsePhaseField(stateExtractField(preCurrentPositionScope, 'Phase')).phase;
|
||||
const preBodyPausedAt = stateExtractField(preSessionScope, 'Paused At');
|
||||
const preBodyLastActivityRaw = stateExtractField(preBody, 'Last Activity')
|
||||
?? stateExtractField(preBody, 'Last activity');
|
||||
const preBodyLastActivityDesc = stateExtractField(preBody, 'Last Activity Description')
|
||||
?? parseProseLastActivityField(preBodyLastActivityRaw).description;
|
||||
|
||||
const modified = transformFn(content);
|
||||
|
||||
// Bug #948: no-op guard — if the transform produced no change, do NOT write
|
||||
@@ -2715,18 +2733,39 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
||||
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
||||
// current_phase_name delta comparison.
|
||||
const postBodyPhaseSource = stateExtractField(postBody, 'Phase');
|
||||
// #3258: post-transform body sources for the preserve-when-unchanged rows
|
||||
// added in #3258 (mirrors the pre-transform block above).
|
||||
const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
|
||||
const postBodyCurrentPlan = stateExtractField(postBody, 'Current Plan');
|
||||
const postBodyCurrentPhase = stateExtractField(postBody, 'Current Phase')
|
||||
?? parseProsePhaseField(stateExtractField(postCurrentPositionScope, 'Phase')).phase;
|
||||
const postBodyPausedAt = stateExtractField(postSessionScope, 'Paused At');
|
||||
const postBodyLastActivityRaw = stateExtractField(postBody, 'Last Activity')
|
||||
?? stateExtractField(postBody, 'Last activity');
|
||||
const postBodyLastActivityDesc = stateExtractField(postBody, 'Last Activity Description')
|
||||
?? parseProseLastActivityField(postBodyLastActivityRaw).description;
|
||||
const bodyDeltas = {
|
||||
last_activity_desc: { pre: preBodyLastActivityDesc, post: postBodyLastActivityDesc },
|
||||
paused_at: { pre: preBodyPausedAt, post: postBodyPausedAt },
|
||||
current_phase: { pre: preBodyCurrentPhase, post: postBodyCurrentPhase },
|
||||
current_plan: { pre: preBodyCurrentPlan, post: postBodyCurrentPlan },
|
||||
};
|
||||
|
||||
// ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
|
||||
// preservation block is now the pure, table-driven `applyStatePreservation`
|
||||
// in the STATE.md Transition Module. progress / status / stopped_at /
|
||||
// current_phase_name are all governed by their FIELD_CLASSIFICATION row —
|
||||
// one policy source, not three drifting encodings. Behavior-identical to
|
||||
// the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md
|
||||
// one policy source, not three drifting encodings. #3258 extends the same
|
||||
// pass to last_activity_desc / paused_at / current_phase / current_plan
|
||||
// (preserve-when-unchanged) and milestone / milestone_name (preserve-if-
|
||||
// placeholder). Behavior-identical to the pre-#1796 inline block for the
|
||||
// original four fields; this is the absorption ADR-1769 / CONTEXT.md
|
||||
// already claimed shipped.
|
||||
const postFm = extractFrontmatter(synced, statePath) as Record<string, unknown>;
|
||||
const preservation = applyStatePreservation({
|
||||
preFm, postFm, preFmSnapshot, resync,
|
||||
deriveProgressKeys: options?.deriveProgressKeys === true,
|
||||
bodyDeltas,
|
||||
preBodyStatus, postBodyStatus,
|
||||
preBodyStoppedAt, postBodyStoppedAt,
|
||||
preBodyPhaseSource, postBodyPhaseSource,
|
||||
|
||||
@@ -1476,6 +1476,228 @@ describe('ADR-1769 #1796: applyStatePreservation — table-driven post-sync cons
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3258: every FIELD_CLASSIFICATION row declaring a preservation policy must be
|
||||
// honored by applyStatePreservation (the table-consuming pass). The table's own
|
||||
// docstring promises "a policy change is a one-row table edit" — this invariant
|
||||
// proves it: for every non-`derive`/non-`clear` row, a minimal input where the
|
||||
// declared policy would restore the snapshot value DOES restore it. Adding a
|
||||
// new preservation row without an implementation branch makes this fail.
|
||||
//
|
||||
// Written FIRST and RED before the fix. Before the fix this fails for six rows:
|
||||
// last_activity_desc, paused_at, current_phase, current_plan (preserve-when-
|
||||
// unchanged, only approximated by the weaker #905 absent-fallback) and
|
||||
// milestone, milestone_name (preserve-if-placeholder, enforced only by the
|
||||
// #948/#2135 guard in syncStateFrontmatter). See issue #3258.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3258: applyStatePreservation honors every declared preservation row', () => {
|
||||
const GOOD_PLACEHOLDER = 'preserved-by-table';
|
||||
// Universal "body source unchanged this write" deltas. Every
|
||||
// preserve-when-unchanged probe reuses these so the delta condition (pre ===
|
||||
// post) is satisfied and the only variable is whether the branch exists.
|
||||
const SAME = { pre: 'unchanged-source', post: 'unchanged-source' };
|
||||
const unchangedBodyDeltas = {
|
||||
status: SAME,
|
||||
stopped_at: SAME,
|
||||
paused_at: SAME,
|
||||
current_phase: SAME,
|
||||
current_plan: SAME,
|
||||
last_activity_desc: SAME,
|
||||
};
|
||||
// Dedicated-channel deltas (the four pre-#3258 rows use these, unchanged).
|
||||
const sameStatus = { preBodyStatus: 'x', postBodyStatus: 'x' };
|
||||
const sameStoppedAt = { preBodyStoppedAt: 'x', postBodyStoppedAt: 'x' };
|
||||
const samePhaseSource = { preBodyPhaseSource: 'x', postBodyPhaseSource: 'x' };
|
||||
|
||||
// Per-policy probe. Returns whether applyStatePreservation restored the
|
||||
// field's snapshot value under an input crafted so the declared policy fires.
|
||||
function honored(field) {
|
||||
const cls = getFieldClassification(field);
|
||||
if (!cls) return false;
|
||||
const policy = cls.preservation;
|
||||
if (policy === 'derive' || policy === 'clear') return true; // not a preservation policy
|
||||
|
||||
const GOOD = 'preserved-by-table';
|
||||
const BAD = 'clobbered-by-derive';
|
||||
|
||||
if (policy === 'preserve-always') {
|
||||
if (field === 'progress') {
|
||||
const curated = { progress: { total_phases: 4, completed_phases: 3, percent: 75 } };
|
||||
const r = applyStatePreservation({
|
||||
preFm: curated, preFmSnapshot: curated,
|
||||
postFm: { progress: { total_phases: 5, completed_phases: 0, percent: 0 } },
|
||||
resync: false, bodyDeltas: unchangedBodyDeltas,
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
return JSON.stringify(r.postFm.progress) === JSON.stringify(curated.progress);
|
||||
}
|
||||
// current_phase_name: preserve-always, restored when body Phase source unchanged.
|
||||
const r = applyStatePreservation({
|
||||
preFm: null, preFmSnapshot: { [field]: GOOD },
|
||||
postFm: { [field]: BAD }, resync: false, bodyDeltas: unchangedBodyDeltas,
|
||||
...sameStatus, ...sameStoppedAt, preBodyPhaseSource: '3', postBodyPhaseSource: '3',
|
||||
});
|
||||
return r.postFm[field] === GOOD;
|
||||
}
|
||||
|
||||
if (policy === 'preserve-when-unchanged') {
|
||||
const r = applyStatePreservation({
|
||||
preFm: null, preFmSnapshot: { [field]: GOOD },
|
||||
postFm: { [field]: BAD }, resync: true, bodyDeltas: unchangedBodyDeltas,
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
return r.postFm[field] === GOOD;
|
||||
}
|
||||
|
||||
if (policy === 'preserve-if-placeholder') {
|
||||
// Derived name is the placeholder 'milestone'; snapshot holds a real
|
||||
// name+version. Mirrors the #948/#2135 contract: name restored to the
|
||||
// curated snapshot, version restored alongside it.
|
||||
const r = applyStatePreservation({
|
||||
preFm: null,
|
||||
preFmSnapshot: { milestone: GOOD, milestone_name: GOOD },
|
||||
postFm: { milestone: 'derived-version', milestone_name: 'milestone' },
|
||||
resync: true, bodyDeltas: unchangedBodyDeltas,
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
return r.postFm[field] === GOOD;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
test('every non-derive/non-clear preservation row is honored (the one-row-table-edit contract)', () => {
|
||||
const expected = [];
|
||||
for (const [field, cls] of Object.entries(FIELD_CLASSIFICATION)) {
|
||||
if (cls.preservation !== 'derive' && cls.preservation !== 'clear') {
|
||||
expected.push(field);
|
||||
}
|
||||
}
|
||||
const missing = expected.filter((f) => !honored(f));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`applyStatePreservation does not honor these declared preservation rows (expected every ` +
|
||||
`non-derive/non-clear row to restore its snapshot value): ${JSON.stringify(missing)}. ` +
|
||||
`Add a branch per ADR-1769 §4 so the table is the single policy source (#3258).`,
|
||||
);
|
||||
});
|
||||
|
||||
const unchchangedChanged = {
|
||||
status: { pre: 'x', post: 'x' },
|
||||
stopped_at: { pre: 'x', post: 'x' },
|
||||
paused_at: { pre: 'x', post: 'x' },
|
||||
current_phase: { pre: 'x', post: 'x' },
|
||||
current_plan: { pre: 'x', post: 'x' },
|
||||
last_activity_desc: { pre: 'old description', post: 'new description from transition' }, // changed
|
||||
};
|
||||
|
||||
test('a row declaring a preservation policy with no implementation would be caught (sentinel)', () => {
|
||||
// Proves the invariant above actually catches a missing implementation: a
|
||||
// field with a preservation policy but no body-delta wiring / branch does
|
||||
// not get restored. We simulate this by probing a field whose body-source
|
||||
// delta the caller forgot to supply (the realistic regression shape).
|
||||
const r = applyStatePreservation({
|
||||
preFm: null,
|
||||
preFmSnapshot: { current_plan: GOOD_PLACEHOLDER },
|
||||
postFm: { current_plan: 'derived' },
|
||||
resync: true,
|
||||
bodyDeltas: {}, // caller forgot to wire current_plan's body-source delta
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
assert.notEqual(r.postFm.current_plan, GOOD_PLACEHOLDER,
|
||||
'a preserve-when-unchanged row with no body-source delta wiring must NOT be restored ' +
|
||||
'(proves the invariant would catch a new unimplemented row)');
|
||||
});
|
||||
|
||||
// Per-field restore tests (clearer failure messages than the set-equality
|
||||
// invariant alone, and they document each row's declared semantics).
|
||||
|
||||
test('last_activity_desc: preserve-when-unchanged restores snapshot when body source unchanged', () => {
|
||||
const r = applyStatePreservation({
|
||||
preFm: null,
|
||||
preFmSnapshot: { last_activity_desc: 'authoritative description' },
|
||||
postFm: { last_activity_desc: 'stale derived description' },
|
||||
resync: true,
|
||||
bodyDeltas: { ...unchangedBodyDeltas },
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
assert.equal(r.postFm.last_activity_desc, 'authoritative description');
|
||||
assert.equal(r.mutated, true);
|
||||
});
|
||||
|
||||
test('last_activity_desc: derived wins when the body source changed this write (no over-preservation)', () => {
|
||||
const r = applyStatePreservation({
|
||||
preFm: null,
|
||||
preFmSnapshot: { last_activity_desc: 'old description' },
|
||||
postFm: { last_activity_desc: 'new description from transition' },
|
||||
resync: true,
|
||||
bodyDeltas: { ...unchchangedChanged }, // body 'Last Activity Description' moved
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
assert.equal(r.postFm.last_activity_desc, 'new description from transition');
|
||||
assert.equal(r.mutated, false);
|
||||
});
|
||||
|
||||
test('paused_at: preserve-when-unchanged restores curated value over a stale-but-present derived value', () => {
|
||||
// Group 2: the declared #1230 delta heuristic beats the weaker #905
|
||||
// absent-fallback. Derived is PRESENT but stale; body source unchanged →
|
||||
// curated frontmatter value wins.
|
||||
const r = applyStatePreservation({
|
||||
preFm: null,
|
||||
preFmSnapshot: { paused_at: '2026-02-02' },
|
||||
postFm: { paused_at: '2026-01-01' },
|
||||
resync: true,
|
||||
bodyDeltas: { ...unchangedBodyDeltas },
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
assert.equal(r.postFm.paused_at, '2026-02-02');
|
||||
assert.equal(r.mutated, true);
|
||||
});
|
||||
|
||||
test('current_phase: preserve-when-unchanged restores curated value over a stale derived value', () => {
|
||||
const r = applyStatePreservation({
|
||||
preFm: null,
|
||||
preFmSnapshot: { current_phase: '4' },
|
||||
postFm: { current_phase: '2' },
|
||||
resync: true,
|
||||
bodyDeltas: { ...unchangedBodyDeltas },
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
assert.equal(r.postFm.current_phase, '4');
|
||||
assert.equal(r.mutated, true);
|
||||
});
|
||||
|
||||
test('current_plan: preserve-when-unchanged restores curated value over a stale derived value', () => {
|
||||
const r = applyStatePreservation({
|
||||
preFm: null,
|
||||
preFmSnapshot: { current_plan: '5' },
|
||||
postFm: { current_plan: '3' },
|
||||
resync: true,
|
||||
bodyDeltas: { ...unchangedBodyDeltas },
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
assert.equal(r.postFm.current_plan, '5');
|
||||
assert.equal(r.mutated, true);
|
||||
});
|
||||
|
||||
test('milestone / milestone_name: preserve-if-placeholder restores curated name when derived is placeholder', () => {
|
||||
const r = applyStatePreservation({
|
||||
preFm: null,
|
||||
preFmSnapshot: { milestone: '0.1', milestone_name: 'Real Curated Name' },
|
||||
postFm: { milestone: '0.x', milestone_name: 'milestone' }, // placeholder derive
|
||||
resync: true,
|
||||
bodyDeltas: { ...unchangedBodyDeltas },
|
||||
...sameStatus, ...sameStoppedAt, ...samePhaseSource,
|
||||
});
|
||||
assert.equal(r.postFm.milestone_name, 'Real Curated Name',
|
||||
'placeholder-derived milestone_name must yield to the curated snapshot (#948/#2135 contract)');
|
||||
assert.equal(r.postFm.milestone, '0.1',
|
||||
'milestone version must stay consistent with the preserved name');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-21-state-md-template-frontmatter.test.cjs — consolidation epic #1969 (B8 #1977)
|
||||
|
||||
@@ -4818,6 +4818,120 @@ describe('last_activity / paused_at frontmatter not overwritten by historical pr
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3258: the FIELD_CLASSIFICATION preserve-when-unchanged rows for the Group 2
|
||||
// fields (paused_at, current_phase, current_plan) are now honored by
|
||||
// applyStatePreservation. Before the fix the only protection was the weaker
|
||||
// #905 absent-fallback in syncStateFrontmatter, which restores a field ONLY when
|
||||
// the derived value is falsy/absent — so a stale-but-present body value won
|
||||
// over a curated frontmatter value on every body-only write. These pin the
|
||||
// declared semantics end-to-end through the CLI: an unrelated `state update`
|
||||
// (which does NOT touch the Group 2 body source) must leave the curated
|
||||
// frontmatter values intact.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3258: Group 2 preserve-when-unchanged beats the weaker absent-fallback (paused_at / current_phase / current_plan)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function frontmatterBlock(stateContent) {
|
||||
const m = stateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
// STATE.md with curated frontmatter values and stale-but-present body values
|
||||
// for all three Group 2 fields. A body-only write that does not touch any of
|
||||
// them must NOT let the stale derived value win.
|
||||
function writeGroup2Fixture() {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
"gsd_state_version: '1.0'",
|
||||
"current_phase: '4'",
|
||||
"current_plan: '5'",
|
||||
"paused_at: '2026-02-02'",
|
||||
'status: executing',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 2',
|
||||
'**Current Plan:** 3',
|
||||
'**Status:** In progress',
|
||||
'',
|
||||
'## Current Position',
|
||||
'Phase: 2',
|
||||
'Plan: 3',
|
||||
'Status: In progress',
|
||||
'',
|
||||
'## Session',
|
||||
'',
|
||||
'Last Date: 2026-02-01',
|
||||
'Paused At: 2026-01-01',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
test('paused_at: curated frontmatter value survives a body-only write that leaves the body source unchanged', () => {
|
||||
writeGroup2Fixture();
|
||||
// `state update` on Status is a body-only RMW write (resync=false) that does
|
||||
// NOT touch the Paused At body source. The stale body value (2026-01-01)
|
||||
// must not overwrite the curated frontmatter value (2026-02-02).
|
||||
const result = runGsdTools('state update Status "Executing"', tmpDir);
|
||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||||
|
||||
const fm = frontmatterBlock(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'));
|
||||
assert.ok(/paused_at:[^\n]*2026-02-02/.test(fm),
|
||||
`paused_at must keep the curated value (2026-02-02); frontmatter was:\n${fm}`);
|
||||
assert.ok(!/paused_at:[^\n]*2026-01-01/.test(fm),
|
||||
`stale body-derived paused_at (2026-01-01) must not win; frontmatter was:\n${fm}`);
|
||||
});
|
||||
|
||||
test('current_plan: curated frontmatter value survives a body-only write that leaves the body source unchanged', () => {
|
||||
writeGroup2Fixture();
|
||||
runGsdTools('state update Status "Executing"', tmpDir);
|
||||
|
||||
const fm = frontmatterBlock(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'));
|
||||
assert.ok(/current_plan:[^\n]*5/.test(fm),
|
||||
`current_plan must keep the curated value (5); frontmatter was:\n${fm}`);
|
||||
assert.ok(!/current_plan:[^\n]*3\b/.test(fm),
|
||||
`stale body-derived current_plan (3) must not win; frontmatter was:\n${fm}`);
|
||||
});
|
||||
|
||||
test('current_phase: curated frontmatter value survives a body-only write that leaves the body source unchanged', () => {
|
||||
writeGroup2Fixture();
|
||||
runGsdTools('state update Status "Executing"', tmpDir);
|
||||
|
||||
const fm = frontmatterBlock(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'));
|
||||
assert.ok(/current_phase:[^\n]*4/.test(fm),
|
||||
`current_phase must keep the curated value (4); frontmatter was:\n${fm}`);
|
||||
assert.ok(!/current_phase:[^\n]*2\b/.test(fm),
|
||||
`stale body-derived current_phase (2) must not win; frontmatter was:\n${fm}`);
|
||||
});
|
||||
|
||||
test('Group 2 fields still re-derive when the body source actually changes (no over-preservation)', () => {
|
||||
// When the transform DOES change the body source, the derived value must
|
||||
// win — preserve-when-unchanged must not freeze a field that a transition
|
||||
// intentionally moved. Drive it via `state update "Current Plan"`.
|
||||
writeGroup2Fixture();
|
||||
const result = runGsdTools('state update "Current Plan" "7"', tmpDir);
|
||||
assert.ok(result.success, `state update failed: ${result.error}`);
|
||||
|
||||
const fm = frontmatterBlock(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'));
|
||||
assert.ok(/current_plan:[^\n]*7/.test(fm),
|
||||
`current_plan must take the new body value (7) when the body source changed; frontmatter was:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bug #2445: stale phase dirs from closed milestone inflate phase counts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user