fix(#3258): honor all field-classification preservation rows (#3447)

* 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:
Tom Boucher
2026-08-14 01:45:38 -04:00
committed by GitHub
parent fd4715f80f
commit 5fff839713
5 changed files with 480 additions and 12 deletions

View File

@@ -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 };
}

View File

@@ -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,