* refactor(#3471): one enforcement point for the empty case, and reports that match the disk Implements ADR-3408 section 8.5 and section 8.4's residue (folded in when Phase 3 closed as subsumed). Four items, and two findings the design did not predict. FINDING 1 — the guards could not simply be deleted, as the design instructed. state sync and REGENERATE_STATE never run applyStatePreservation at all, so those six conditions were their ONLY empty-field fallback. A baseline probe on the unedited tree confirmed unconditional deletion drops current_phase, current_phase_name, current_plan, stopped_at and paused_at from a blank-body STATE.md on state sync — breaking the byte-identical requirement section 8.3 grants those two sanctioned-permanent exceptions. They are now GATED, not deleted: on for the exceptions, off for the write seam, where an empty derived value finally reaches the executor unmolested. FINDING 2, the more serious one — there was a FOURTH encoding of this policy. The pre-existing #2202 unknown-key carry-forward loop independently restored the same six fields whenever derivedFm lacked the key, completely neutralizing the fix. It is named nowhere in the ADR, the design, or three prior phases. It was found only because a probe that should have passed did not: the first attempt reported divergedFields: [] and silently restored both fields, reproducing the exact bug this phase exists to close. That is worth stating plainly. This epic's thesis is 'policy declared in one table, enforcement hand-rolled per call site.' The final phase found one more call site than anyone had counted — which is the fourth consecutive time a copy count in this epic proved to be a lower bound. Also: divergedFields could only observe fields the executor actively RESTORED, by diffing postFm. A discard-to-empty is absent both before and after, so it was invisible. A second pass now reports it, which is what makes section 8.5's 'preservation is visible' true for the delete-the-body-line case rather than aspirational. cmdPhaseComplete now reports what it preserved — #3374 was filed against that command and its complaint was warnings: [], silence. cmdStateJson's private third copy of the guards is routed onto the executor's preserve-when-unchanged rule. A read is definitionally not a write, so the #1230 delta is 'unchanged' and curated wins over a stale annotation. shouldPreserveExistingProgress is a different rule and is untouched. Report reconciliation is ONE shared helper across seven commands, not five copies of fix(#3351)'s block. Five copies of a reconciliation is precisely the shape this epic removes, and introducing it in the final phase would have been a poor joke. Both untraced commands were traced rather than assumed: cmdStatePlannedPhase matched cmdStateBeginPhase exactly; cmdStateCompletePhase turned out to be a different legacy hand-rolled path reporting a mix of field names AND a section name, where the naive helper would have dropped 'Current Position' as a false negative every time. * test(#3471): characterization coverage for one enforcement point and reconciled reports Matrix sections A-E, asserted at the consumer's output per ADR-3180 Decision 4(b)/(c) — this phase owes Decision 5's outcome metric, the one the drift guard's zero may never be reported without. Three walls matter more than the new coverage: A2 is SIX separately named tests, one per gated guard, not one parameterised assertion over a list. A list is trivially shortened later; six named tests are not, and six guards is exactly where a field gets silently dropped. A6 pins what Phases 1-3 already fixed — non-empty stale body, delta unchanged, losing to fresher curated frontmatter, with the divergence reported. If A6 reddens, this phase broke the thing the epic was for. D1/D2 pin state sync byte-identical. The implementation had to GATE the six guards rather than delete them precisely because state sync has no executor, and a baseline probe showed unconditional deletion drops five fields. Nothing else in the suite would notice that regression. E6 covers #3345's direction — a field preservation restored that the intent never named IS reported. Nothing has ever tested that direction. Assertions were empirically verified against the compiled lib and the real CLI before being written, since the suite cannot be executed locally. That caught two type bugs in the draft: fm.current_phase after a quoted-YAML round-trip is the string '5', not the number 5. E5 is recorded as structurally unreachable rather than weakened or faked. Those four commands report body Title-Case labels, which cannot string-collide with a frontmatter snake_case key the way cmdStatePatch's arbitrary field names can — which is why fix(#3351) targeted only cmdStatePatch. Testing it directly would need reconcileReportedFields exported from private scope; the helper is exercised through E6 and all seven commands instead. * docs(#3471): amend ADR-3408 section 8.5 — a fourth enforcement point, and guards that could not be deleted Amendment 3. The contract held; two of section 8.5's own statements did not. It said the six empty-only guards are DELETED. They cannot be. writeStateMd is the sole path for both section 8.3 sanctioned-permanent exceptions and never runs applyStatePreservation, so those guards were their only empty-field fallback. A baseline probe on the unedited tree confirmed unconditional deletion drops five fields from a blank-body STATE.md on state sync, breaking the byte-identical guarantee section 8.3 grants it. They are gated instead. It also mis-located cmdStateJson's guards, describing them as living in syncStateFrontmatter. They were a separate private copy on the read path with no delta check at all, so a stale body annotation always beat fresher curated frontmatter in state.json — #3395's shape entirely outside the write seam. THE FINDING: a fourth enforcement point nobody had counted. The pre-existing #2202 unknown-key carry-forward loop independently restored the same six fields, silently neutralizing the fix. It is named nowhere in this ADR, in the phase design, or in three prior phases, and was found only because a probe that should have passed did not. Fourth consecutive time a copy count in this epic proved a lower bound: 2 write-seam bypasses became 4, three preservation encodings became four, and the estimate was wrong every time. ADR-3180's standing rule has earned itself in every phase — read the code, not the write-up. Records the Row 2 decision (a discard-to-empty wins per the delta rule and is reported, not silent — the sharpest Hyrum exposure in the epic), section 8.4's residue landing as ONE shared reconcileReportedFields across seven commands rather than five copies, and the parity assertion added because FRONTMATTER_KEY_TO_BODY_LABEL was itself a second table that failed silently — this epic's shape in miniature, in its final phase. * fix(#3471): repair four regressions the checkpoint caught Checkpoint returned 16 failures of 34389: six real regressions in pre-existing tests, plus seven of my own test bugs. My hypothesis was wrong and is recorded as such. I predicted the #2202 carry-forward skip was the cause, reasoning it had removed a load-bearing fallback the way the six guards nearly were. It was not implicated in any of the six. Three unrelated causes: #2111 — current_phase came back undefined from milestone complete, which is the epic's own defect class reintroduced by its final phase. Root cause is Row 2 working exactly as designed: milestoneCompleteCore rewrites the body Phase: line to a closure message, so current_phase's #1230 delta reads CHANGED and the new rule correctly discards the curated value. The transition never declared any intent to touch that field. Fixed by re-asserting current_phase and current_phase_name through authoritativeFm — the existing #2736 mechanism beginPhaseCore and completePhaseCore already use — rather than by weakening Row 2, which A5 pins. That interaction is worth naming: a rule that keys on 'did this write change the body source' will fire on a transition that moves the body line for an entirely unrelated reason. The design did not anticipate it. #1264 / #3242 / the state.patch progress report — reconcileReportedFields folded EVERY divergedFields entry into updated, including preserve-always progress restores no caller asked about. Now scoped to preserve-when-unchanged rows only. #1162 / case-insensitive table fields — valueOf checked frontmatter before body, so a lowercase table field name exact-matched the lowercase frontmatter key sync always derives, comparing stale pre-sync body text against a post-sync frontmatter enum. Flipped to body-first. That last one is the SAME lesson as Phase 2's patchCore, recurring in a different function two phases later: in this model the body is authoritative and frontmatter is the projection, so a name that could mean either resolves body-first. Twice now. Test bugs: a stray unused parameter shifted every argument at six call sites, so body arrived undefined; and A4 compared nested progress scalars against numbers when extractFrontmatter returns raw YAML strings. The string-vs-number YAML round-trip has now been caught three times in this phase alone. * test(#3471): one helper for the progress coercion that bit four times A2f failed on the string-vs-number YAML round-trip: extractFrontmatter returns nested progress scalars as raw YAML strings, so a comparison against numeric literals can never pass. This is the FOURTH time this exact class has been caught in this phase — twice during test authoring, once as A4 in the previous checkpoint, now as A2f. Patching it a fourth time by hand would guarantee a fifth. Added numericProgress() with a comment saying why it exists, and routed every progress-reading assertion in the #3471 block through it. Swept the block: C3 needed no change, because cmdStateJson's output already runs through normalizeProgressNumbers. Deliberately NOT shared with frontmatter.test.cjs's readPersistedProgress: that one is path-based and re-reads from disk, while these assert on an in-memory string that is never written. Sharing would have meant either a disk round-trip these tests do not do, or duplicating half the helper — so the coercion pattern is mirrored locally and the reason recorded, rather than manufacturing a dependency to satisfy the letter of consolidation. * chore(#3471): backfill pr number in changeset fragment --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/witty-lynx-greet.md
Normal file
5
.changeset/witty-lynx-greet.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Changed
|
||||||
|
pr: 3519
|
||||||
|
---
|
||||||
|
**state json and the state-mutating commands now agree with what is actually on disk** — a stale body annotation could beat a fresher curated frontmatter value in state json output, and commands reported fields as updated that the write pipeline had already discarded while staying silent about fields it restored. Preservation is now enforced in one place across every path, each command reconciles its report against the persisted file, and a value dropped because this write deliberately removed its body line is reported rather than silently lost. (#3471)
|
||||||
File diff suppressed because one or more lines are too long
@@ -214,7 +214,13 @@ Both are **permanent entries in the ratchet with `owner: sanctioned-permanent`**
|
|||||||
|
|
||||||
**Question.** The body source disagrees with frontmatter and the derived value is non-empty. Who wins?
|
**Question.** The body source disagrees with frontmatter and the derived value is non-empty. Who wins?
|
||||||
|
|
||||||
**Rule.** The declared policy decides, on the same terms as an empty derived value. `preserve-when-unchanged` restores the curated frontmatter value when that field's body source did not change in **this** write. The empty-only "#905" guards in `syncStateFrontmatter` are **deleted**, not kept in sync — one enforcement point on every path, including `writeStateMd` and `cmdStateJson`.
|
**Rule.** The declared policy decides, on the same terms as an empty derived value. `preserve-when-unchanged` restores the curated frontmatter value when that field's body source did not change in **this** write.
|
||||||
|
|
||||||
|
**One enforcement point on the write seam — with the §8.3 exceptions carved out explicitly.** The empty-only "#905" guards in `syncStateFrontmatter` are **gated, not deleted** (corrected by Amendment 3): they stay active for the two sanctioned-permanent exceptions, which never run `applyStatePreservation` and for which those guards are the *only* empty-field fallback, and are off on the write seam where the executor owns the empty case.
|
||||||
|
|
||||||
|
**Rule — a discard is as visible as a restore.** When the body source *did* change this write and the derived value is empty, the derived value wins per the delta rule and the curated value is dropped. That drop is reported through the same channel as a restore. Silence would make "preservation is visible" true only for the half of the rule that adds a value back.
|
||||||
|
|
||||||
|
**Rule — `cmdStateJson` is governed too.** It is a read path, and it carried its **own private copy** of the empty-only guards with no delta check at all. It routes through the executor's `preserve-when-unchanged` rule instead. *(This section previously described those guards as living "in `syncStateFrontmatter`". They did not — they were a separate third encoding, corrected by Amendment 3.)*
|
||||||
|
|
||||||
**Rule.** **Preservation is visible.** When policy restores a curated value over a disagreeing derived one, the command emits a divergence warning. Silence is the defect #3374 reported (`warnings: []`), not the fix.
|
**Rule.** **Preservation is visible.** When policy restores a curated value over a disagreeing derived one, the command emits a divergence warning. Silence is the defect #3374 reported (`warnings: []`), not the fix.
|
||||||
|
|
||||||
@@ -333,4 +339,24 @@ This was not a theoretical over-reach. #3469's own scope line, inherited from th
|
|||||||
|
|
||||||
**Criterion 6 context.** All five of the epic's named instances were closed by point fixes while Phase 1 was in flight, so Phase 2 and Phase 4 are driven by **characterization tests at the consumer's output** (Decision 4(b)/(c)) rather than fail-first tests. Weaker, and stated as such.
|
**Criterion 6 context.** All five of the epic's named instances were closed by point fixes while Phase 1 was in flight, so Phase 2 and Phase 4 are driven by **characterization tests at the consumer's output** (Decision 4(b)/(c)) rather than fail-first tests. Weaker, and stated as such.
|
||||||
|
|
||||||
*(Phase 3 records the §8.4 bucket decision here — though see the epic: `fix(#3351)` (#3487) appears to have subsumed most of Phase 3.)*
|
### Amendment 3 — Phase 4 (#3471): there was a FOURTH enforcement point, and the guards could not be deleted
|
||||||
|
|
||||||
|
The contract held. Two of this section's own statements did not.
|
||||||
|
|
||||||
|
**§8.5 said the guards are "deleted". They cannot be.** `writeStateMd` — the sole path for both §8.3 sanctioned-permanent exceptions — never runs `applyStatePreservation`, so those six conditions were their **only** empty-field fallback. A baseline probe on the unedited tree confirmed unconditional deletion drops `current_phase`, `current_phase_name`, `current_plan`, `stopped_at` and `paused_at` from a blank-body STATE.md on `state sync`, breaking the byte-identical guarantee §8.3 grants it. They are **gated**: on for the exceptions, off for the write seam. §8.5 is corrected above.
|
||||||
|
|
||||||
|
**§8.5 mis-located `cmdStateJson`'s guards.** It described them as living "in `syncStateFrontmatter`". They were a **separate private copy** on the read path, with no delta check at all — so a stale body annotation always beat fresher curated frontmatter in `state.json`, reproducing #3395's shape entirely outside the write seam. Now routed through `applyPreserveWhenUnchanged`. `shouldPreserveExistingProgress`'s cross-milestone logic is a different rule and is untouched.
|
||||||
|
|
||||||
|
**THE FINDING: a fourth enforcement point nobody had counted.** The pre-existing #2202 unknown-key carry-forward loop independently restored the same six fields whenever `derivedFm` lacked the key — silently neutralizing the gating fix. It is named nowhere in this ADR, in Phase 4's design, or in three prior phases. It was found only because a probe that should have passed did not: the first attempt reported `divergedFields: []` and restored the stale values, reproducing the exact bug the phase existed to close.
|
||||||
|
|
||||||
|
That is the **fourth consecutive time** a copy count in this epic proved a lower bound — 2 write-seam bypasses became 4, three preservation encodings became four, and the estimate was wrong every single time. ADR-3180 Amendment 3's standing rule has now earned itself in every phase of this epic: **read the code, not the write-up.**
|
||||||
|
|
||||||
|
**`divergedFields` could not see a discard.** It diffed `postFm` before and after preservation, so it could only observe fields actively *restored*. A discard-to-empty is absent on both sides and was therefore invisible. A second pass reports it, which is what makes the new "a discard is as visible as a restore" rule real rather than aspirational.
|
||||||
|
|
||||||
|
**Decided — Row 2, the delete-the-body-line case.** When a transform deliberately removes a body line, the derived (empty) value wins per the delta rule and the curated value is dropped. Consistent with "same terms as empty", and it discards a value that previously survived on that path — so it is reported rather than silent. This is the sharpest Hyrum exposure in the epic and ships with its own call-out.
|
||||||
|
|
||||||
|
**§8.4's residue, folded in when Phase 3 (#3470) closed as subsumed.** `fix(#3351)` reconciled only `cmdStatePatch`. Seven commands now share **one** `reconcileReportedFields` helper — not five copies of that block, which would have been this epic's own defect class introduced in its final phase. Both previously-untraced commands were traced rather than assumed: `cmdStatePlannedPhase` matched `cmdStateBeginPhase` exactly; `cmdStateCompletePhase` turned out to be a different legacy path reporting field names **and** a section name, where the naive helper would have dropped `Current Position` as a false negative every time.
|
||||||
|
|
||||||
|
**A parity assertion, because the fix needed one.** `FRONTMATTER_KEY_TO_BODY_LABEL` is a second table beside `FIELD_CLASSIFICATION`, and a missing entry originally fell back silently to the raw key. That is this epic's shape in miniature. A missing `preserve-when-unchanged` label now throws with a structured error mirroring §8.2's `throwUnwiredRow`, and a test asserts every such row has an entry — the parity assertion `CLAUDE.md`'s *Generative Fix Divergence* entry requires whenever two surfaces share a constant. The throw is deliberately scoped to that policy: `divergedFields` legitimately carries `progress`, `milestone` and `milestone_name`, which have no body-line label, and an unconditional throw would have broken a live case.
|
||||||
|
|
||||||
|
*(Phase 3's §8.4 bucket decision was folded here; `fix(#3351)` (#3487) subsumed most of Phase 3, which closed as subsumed.)*
|
||||||
|
|||||||
@@ -902,13 +902,41 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
|||||||
{ clock: realClock, sourcePath: statePath },
|
{ clock: realClock, sourcePath: statePath },
|
||||||
);
|
);
|
||||||
const divergedFields: string[] = [];
|
const divergedFields: string[] = [];
|
||||||
|
// #2111 (found by #3471 review): `milestoneCompleteCore` never declares
|
||||||
|
// `current_phase`/`current_phase_name` among the fields it touches — but
|
||||||
|
// its ## Current Position reset REWRITES the `Phase:` prose line to a
|
||||||
|
// closure message ("Milestone vX.Y complete"), which is not a number.
|
||||||
|
// That is an unavoidable side effect of the wholesale section reset
|
||||||
|
// `resetSectionVerbatim` performs, not an intent to change the phase.
|
||||||
|
// Downstream, `current_phase`/`current_phase_name` are
|
||||||
|
// `preserve-when-unchanged` rows: the #1230 delta heuristic sees the
|
||||||
|
// body source go from a real value to unparseable and — correctly, per
|
||||||
|
// ADR-3408 §8.5 Row 2 — lets the derived (empty) value win, discarding
|
||||||
|
// the curated phase entirely. §8.5 Row 2 governs a genuine mid-write
|
||||||
|
// body edit (e.g. `state.patch` deleting the Phase line); milestone
|
||||||
|
// closure is a different shape — the transition never intended to
|
||||||
|
// touch these fields at all. Re-assert them via `authoritativeFm` (the
|
||||||
|
// same #2736 intent-first mechanism `beginPhaseCore`/`completePhaseCore`
|
||||||
|
// already use to freeze a field the transition resolved out-of-band),
|
||||||
|
// so the closure-message side effect cannot clobber the last real
|
||||||
|
// phase. Scoped to non-empty strings only, mirroring #2736's own guard.
|
||||||
|
const authoritativeFm: Record<string, unknown> = {};
|
||||||
|
const preFm = extractFrontmatter(originalStateContent, statePath) as Record<string, unknown>;
|
||||||
|
const preCurrentPhase = preFm['current_phase'];
|
||||||
|
const preCurrentPhaseName = preFm['current_phase_name'];
|
||||||
|
if (typeof preCurrentPhase === 'string' && preCurrentPhase.trim().length > 0) {
|
||||||
|
authoritativeFm['current_phase'] = preCurrentPhase;
|
||||||
|
}
|
||||||
|
if (typeof preCurrentPhaseName === 'string' && preCurrentPhaseName.trim().length > 0) {
|
||||||
|
authoritativeFm['current_phase_name'] = preCurrentPhaseName;
|
||||||
|
}
|
||||||
const finalContent = syncAndPreserveStateMd(
|
const finalContent = syncAndPreserveStateMd(
|
||||||
originalStateContent,
|
originalStateContent,
|
||||||
result.content,
|
result.content,
|
||||||
statePath,
|
statePath,
|
||||||
cwd,
|
cwd,
|
||||||
true,
|
true,
|
||||||
undefined,
|
Object.keys(authoritativeFm).length > 0 ? authoritativeFm : undefined,
|
||||||
undefined,
|
undefined,
|
||||||
divergedFields,
|
divergedFields,
|
||||||
);
|
);
|
||||||
@@ -916,6 +944,14 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
|||||||
for (const field of divergedFields) {
|
for (const field of divergedFields) {
|
||||||
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
||||||
}
|
}
|
||||||
|
// The authoritativeFm re-assert above (unlike a delta-based restore) is
|
||||||
|
// invisible to `divergedFields` — #2736's re-assert runs after that
|
||||||
|
// diff — so surface it explicitly here for "liberal but visible".
|
||||||
|
for (const field of Object.keys(authoritativeFm)) {
|
||||||
|
if (!divergedFields.includes(field)) {
|
||||||
|
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2097,6 +2097,18 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
|||||||
let requirementsUpdated = false;
|
let requirementsUpdated = false;
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
// ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
|
||||||
|
// composition's preservation stage restores a curated frontmatter value
|
||||||
|
// over a disagreeing derived one, that divergence is surfaced here rather
|
||||||
|
// than silently absorbed. Structured (field + reason), not prose, so a
|
||||||
|
// caller can assert on the value rather than regex a rendered message.
|
||||||
|
// Named `preservation_warnings`, NOT `warnings`: `warnings` above is
|
||||||
|
// already a prose `string[]` on this exact command — reusing it for a
|
||||||
|
// structured `{field, reason}[]` shape would be the "Generative Fix
|
||||||
|
// Divergence" anti-pattern (two sibling fields, same name, different
|
||||||
|
// element types). Mirrors `cmdMilestoneComplete`'s identical field
|
||||||
|
// (milestone.cts).
|
||||||
|
const preservationWarnings: Array<{ field: string; reason: string }> = [];
|
||||||
// #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
|
// #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
|
||||||
// roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
|
// roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
|
||||||
// below (inside withPlanningLock) alongside the warnings[] entry, so a
|
// below (inside withPlanningLock) alongside the warnings[] entry, so a
|
||||||
@@ -3037,6 +3049,11 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
|||||||
// apply). Fields the transition legitimately rewrote (Status, Phase,
|
// apply). Fields the transition legitimately rewrote (Status, Phase,
|
||||||
// Stopped At via completePhaseCore's #3374 continuity line) have
|
// Stopped At via completePhaseCore's #3374 continuity line) have
|
||||||
// changed body sources, so their deltas do not fire.
|
// changed body sources, so their deltas do not fire.
|
||||||
|
// ADR-3408 §8.5 / D2 (#3374): thread `divergedFields` through so this
|
||||||
|
// command reports what it preserved, following `cmdMilestoneComplete`'s
|
||||||
|
// shape (milestone.cts) — the same composition, the same out-param,
|
||||||
|
// the same visibility contract.
|
||||||
|
const divergedFields: string[] = [];
|
||||||
stateContent = syncAndPreserveStateMd(
|
stateContent = syncAndPreserveStateMd(
|
||||||
originalStateContent,
|
originalStateContent,
|
||||||
stateContent,
|
stateContent,
|
||||||
@@ -3044,7 +3061,12 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
|||||||
cwd,
|
cwd,
|
||||||
true,
|
true,
|
||||||
authoritativeFm,
|
authoritativeFm,
|
||||||
|
undefined,
|
||||||
|
divergedFields,
|
||||||
);
|
);
|
||||||
|
for (const field of divergedFields) {
|
||||||
|
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
||||||
|
}
|
||||||
|
|
||||||
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
|
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
|
||||||
}
|
}
|
||||||
@@ -3121,6 +3143,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
|||||||
has_warnings: warnings.length > 0,
|
has_warnings: warnings.length > 0,
|
||||||
verification_stale_check_indeterminate: staleCheckIndeterminate,
|
verification_stale_check_indeterminate: staleCheckIndeterminate,
|
||||||
milestone_conflict: milestoneConflict,
|
milestone_conflict: milestoneConflict,
|
||||||
|
preservation_warnings: preservationWarnings,
|
||||||
};
|
};
|
||||||
|
|
||||||
output(result, raw);
|
output(result, raw);
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export type StatePreservationResult = {
|
|||||||
* executor below. `mutated` accumulates across the whole field loop (ADR-3408
|
* executor below. `mutated` accumulates across the whole field loop (ADR-3408
|
||||||
* §8.1 — one executor per policy, sharing one result).
|
* §8.1 — one executor per policy, sharing one result).
|
||||||
*/
|
*/
|
||||||
type PreservationCtx = {
|
export type PreservationCtx = {
|
||||||
preFm: Record<string, unknown> | null;
|
preFm: Record<string, unknown> | null;
|
||||||
postFm: Record<string, unknown>;
|
postFm: Record<string, unknown>;
|
||||||
preFmSnapshot: Record<string, unknown>;
|
preFmSnapshot: Record<string, unknown>;
|
||||||
@@ -254,8 +254,17 @@ function throwUnwiredRow(field: string): never {
|
|||||||
* current_phase, current_plan, paused_at, last_activity_desc — is honored by
|
* current_phase, current_plan, paused_at, last_activity_desc — is honored by
|
||||||
* this ONE executor; `cls.guard` is the only field-specific variation (the
|
* this ONE executor; `cls.guard` is the only field-specific variation (the
|
||||||
* closed vocabulary of ADR-3408 Decision 1).
|
* closed vocabulary of ADR-3408 Decision 1).
|
||||||
|
*
|
||||||
|
* Exported (ADR-3408 §8.5 / D3) so `cmdStateJson` (state.cts) — a read-only
|
||||||
|
* path with no transform of its own — can route its stale-vs-fresh decision
|
||||||
|
* through the SAME executor the write path uses, rather than maintaining a
|
||||||
|
* third private copy of this policy. `cmdStateJson` calls this directly
|
||||||
|
* (not the full `applyStatePreservation` dispatch loop) so its read stays
|
||||||
|
* scoped to exactly the fields it has always governed and never touches
|
||||||
|
* `progress` or `milestone*`, which are different policies with their own
|
||||||
|
* read-path rules (`shouldPreserveExistingProgress`, `preserve-if-placeholder`).
|
||||||
*/
|
*/
|
||||||
function applyPreserveWhenUnchanged(field: string, cls: FieldClassification, ctx: PreservationCtx): void {
|
export function applyPreserveWhenUnchanged(field: string, cls: FieldClassification, ctx: PreservationCtx): void {
|
||||||
// 1. A declared row with no wired delta is an internal invariant violation
|
// 1. A declared row with no wired delta is an internal invariant violation
|
||||||
// — throw (ADR-3408 §8.2). Never reached for a user-document defect: the
|
// — throw (ADR-3408 §8.2). Never reached for a user-document defect: the
|
||||||
// production caller (readModifyWriteStateMd) wires every preserve-when-
|
// production caller (readModifyWriteStateMd) wires every preserve-when-
|
||||||
|
|||||||
584
src/state.cts
584
src/state.cts
@@ -102,6 +102,16 @@ interface ReadModifyWriteOptions {
|
|||||||
* destroy information the transition just resolved.
|
* destroy information the transition just resolved.
|
||||||
*/
|
*/
|
||||||
authoritativeFm?: Record<string, unknown>;
|
authoritativeFm?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* ADR-3408 §8.5 (D4): out-param, forwarded straight through to
|
||||||
|
* `syncAndPreserveStateMd` — every frontmatter field name whose value
|
||||||
|
* preservation restored over a disagreeing freshly-derived one during THIS
|
||||||
|
* write. Callers that supply an array here can fold it into their own
|
||||||
|
* report (see `reconcileReportedFields`) so a preserved field the caller's
|
||||||
|
* transform never named is still visible (#3345's direction). Omit for
|
||||||
|
* callers that do not report per-field arrays; costs nothing extra.
|
||||||
|
*/
|
||||||
|
divergedFields?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateRecordMetricOptions {
|
interface StateRecordMetricOptions {
|
||||||
@@ -506,40 +516,34 @@ function cmdStatePatch(cwd: string, patches: Record<string, string>, raw: boolea
|
|||||||
// #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
|
// #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
|
||||||
// delta (table-driven) that this phase adds. Field-name validation (security)
|
// delta (table-driven) that this phase adds. Field-name validation (security)
|
||||||
// and the resync-progress decision stay in this adapter.
|
// and the resync-progress decision stay in this adapter.
|
||||||
let results: { updated: string[]; failed: string[] } = { updated: [], failed: [] };
|
let precomputed: { updated: string[]; failed: string[] } = { updated: [], failed: [] };
|
||||||
|
let preSyncContent = '';
|
||||||
|
const divergedFields: string[] = [];
|
||||||
readModifyWriteStateMd(statePath, (content) => {
|
readModifyWriteStateMd(statePath, (content) => {
|
||||||
const result = transitionCore(content, { kind: 'patch', patches }, { clock: realClock });
|
const result = transitionCore(content, { kind: 'patch', patches }, { clock: realClock });
|
||||||
results = (result.data as { updated: string[]; failed: string[] }) ?? results;
|
precomputed = (result.data as { updated: string[]; failed: string[] }) ?? precomputed;
|
||||||
|
preSyncContent = result.content;
|
||||||
return result.content;
|
return result.content;
|
||||||
}, cwd, { resync: shouldResync });
|
}, cwd, { resync: shouldResync, divergedFields });
|
||||||
|
|
||||||
// #3351: reconcile the report against the bytes actually persisted.
|
// ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
|
||||||
// patchCore's bookkeeping says whether the stateReplaceField text-replace
|
// patchCore's bookkeeping says whether the stateReplaceField text-replace
|
||||||
// MATCHED — but its plain-line pattern (`m` flag over the full document)
|
// MATCHED — but its plain-line pattern (`m` flag over the full document)
|
||||||
// can match the YAML frontmatter line for a lower-cased key, and the write
|
// can match the YAML frontmatter line for a lower-cased key, and the write
|
||||||
// pipeline (syncStateFrontmatter re-derivation + the FIELD_CLASSIFICATION
|
// pipeline (syncStateFrontmatter re-derivation + the FIELD_CLASSIFICATION
|
||||||
// preservation rows) then discards or restores that text before the file is
|
// preservation rows) then discards or restores that text before the file is
|
||||||
// saved. A field is only reported `updated` when its post-write on-disk
|
// saved. A field is only reported `updated` when its post-write on-disk
|
||||||
// value equals the requested value: the frontmatter key when present,
|
// value equals what THIS transform actually wrote (the frontmatter key
|
||||||
// else the body field (the legitimate working case for state.patch is
|
// when present, else the body field — the legitimate working case for
|
||||||
// display-cased BODY fields — Status, Current Plan, Phase — which are
|
// state.patch is display-cased BODY fields — Status, Current Plan, Phase —
|
||||||
// never frontmatter keys).
|
// which are never frontmatter keys). Also folds in any field
|
||||||
const persisted = platformReadSync(statePath) || '';
|
// `applyStatePreservation` restored that this patch never named at all
|
||||||
const postFm = extractFrontmatter(persisted, statePath) as Record<string, unknown>;
|
// (#3345's direction), a case the pre-#3471 version of this command never
|
||||||
const postBody = stripFrontmatter(persisted);
|
// covered.
|
||||||
const updated: string[] = [];
|
const updated = reconcileReportedFields(statePath, preSyncContent, precomputed.updated, divergedFields);
|
||||||
const failed: string[] = [];
|
const updatedSet = new Set(updated);
|
||||||
for (const [field, value] of Object.entries(patches)) {
|
const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
|
||||||
const persistedValue = Object.prototype.hasOwnProperty.call(postFm, field)
|
const results = { updated, failed };
|
||||||
? String(postFm[field])
|
|
||||||
: stateExtractField(postBody, field);
|
|
||||||
if (persistedValue !== null && persistedValue.trim() === String(value).trim()) {
|
|
||||||
updated.push(field);
|
|
||||||
} else {
|
|
||||||
failed.push(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results = { updated, failed };
|
|
||||||
|
|
||||||
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -563,6 +567,8 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
|
|||||||
const statePath = planningPaths(cwd).state;
|
const statePath = planningPaths(cwd).state;
|
||||||
try {
|
try {
|
||||||
let updated = false;
|
let updated = false;
|
||||||
|
let preSyncContent = '';
|
||||||
|
const divergedFields: string[] = [];
|
||||||
const shouldResync = shouldResyncStateProgress([field as string]);
|
const shouldResync = shouldResyncStateProgress([field as string]);
|
||||||
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
||||||
// body-strip/reassemble single-field update is the pure `updateCore` in
|
// body-strip/reassemble single-field update is the pure `updateCore` in
|
||||||
@@ -577,12 +583,25 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
|
|||||||
{ clock: realClock },
|
{ clock: realClock },
|
||||||
);
|
);
|
||||||
updated = (result.data as { updated: boolean } | undefined)?.updated === true;
|
updated = (result.data as { updated: boolean } | undefined)?.updated === true;
|
||||||
|
preSyncContent = result.content;
|
||||||
return result.content;
|
return result.content;
|
||||||
}, cwd, { resync: shouldResync });
|
}, cwd, { resync: shouldResync, divergedFields });
|
||||||
|
|
||||||
|
// ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
|
||||||
|
// `updateCore`'s own match does not know whether sync/preservation later
|
||||||
|
// discarded the value it wrote (#3351's direction, generalized from
|
||||||
|
// `cmdStatePatch`). `preserved` folds in any OTHER field preservation
|
||||||
|
// restored during this write that this command never touched at all
|
||||||
|
// (#3345's direction) — reported separately from `updated` because this
|
||||||
|
// command's contract is a single-field boolean, not a per-field array.
|
||||||
|
const reconciled = reconcileReportedFields(statePath, preSyncContent, updated ? [field as string] : [], divergedFields);
|
||||||
|
updated = reconciled.includes(field as string);
|
||||||
|
const preserved = reconciled.filter((f) => f !== field);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
output({ updated: true }, false, undefined);
|
output({ updated: true, preserved }, false, undefined);
|
||||||
} else {
|
} else {
|
||||||
output({ updated: false, reason: `Field "${field as string}" not found in STATE.md` }, false, undefined);
|
output({ updated: false, reason: `Field "${field as string}" not found in STATE.md`, preserved }, false, undefined);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
output({ updated: false, reason: 'STATE.md not found' }, false, undefined);
|
output({ updated: false, reason: 'STATE.md not found' }, false, undefined);
|
||||||
@@ -629,6 +648,9 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let resultData: Record<string, unknown> | undefined;
|
let resultData: Record<string, unknown> | undefined;
|
||||||
|
let precomputedUpdated: string[] = [];
|
||||||
|
let preSyncContent = '';
|
||||||
|
const divergedFields: string[] = [];
|
||||||
// #3311: the milestone (phase + session) claim is consulted INSIDE the
|
// #3311: the milestone (phase + session) claim is consulted INSIDE the
|
||||||
// STATE.md lock, so the position read and the claim read cannot interleave
|
// STATE.md lock, so the position read and the claim read cannot interleave
|
||||||
// with another session's Current Position write.
|
// with another session's Current Position write.
|
||||||
@@ -650,18 +672,28 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
|
|||||||
}
|
}
|
||||||
const result = transitionCore(content, intent, deps);
|
const result = transitionCore(content, intent, deps);
|
||||||
resultData = result.data;
|
resultData = result.data;
|
||||||
|
precomputedUpdated = result.updated;
|
||||||
|
preSyncContent = result.content;
|
||||||
return result.content;
|
return result.content;
|
||||||
}, cwd);
|
}, cwd, { divergedFields });
|
||||||
|
|
||||||
if (!resultData || resultData['error']) {
|
if (!resultData || resultData['error']) {
|
||||||
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
|
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
|
||||||
|
// the bytes actually persisted — this command previously reported none of
|
||||||
|
// its per-field writes at all (`updated` never left `advancePlanCore`).
|
||||||
|
// Generalizes fix(#3351) (closes #3351's direction) and folds in any field
|
||||||
|
// preservation restored that this transform never touched (#3345's
|
||||||
|
// direction).
|
||||||
|
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
|
||||||
|
|
||||||
if (resultData['advanced'] === false) {
|
if (resultData['advanced'] === false) {
|
||||||
output({ ...resultData, milestone_conflict: milestoneConflict }, raw, 'false');
|
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
|
||||||
} else {
|
} else {
|
||||||
output({ ...resultData, milestone_conflict: milestoneConflict }, raw, 'true');
|
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,6 +1277,8 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
|||||||
const now = realClock.nowIso();
|
const now = realClock.nowIso();
|
||||||
const updated: string[] = [];
|
const updated: string[] = [];
|
||||||
let sessionCreated = false;
|
let sessionCreated = false;
|
||||||
|
let preSyncContent = '';
|
||||||
|
const divergedFields: string[] = [];
|
||||||
|
|
||||||
readModifyWriteStateMd(statePath, (content) => {
|
readModifyWriteStateMd(statePath, (content) => {
|
||||||
// Update Last session / Last Date
|
// Update Last session / Last Date
|
||||||
@@ -1450,11 +1484,18 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preSyncContent = content;
|
||||||
return content;
|
return content;
|
||||||
}, cwd);
|
}, cwd, { divergedFields });
|
||||||
|
|
||||||
if (updated.length > 0) {
|
// ADR-3408 §8.4 (D4): reconcile this command's own success list against the
|
||||||
const result: Record<string, unknown> = { recorded: true, updated };
|
// bytes actually persisted (fix(#3351) generalized) and fold in any field
|
||||||
|
// preservation restored that this transform never touched (#3345's
|
||||||
|
// direction).
|
||||||
|
const reconciledUpdated = reconcileReportedFields(statePath, preSyncContent, updated, divergedFields);
|
||||||
|
|
||||||
|
if (reconciledUpdated.length > 0) {
|
||||||
|
const result: Record<string, unknown> = { recorded: true, updated: reconciledUpdated };
|
||||||
if (sessionCreated) result['created'] = true;
|
if (sessionCreated) result['created'] = true;
|
||||||
output(result, raw, 'true');
|
output(result, raw, 'true');
|
||||||
} else {
|
} else {
|
||||||
@@ -2357,7 +2398,12 @@ function readStoredTotalPhases(existingFm: Record<string, unknown> | null | unde
|
|||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncStateFrontmatter(content: string, cwd: string | undefined, authoritativeFm?: Record<string, unknown>): string {
|
function syncStateFrontmatter(
|
||||||
|
content: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
authoritativeFm?: Record<string, unknown>,
|
||||||
|
sanctionedPermanentEmptyFallback?: boolean,
|
||||||
|
): string {
|
||||||
// Read existing frontmatter BEFORE stripping — it may contain values
|
// Read existing frontmatter BEFORE stripping — it may contain values
|
||||||
// that the body no longer has (e.g., Status field removed by an agent).
|
// that the body no longer has (e.g., Status field removed by an agent).
|
||||||
// `cwd` already identifies the workspace this content came from, so the STATE.md path is
|
// `cwd` already identifies the workspace this content came from, so the STATE.md path is
|
||||||
@@ -2415,56 +2461,75 @@ function syncStateFrontmatter(content: string, cwd: string | undefined, authorit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
|
// ADR-3408 §8.5 (D1): the six empty-only "#905" guards that used to live
|
||||||
// from body annotations (Current Phase:, Current Plan:, etc.). When those
|
// here UNCONDITIONALLY are deleted for the write-seam pipeline
|
||||||
// annotations are absent — e.g. after an agent or tool rewrites the body —
|
// (`syncAndPreserveStateMd`, consumed by `readModifyWriteStateMd` and by
|
||||||
// buildStateFrontmatter returns no value for those keys. Mirror the same
|
// `cmdPhaseComplete`'s atomic-commit adapter). An empty derived value now
|
||||||
// fallback pattern used in cmdStateJson so the existing frontmatter values
|
// reaches `applyStatePreservation` unmolested, so the table-driven executor
|
||||||
// survive every writeStateMd call.
|
// — not a private copy inside this function — decides whether a curated
|
||||||
|
// frontmatter value survives, and reports the decision via
|
||||||
|
// `divergedFields` when it does. That was the actual D1 bug: these guards
|
||||||
|
// ran BEFORE the executor ever saw the value, so a transform that
|
||||||
|
// deliberately emptied a body line (delta CHANGED) lost silently — the
|
||||||
|
// guard restored the stale frontmatter, the executor's own #1230 delta
|
||||||
|
// check then found "already restored, nothing to do", and
|
||||||
|
// `divergedFields` stayed empty even though a curated value had just won
|
||||||
|
// over a genuine derived-empty.
|
||||||
|
//
|
||||||
|
// `writeStateMd`'s two callers — `cmdStateSync` and `/gsd-health --repair`'s
|
||||||
|
// `REGENERATE_STATE` — are §8.3's closed, sanctioned-permanent exception
|
||||||
|
// list: NEITHER ever runs `applyStatePreservation` afterward, because their
|
||||||
|
// whole contract is "re-derive frontmatter FROM the body, body wins" (the
|
||||||
|
// opposite of preservation). For them, these six conditions are the ONLY
|
||||||
|
// mechanism that has ever kept a curated frontmatter value alive when the
|
||||||
|
// body simply carries no annotation for a field at all (most STATE.md
|
||||||
|
// files do not restate every field in body prose on every write) — losing
|
||||||
|
// that would blank `current_phase_name` / `stopped_at` / etc. on every
|
||||||
|
// `state sync`, which is a regression, not this phase's fix: `state sync`'s
|
||||||
|
// output must stay byte-identical (ADR-3408 §8.3 Amendment 2). So the same
|
||||||
|
// six conditions are kept, verbatim, but now gated behind the explicit
|
||||||
|
// `sanctionedPermanentEmptyFallback` parameter — threaded ONLY from
|
||||||
|
// `writeStateMd` — instead of running unconditionally or being duplicated
|
||||||
|
// as a second private copy. This is still ONE enforcement point: the six
|
||||||
|
// conditions exist in exactly one place in the source, selected by caller
|
||||||
|
// identity per the closed §8.3 exception list, never re-derived elsewhere.
|
||||||
//
|
//
|
||||||
// For stopped_at / paused_at: the original #905 "fall back when derived is
|
|
||||||
// absent" rule is preserved here — this block handles the EMPTY case only.
|
|
||||||
// The disagreeing case (a present-but-stale body value vs a fresher
|
// The disagreeing case (a present-but-stale body value vs a fresher
|
||||||
// frontmatter value, #948/#3374) is NOT handled here: it is governed by
|
// frontmatter value, #948/#3374/§8.5) was never handled here even before
|
||||||
// applyStatePreservation's preserve-when-unchanged delta, applied post-sync
|
// this change: it is governed by applyStatePreservation's
|
||||||
// by the shared applyPostSyncPreservation pass — run by
|
// preserve-when-unchanged delta, applied post-sync by the shared
|
||||||
// readModifyWriteStateMd and by cmdPhaseComplete's adapter (the one caller
|
// applyPostSyncPreservation pass.
|
||||||
// that deliberately bypasses the RMW wrapper for the atomic
|
if (sanctionedPermanentEmptyFallback) {
|
||||||
// ROADMAP/REQUIREMENTS/STATE commit; #3374). The writeStateMd path
|
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
|
||||||
// (state sync) intentionally derives from the body instead — its #905
|
derivedFm['stopped_at'] = existingFm['stopped_at'];
|
||||||
// contract is body-beats-frontmatter. "Always prefer frontmatter" here
|
}
|
||||||
// would still be wrong: it would break transforms that legitimately write a
|
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
|
||||||
// new body value and expect this sync to project it — the #1230 delta
|
derivedFm['paused_at'] = existingFm['paused_at'];
|
||||||
// ("did THIS write change the body source?") is what distinguishes those
|
}
|
||||||
// from a stale harvest.
|
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
|
||||||
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
|
derivedFm['current_phase'] = existingFm['current_phase'];
|
||||||
derivedFm['stopped_at'] = existingFm['stopped_at'];
|
}
|
||||||
}
|
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
|
||||||
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
|
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
|
||||||
derivedFm['paused_at'] = existingFm['paused_at'];
|
}
|
||||||
}
|
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
|
||||||
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
|
derivedFm['current_plan'] = existingFm['current_plan'];
|
||||||
derivedFm['current_phase'] = existingFm['current_phase'];
|
}
|
||||||
}
|
// progress is a sub-object: fall back to existing only when the
|
||||||
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
|
// body+disk scan produced NO progress block at all. When
|
||||||
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
|
// buildStateFrontmatter did derive a progress block (even a lower one),
|
||||||
}
|
// that derived value wins — the shouldPreserveExistingProgress
|
||||||
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
|
// cross-milestone logic is applied later in cmdStateJson on the read
|
||||||
derivedFm['current_plan'] = existingFm['current_plan'];
|
// path where it is appropriate.
|
||||||
}
|
if (!derivedFm['progress'] && existingFm['progress']) {
|
||||||
// progress is a sub-object: fall back to existing only when the body+disk
|
derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
|
||||||
// scan produced NO progress block at all. When buildStateFrontmatter did
|
}
|
||||||
// derive a progress block (even a lower one), that derived value wins — the
|
|
||||||
// shouldPreserveExistingProgress cross-milestone logic is applied later in
|
|
||||||
// cmdStateJson on the read path where it is appropriate.
|
|
||||||
if (!derivedFm['progress'] && existingFm['progress']) {
|
|
||||||
derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// #2202: carry forward any existing frontmatter key that the schema does not
|
// #2202: carry forward any existing frontmatter key that the schema does not
|
||||||
// own, so custom/unknown keys are not silently dropped on every mutating verb.
|
// own, so custom/unknown keys are not silently dropped on every mutating verb.
|
||||||
// Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
|
// Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
|
||||||
// preserve guards above) still win.
|
// sanctioned-permanent guards above, when they ran) still win.
|
||||||
for (const key of Object.keys(existingFm)) {
|
for (const key of Object.keys(existingFm)) {
|
||||||
if (key in derivedFm || existingFm[key] === undefined) continue;
|
if (key in derivedFm || existingFm[key] === undefined) continue;
|
||||||
|
|
||||||
@@ -2487,6 +2552,39 @@ function syncStateFrontmatter(content: string, cwd: string | undefined, authorit
|
|||||||
const classification = stateTransitionMod.getFieldClassification(key);
|
const classification = stateTransitionMod.getFieldClassification(key);
|
||||||
if (classification && classification.source === 'free') continue;
|
if (classification && classification.source === 'free') continue;
|
||||||
|
|
||||||
|
// ADR-3408 §8.1/§8.5 (D1 follow-on — found by probe, not predicted by the
|
||||||
|
// design): a `preserve-when-unchanged` / `preserve-always` field must be
|
||||||
|
// decided ONLY by `applyStatePreservation` — the single enforcement point
|
||||||
|
// — never by this generic carry-forward, on the write-seam path. Before
|
||||||
|
// the six sanctioned-permanent guards above were gated behind
|
||||||
|
// `sanctionedPermanentEmptyFallback` (D1), this loop's `key in derivedFm`
|
||||||
|
// check was effectively always true for a field the guards had already
|
||||||
|
// restored, so this branch was unreachable for it and the distinction
|
||||||
|
// never mattered. With the guards now OFF on the write-seam path,
|
||||||
|
// `derivedFm` genuinely lacks the key when the body carries no
|
||||||
|
// annotation — and without this skip, this loop silently resurrects the
|
||||||
|
// exact stale value the executor's delta rule (§8.5 Row 2) just decided
|
||||||
|
// to discard, re-introducing the D1 bug through a second, unrelated code
|
||||||
|
// path (confirmed live: an A5-shaped probe restored `current_phase_name`
|
||||||
|
// via THIS loop even with the six guards deleted).
|
||||||
|
//
|
||||||
|
// Gated to the write-seam path ONLY (`!sanctionedPermanentEmptyFallback`)
|
||||||
|
// — `writeStateMd`'s two sanctioned-permanent callers never run
|
||||||
|
// `applyStatePreservation` at all, so unconditionally skipping here would
|
||||||
|
// blank fields this loop has always carried forward for them (e.g.
|
||||||
|
// `last_activity_desc`, which was never one of the six explicit guards
|
||||||
|
// above but relied on THIS loop for its empty-case fallback), breaking
|
||||||
|
// `state sync`'s required byte-identical output for a field D1 never
|
||||||
|
// named. On the write-seam path this executor-only rule genuinely widens
|
||||||
|
// beyond the original six fields (e.g. also covers `last_activity_desc`)
|
||||||
|
// — a deliberate, in-scope consequence of "one enforcement point", not a
|
||||||
|
// separate defect.
|
||||||
|
if (
|
||||||
|
!sanctionedPermanentEmptyFallback &&
|
||||||
|
classification &&
|
||||||
|
(classification.preservation === 'preserve-when-unchanged' || classification.preservation === 'preserve-always')
|
||||||
|
) continue;
|
||||||
|
|
||||||
derivedFm[key] = existingFm[key];
|
derivedFm[key] = existingFm[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2763,7 +2861,12 @@ function writeStateMd(statePath: string, content: string, cwd?: string, clock?:
|
|||||||
// Invalidate the disk scan cache first — the write may create new PLAN/SUMMARY
|
// Invalidate the disk scan cache first — the write may create new PLAN/SUMMARY
|
||||||
// files that buildStateFrontmatter must see (#1967).
|
// files that buildStateFrontmatter must see (#1967).
|
||||||
if (cwd) _diskScanCache.delete(cwd);
|
if (cwd) _diskScanCache.delete(cwd);
|
||||||
const synced = syncStateFrontmatter(content, cwd);
|
// ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
|
||||||
|
// sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
|
||||||
|
// pass `sanctionedPermanentEmptyFallback: true` so their long-standing
|
||||||
|
// empty-field fallback behavior stays byte-identical (see
|
||||||
|
// `syncStateFrontmatter`'s docstring above the guard block).
|
||||||
|
const synced = syncStateFrontmatter(content, cwd, undefined, true);
|
||||||
platformWriteSync(statePath, synced);
|
platformWriteSync(statePath, synced);
|
||||||
} finally {
|
} finally {
|
||||||
releaseStateLock(lockPath);
|
releaseStateLock(lockPath);
|
||||||
@@ -2943,6 +3046,30 @@ function applyPostSyncPreservation(
|
|||||||
: before !== after;
|
: before !== after;
|
||||||
if (changed) divergedFields.push(key);
|
if (changed) divergedFields.push(key);
|
||||||
}
|
}
|
||||||
|
// ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
|
||||||
|
// deleted rather than merely relocated): the loop above can only see a
|
||||||
|
// field that `applyStatePreservation` itself RESTORED — it diffs
|
||||||
|
// `postFm` before vs after the executor ran, and `preserve-when-unchanged`
|
||||||
|
// never adds an absent key back when the body source changed this write
|
||||||
|
// (the delta rule correctly lets the empty derived value win, so `postFm`
|
||||||
|
// never gains the key at all). That means a curated value can vanish —
|
||||||
|
// deliberately, per policy — with NOTHING in the loop above to report it.
|
||||||
|
// "Liberal but visible" requires the discard itself to be named, not just
|
||||||
|
// a restore. Scoped to exactly the fields `bodyDeltas` tracks
|
||||||
|
// (preserve-when-unchanged rows only — `preserve-always`/`progress` and
|
||||||
|
// `preserve-if-placeholder`/`milestone*` are unaffected by the delta rule
|
||||||
|
// and already fully covered by the restore-diff loop above).
|
||||||
|
for (const [field, delta] of Object.entries(bodyDeltas)) {
|
||||||
|
if (divergedFields.includes(field)) continue; // already reported as a restore above
|
||||||
|
const before = preFmSnapshot[field];
|
||||||
|
const beforeIsReal = typeof before === 'string' && before.trim().length > 0;
|
||||||
|
if (!beforeIsReal) continue; // nothing curated existed to discard
|
||||||
|
if (delta.pre === delta.post) continue; // body source unchanged — governed by the restore branch, not the discard rule
|
||||||
|
const after = preservation.postFm[field];
|
||||||
|
const afterIsEmpty = after === undefined || after === null
|
||||||
|
|| (typeof after === 'string' && after.trim().length === 0);
|
||||||
|
if (afterIsEmpty) divergedFields.push(field);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// #2736: re-assert the intent-first values AFTER preservation. On STATE.md
|
// #2736: re-assert the intent-first values AFTER preservation. On STATE.md
|
||||||
// layouts with no body `Phase:` line, both phase-source snapshots are null
|
// layouts with no body `Phase:` line, both phase-source snapshots are null
|
||||||
@@ -3069,6 +3196,7 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
|||||||
resync,
|
resync,
|
||||||
options?.authoritativeFm,
|
options?.authoritativeFm,
|
||||||
options?.deriveProgressKeys === true,
|
options?.deriveProgressKeys === true,
|
||||||
|
options?.divergedFields,
|
||||||
);
|
);
|
||||||
|
|
||||||
platformWriteSync(statePath, synced);
|
platformWriteSync(statePath, synced);
|
||||||
@@ -3078,6 +3206,170 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-3408 §8.4/§8.5 (D4): frontmatter field name → the body Title-Case
|
||||||
|
* label the `updated` arrays below use. Every `preserve-when-unchanged` row
|
||||||
|
* in `FIELD_CLASSIFICATION` MUST have an entry here (pinned by a parity test,
|
||||||
|
* #3471 review) — `reconcileReportedFields` consults this so a preservation
|
||||||
|
* event on `current_phase_name` folds into a report that otherwise only ever
|
||||||
|
* speaks in body labels like `Current Phase Name` (#3345's direction). A
|
||||||
|
* `preserve-when-unchanged` field missing here is a table drift bug and
|
||||||
|
* `bodyLabelFor` throws rather than silently degrading to the raw
|
||||||
|
* snake_case key (#3471 review — this is a second hand-maintained table
|
||||||
|
* parallel to `FIELD_CLASSIFICATION`, so an unwired row must fail as loudly
|
||||||
|
* as `throwUnwiredRow` in `state-transition.cts` does for the same shape of
|
||||||
|
* omission). `preserve-always`/`preserve-if-placeholder` fields (`progress`,
|
||||||
|
* `milestone`, `milestone_name`) are deliberately absent — `divergedFields`
|
||||||
|
* (ADR-3408 §8.5's out-param) is NOT scoped to `preserve-when-unchanged`
|
||||||
|
* rows alone (see `applyPostSyncPreservation`'s "regardless of which policy
|
||||||
|
* executor fired" diff), so those fields legitimately reach the lookup with
|
||||||
|
* no body-line label to report — `progress` is a structured sub-object and
|
||||||
|
* `milestone`/`milestone_name` version/name pairs, neither ever rendered as
|
||||||
|
* a body prose line — and `bodyLabelFor` falls through to the raw key for
|
||||||
|
* exactly that closed, tested set (`tests/state.test.cjs` A2f pins
|
||||||
|
* `divergedFields` reporting bare `'progress'`).
|
||||||
|
*/
|
||||||
|
const FRONTMATTER_KEY_TO_BODY_LABEL: Readonly<Record<string, string>> = Object.freeze({
|
||||||
|
current_phase: 'Current Phase',
|
||||||
|
current_phase_name: 'Current Phase Name',
|
||||||
|
current_plan: 'Current Plan',
|
||||||
|
stopped_at: 'Stopped At',
|
||||||
|
paused_at: 'Paused At',
|
||||||
|
status: 'Status',
|
||||||
|
last_activity_desc: 'Last Activity Description',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
|
||||||
|
* entry. Throws for a `preserve-when-unchanged` field with no
|
||||||
|
* `FRONTMATTER_KEY_TO_BODY_LABEL` row — that combination can only happen if
|
||||||
|
* a future row is added to `FIELD_CLASSIFICATION` without a matching label,
|
||||||
|
* an internal table-drift bug, never a user-document defect (mirrors
|
||||||
|
* `throwUnwiredRow`'s shape in `state-transition.cts`: an `Error` carrying
|
||||||
|
* `code` and `field` own-properties). Falls through to the raw field name
|
||||||
|
* for every other policy (`preserve-always`, `preserve-if-placeholder`) —
|
||||||
|
* those fields were never claimed to have a body-line label and reaching
|
||||||
|
* this lookup with one of them is the documented, tested, working case
|
||||||
|
* (e.g. `progress`), not a silent degrade.
|
||||||
|
*/
|
||||||
|
function bodyLabelFor(field: string): string {
|
||||||
|
const label = FRONTMATTER_KEY_TO_BODY_LABEL[field];
|
||||||
|
if (label !== undefined) return label;
|
||||||
|
const cls = stateTransitionMod.getFieldClassification(field);
|
||||||
|
if (cls && cls.preservation === 'preserve-when-unchanged') {
|
||||||
|
const err = new Error(
|
||||||
|
`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
|
||||||
|
'FRONTMATTER_KEY_TO_BODY_LABEL entry. This is an internal invariant violation (ADR-3408 ' +
|
||||||
|
'§8.4/D4) — add a label for this field to FRONTMATTER_KEY_TO_BODY_LABEL.',
|
||||||
|
) as Error & { code: string; field: string };
|
||||||
|
err.code = 'STATE_BODY_LABEL_UNWIRED_ROW';
|
||||||
|
err.field = field;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-3408 §8.4 (D4): shared persisted-bytes reconciliation, generalized
|
||||||
|
* from fix(#3351)'s `cmdStatePatch`-only version so every RMW-based command
|
||||||
|
* that reports a per-field `updated` array shares ONE comparison instead of
|
||||||
|
* re-deriving it per call site — duplicated policy is exactly what this
|
||||||
|
* epic exists to remove.
|
||||||
|
*
|
||||||
|
* Closes BOTH directions:
|
||||||
|
* - **#3351's** (reported-but-discarded): a field the transform's own
|
||||||
|
* return value held a value for, that sync/preservation then discarded
|
||||||
|
* or overwrote before the file was saved, must NOT be reported.
|
||||||
|
* - **#3345's** (persisted-but-unreported): a field `applyStatePreservation`
|
||||||
|
* restored that the transform never touched at all must still be
|
||||||
|
* reported — preservation can mutate a field the pre-sync intent never
|
||||||
|
* knew about.
|
||||||
|
*
|
||||||
|
* @param preSyncContent The transformFn's OWN return value — the content
|
||||||
|
* BEFORE `syncAndPreserveStateMd` ran this write — captured by the caller
|
||||||
|
* inside its own `readModifyWriteStateMd` callback. Comparing that against
|
||||||
|
* the actual bytes on disk after the full write pipeline settled is what
|
||||||
|
* makes the report reflect what POST-sync bytes hold (ADR-3408 §8.4),
|
||||||
|
* not what the pre-sync intent merely hoped for.
|
||||||
|
* @param reported The candidate field names — the transform's OWN
|
||||||
|
* success list (e.g. `beginPhaseCore`'s `updated`), never a raw intent
|
||||||
|
* list the transform might not have actually matched. Body Title-Case
|
||||||
|
* labels (`Status`, `Current Plan`) and frontmatter keys are both valid;
|
||||||
|
* each is looked up as a frontmatter key first, else as a body field —
|
||||||
|
* the same fallback chain `cmdStatePatch` used before this generalization.
|
||||||
|
* @param divergedFields Frontmatter field names `applyStatePreservation`
|
||||||
|
* actually restored during this write (ADR-3408 §8.5's out-param).
|
||||||
|
*/
|
||||||
|
function reconcileReportedFields(
|
||||||
|
statePath: string,
|
||||||
|
preSyncContent: string,
|
||||||
|
reported: string[],
|
||||||
|
divergedFields: string[],
|
||||||
|
): string[] {
|
||||||
|
const persisted = platformReadSync(statePath) || '';
|
||||||
|
const persistedFm = extractFrontmatter(persisted, statePath) as Record<string, unknown>;
|
||||||
|
const persistedBody = stripFrontmatter(persisted);
|
||||||
|
const preFm = extractFrontmatter(preSyncContent, statePath) as Record<string, unknown>;
|
||||||
|
const preBody = stripFrontmatter(preSyncContent);
|
||||||
|
|
||||||
|
// #3471 review: body-FIRST, frontmatter-fallback — mirrors the actual write
|
||||||
|
// precedence `patchCore`/`updateCore` apply (their own docstrings: "a key
|
||||||
|
// that resolves against the STRIPPED body ... wins deterministically even
|
||||||
|
// when the same key also happens to exist as a parsed frontmatter key").
|
||||||
|
// The prior frontmatter-first order was silently correct for every
|
||||||
|
// Title-Case body label (`Status`, `Current Plan`, ...) only because those
|
||||||
|
// never case-exact-match a frontmatter key (frontmatter keys are always
|
||||||
|
// lowercase snake_case) — so `hasOwnProperty` always missed and it fell
|
||||||
|
// through to the body anyway. It broke the one case where `field` IS
|
||||||
|
// lowercase and DOES exact-match a frontmatter key: a table-format
|
||||||
|
// STATE.md with a lowercase field name (e.g. `state update status ...`
|
||||||
|
// against `| status | ... |`). There, `preFm` (extracted from the
|
||||||
|
// transform's own pre-sync output) never has a `status` key yet — but
|
||||||
|
// `persistedFm` (extracted after `syncStateFrontmatter` ran) always does,
|
||||||
|
// since `status` is a schema-owned frontmatter key re-derived on every
|
||||||
|
// write. Reading frontmatter first made `intended` (body text) and
|
||||||
|
// `persistedValue` (frontmatter-derived enum) compare two different
|
||||||
|
// representations of the same field, and the write was never reconciled
|
||||||
|
// (regression: #1162's "state update is case-insensitive for table field
|
||||||
|
// names").
|
||||||
|
const valueOf = (fm: Record<string, unknown>, body: string, field: string): string | null => {
|
||||||
|
const bodyValue = stateExtractField(body, field);
|
||||||
|
if (bodyValue !== null) return bodyValue;
|
||||||
|
return Object.prototype.hasOwnProperty.call(fm, field) ? String(fm[field]) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconciled: string[] = [];
|
||||||
|
for (const field of reported) {
|
||||||
|
const intended = valueOf(preFm, preBody, field);
|
||||||
|
const persistedValue = valueOf(persistedFm, persistedBody, field);
|
||||||
|
if (intended !== null && intended.trim() === (persistedValue ?? '').trim()) {
|
||||||
|
reconciled.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #3471 review: only fold a `divergedFields` entry into the reported array
|
||||||
|
// when it is a `preserve-when-unchanged` row (has a genuine body-line
|
||||||
|
// label — Status, Current Plan, Current Phase, ...). #3345's direction
|
||||||
|
// ("preservation restored a field the intent never named") is about a
|
||||||
|
// caller-visible BODY field the transform could plausibly have named —
|
||||||
|
// never about `progress` (`preserve-always`) or `milestone`/`milestone_name`
|
||||||
|
// (`preserve-if-placeholder`), which are structured/paired fields no
|
||||||
|
// caller ever names via a per-field body label and whose restoration is
|
||||||
|
// the long-standing, silent #3242/#948 protection, not a caller-visible
|
||||||
|
// "update". Folding them in unconditionally reported `progress` as
|
||||||
|
// `updated` on every `state.patch`/`state.update` write that happened to
|
||||||
|
// preserve it — even when the call never touched Current Phase's
|
||||||
|
// curated-progress-preserving field at all (regression: #1264's
|
||||||
|
// `state.patch` of `Current Phase` reporting `updated: ['Current Phase',
|
||||||
|
// 'progress']` instead of `['Current Phase']`).
|
||||||
|
for (const field of divergedFields) {
|
||||||
|
const cls = stateTransitionMod.getFieldClassification(field);
|
||||||
|
if (!cls || cls.preservation !== 'preserve-when-unchanged') continue;
|
||||||
|
const label = bodyLabelFor(field);
|
||||||
|
if (!reconciled.includes(label)) reconciled.push(label);
|
||||||
|
}
|
||||||
|
return reconciled;
|
||||||
|
}
|
||||||
|
|
||||||
function cmdStateJson(cwd: string, raw: boolean): void {
|
function cmdStateJson(cwd: string, raw: boolean): void {
|
||||||
const statePath = planningPaths(cwd).state;
|
const statePath = planningPaths(cwd).state;
|
||||||
if (!fs.existsSync(statePath)) {
|
if (!fs.existsSync(statePath)) {
|
||||||
@@ -3096,27 +3388,59 @@ function cmdStateJson(cwd: string, raw: boolean): void {
|
|||||||
// report the preserved value instead of omitting the key.
|
// report the preserved value instead of omitting the key.
|
||||||
const built = buildStateFrontmatter(body, cwd, undefined, readStoredTotalPhases(existingFm));
|
const built = buildStateFrontmatter(body, cwd, undefined, readStoredTotalPhases(existingFm));
|
||||||
|
|
||||||
// Preserve frontmatter-only fields that cannot be recovered from the body.
|
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
|
||||||
if (existingFm && existingFm['stopped_at'] && !built['stopped_at']) {
|
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
|
||||||
built['stopped_at'] = existingFm['stopped_at'];
|
// executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
|
||||||
}
|
// third private copy of the empty-only guards with no delta/staleness check
|
||||||
if (existingFm && existingFm['paused_at'] && !built['paused_at']) {
|
// at all — the shape that let a stale-but-present body annotation always
|
||||||
built['paused_at'] = existingFm['paused_at'];
|
// beat a fresher curated frontmatter value in `state json` output (#3395's
|
||||||
}
|
// shape outside the write seam).
|
||||||
// Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
|
//
|
||||||
if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
|
// `cmdStateJson` never writes — it is one snapshot read, not a
|
||||||
built['status'] = existingFm['status'];
|
// before/after transform — so "did THIS write change the body source"
|
||||||
}
|
// (the #1230 delta the executor consults) is definitionally "no": every
|
||||||
// Bug #905: preserve scalar fields when body annotations are absent.
|
// field's body source is passed as its own delta pre/post pair (the same
|
||||||
// Mirrors the same fallback pattern applied in syncStateFrontmatter.
|
// value twice). That is what makes the executor's rule resolve to
|
||||||
if (existingFm && !built['current_phase'] && existingFm['current_phase']) {
|
// "restore the curated value whenever a real one exists" here — exactly
|
||||||
built['current_phase'] = existingFm['current_phase'];
|
// §8.5's "same terms as an empty derived value" extended to a present
|
||||||
}
|
// one, i.e. the exact D3 fix. Deliberately scoped to only these six
|
||||||
if (existingFm && !built['current_phase_name'] && existingFm['current_phase_name']) {
|
// fields (not the full `applyStatePreservation` dispatch loop): `progress`
|
||||||
built['current_phase_name'] = existingFm['current_phase_name'];
|
// (preserve-always) keeps its own `shouldPreserveExistingProgress`
|
||||||
}
|
// cross-milestone rule below — a DIFFERENT policy that must survive this
|
||||||
if (existingFm && !built['current_plan'] && existingFm['current_plan']) {
|
// change untouched — and `milestone`/`milestone_name`
|
||||||
built['current_plan'] = existingFm['current_plan'];
|
// (preserve-if-placeholder) are out of D3's scope entirely.
|
||||||
|
if (existingFm) {
|
||||||
|
const sessionScope = matchSessionSection(body) ?? body;
|
||||||
|
const positionScope = matchCurrentPositionSection(body) ?? body;
|
||||||
|
const bodyStoppedAt = stateExtractField(sessionScope, 'Stopped At') || stateExtractField(sessionScope, 'Stopped at');
|
||||||
|
const bodyPausedAt = stateExtractField(sessionScope, 'Paused At');
|
||||||
|
const bodyPhaseSource = stateExtractField(body, 'Phase');
|
||||||
|
const bodyCurrentPhase = stateExtractField(body, 'Current Phase')
|
||||||
|
?? parseProsePhaseField(stateExtractField(positionScope, 'Phase')).phase;
|
||||||
|
const bodyCurrentPlan = stateExtractField(body, 'Current Plan');
|
||||||
|
const bodyStatus = stateExtractField(body, 'Status');
|
||||||
|
|
||||||
|
const unchanged = (v: string | null): { pre: string | null; post: string | null } => ({ pre: v, post: v });
|
||||||
|
const ctx = {
|
||||||
|
preFm: null,
|
||||||
|
postFm: built,
|
||||||
|
preFmSnapshot: existingFm,
|
||||||
|
resync: true,
|
||||||
|
deriveProgressKeys: false,
|
||||||
|
bodyDeltas: {
|
||||||
|
status: unchanged(bodyStatus),
|
||||||
|
stopped_at: unchanged(bodyStoppedAt),
|
||||||
|
paused_at: unchanged(bodyPausedAt),
|
||||||
|
current_phase: unchanged(bodyCurrentPhase),
|
||||||
|
current_plan: unchanged(bodyCurrentPlan),
|
||||||
|
current_phase_name: unchanged(bodyPhaseSource),
|
||||||
|
},
|
||||||
|
mutated: false,
|
||||||
|
};
|
||||||
|
for (const field of ['status', 'stopped_at', 'paused_at', 'current_phase', 'current_plan', 'current_phase_name']) {
|
||||||
|
const cls = stateTransitionMod.getFieldClassification(field);
|
||||||
|
if (cls) stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Preserve curated cross-milestone aggregates when local disk scanning sees
|
// Preserve curated cross-milestone aggregates when local disk scanning sees
|
||||||
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
|
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
|
||||||
@@ -3169,10 +3493,13 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
|
|||||||
// that itself contains a parenthetical. The #1695 delta-gate preservation
|
// that itself contains a parenthetical. The #1695 delta-gate preservation
|
||||||
// still runs after the sync; the override is re-asserted after it inside
|
// still runs after the sync; the override is re-asserted after it inside
|
||||||
// readModifyWriteStateMd for layouts with no body `Phase:` line.
|
// readModifyWriteStateMd for layouts with no body `Phase:` line.
|
||||||
|
const divergedFields: string[] = [];
|
||||||
const rmwOptions: ReadModifyWriteOptions = {
|
const rmwOptions: ReadModifyWriteOptions = {
|
||||||
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
||||||
|
divergedFields,
|
||||||
};
|
};
|
||||||
let updated: string[] = [];
|
let precomputedUpdated: string[] = [];
|
||||||
|
let preSyncContent = '';
|
||||||
// #3311: begin-phase is the claim point — it is the one Current Position
|
// #3311: begin-phase is the claim point — it is the one Current Position
|
||||||
// transition that explicitly names its phase, so it both records this
|
// transition that explicitly names its phase, so it both records this
|
||||||
// session's claim and detects a conflicting live claim for a different
|
// session's claim and detects a conflicting live claim for a different
|
||||||
@@ -3185,7 +3512,8 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
|
|||||||
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
|
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
|
||||||
}
|
}
|
||||||
const result = transitionCore(content, intent, deps);
|
const result = transitionCore(content, intent, deps);
|
||||||
updated = result.updated;
|
precomputedUpdated = result.updated;
|
||||||
|
preSyncContent = result.content;
|
||||||
// #3127 resume: the core preserved the mid-flight Current Phase Name, so
|
// #3127 resume: the core preserved the mid-flight Current Phase Name, so
|
||||||
// the intent-first override must not fire — it would drift frontmatter
|
// the intent-first override must not fire — it would drift frontmatter
|
||||||
// away from the preserved body value. Dropping it here is safe because
|
// away from the preserved body value. Dropping it here is safe because
|
||||||
@@ -3196,6 +3524,12 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
|
|||||||
return result.content;
|
return result.content;
|
||||||
}, cwd, rmwOptions);
|
}, cwd, rmwOptions);
|
||||||
|
|
||||||
|
// ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
|
||||||
|
// the bytes actually persisted (fix(#3351) generalized) and fold in any
|
||||||
|
// field preservation restored that this transform never touched (#3345's
|
||||||
|
// direction).
|
||||||
|
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
|
||||||
|
|
||||||
output(
|
output(
|
||||||
{ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict },
|
{ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict },
|
||||||
raw,
|
raw,
|
||||||
@@ -3460,19 +3794,31 @@ function cmdStatePlannedPhase(cwd: string, phaseNumber: string | number, phaseNa
|
|||||||
// line, and the prose re-derivation of current_phase_name truncates names
|
// line, and the prose re-derivation of current_phase_name truncates names
|
||||||
// that themselves contain a parenthetical — the authoritative override keeps
|
// that themselves contain a parenthetical — the authoritative override keeps
|
||||||
// the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
|
// the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
|
||||||
|
const divergedFields: string[] = [];
|
||||||
const rmwOptions: ReadModifyWriteOptions = {
|
const rmwOptions: ReadModifyWriteOptions = {
|
||||||
resync: false,
|
resync: false,
|
||||||
deriveProgressKeys: true,
|
deriveProgressKeys: true,
|
||||||
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
||||||
|
divergedFields,
|
||||||
};
|
};
|
||||||
|
|
||||||
let updated: string[] = [];
|
let precomputedUpdated: string[] = [];
|
||||||
|
let preSyncContent = '';
|
||||||
readModifyWriteStateMd(statePath, (content) => {
|
readModifyWriteStateMd(statePath, (content) => {
|
||||||
const result = transitionCore(content, intent, deps);
|
const result = transitionCore(content, intent, deps);
|
||||||
updated = result.updated;
|
precomputedUpdated = result.updated;
|
||||||
|
preSyncContent = result.content;
|
||||||
return result.content;
|
return result.content;
|
||||||
}, cwd, rmwOptions);
|
}, cwd, rmwOptions);
|
||||||
|
|
||||||
|
// ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
|
||||||
|
// against the bytes actually persisted (fix(#3351) generalized) and fold
|
||||||
|
// in any field preservation restored that this transform never touched
|
||||||
|
// (#3345's direction) — traced for this phase (design doc: "not traced in
|
||||||
|
// the analysis pass") and found to need exactly the same treatment as
|
||||||
|
// `cmdStateBeginPhase`.
|
||||||
|
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
|
||||||
|
|
||||||
const result = updated.length === 0
|
const result = updated.length === 0
|
||||||
? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
|
? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
|
||||||
: { updated, phase: phaseNumber, plan_count: planCount };
|
: { updated, phase: phaseNumber, plan_count: planCount };
|
||||||
@@ -4326,6 +4672,8 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
|
|||||||
|
|
||||||
const today = realClock.localToday();
|
const today = realClock.localToday();
|
||||||
const updated: string[] = [];
|
const updated: string[] = [];
|
||||||
|
let preSyncContent = '';
|
||||||
|
const divergedFields: string[] = [];
|
||||||
|
|
||||||
readModifyWriteStateMd(statePath, (content) => {
|
readModifyWriteStateMd(statePath, (content) => {
|
||||||
const currentPhase = resolvedPhase;
|
const currentPhase = resolvedPhase;
|
||||||
@@ -4409,13 +4757,31 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return reassemble(body);
|
const out = reassemble(body);
|
||||||
}, cwd);
|
preSyncContent = out;
|
||||||
|
return out;
|
||||||
|
}, cwd, { divergedFields });
|
||||||
|
|
||||||
|
// ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
|
||||||
|
// the analysis pass"). Unlike the transitionCore-based commands, this
|
||||||
|
// adapter's `updated` mixes FIELD names (Status, Last Activity, Last
|
||||||
|
// Activity Description — each reconcilable against the persisted bytes,
|
||||||
|
// same as every other command in this phase) with the SECTION name
|
||||||
|
// `Current Position` (the whole Current-Position block, not a single
|
||||||
|
// field `stateExtractField` can look up — reconciling it the same way as
|
||||||
|
// a field would always drop it as a false negative). Reconcile only the
|
||||||
|
// field-shaped entries (#3351's direction), pass the section entry
|
||||||
|
// through unconditionally, and fold in any field preservation restored
|
||||||
|
// that this transform never touched (#3345's direction).
|
||||||
|
const SECTION_ENTRIES = new Set(['Current Position']);
|
||||||
|
const sectionEntries = updated.filter((f) => SECTION_ENTRIES.has(f));
|
||||||
|
const fieldEntries = updated.filter((f) => !SECTION_ENTRIES.has(f));
|
||||||
|
const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preSyncContent, fieldEntries, divergedFields)];
|
||||||
|
|
||||||
output(
|
output(
|
||||||
{ updated, phase: resolvedPhase },
|
{ updated: reconciled, phase: resolvedPhase },
|
||||||
raw,
|
raw,
|
||||||
updated.length > 0 ? 'true' : 'false',
|
reconciled.length > 0 ? 'true' : 'false',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4468,6 +4834,10 @@ export = {
|
|||||||
// Test seam (#1514): the pure retired/folded-phase parser, exposed so its
|
// Test seam (#1514): the pure retired/folded-phase parser, exposed so its
|
||||||
// strikethrough-detection logic can be property-tested directly.
|
// strikethrough-detection logic can be property-tested directly.
|
||||||
_extractRetiredPhaseNumbers: extractRetiredPhaseNumbers,
|
_extractRetiredPhaseNumbers: extractRetiredPhaseNumbers,
|
||||||
|
// Test seam (#3471 review): the second hand-maintained table beside
|
||||||
|
// FIELD_CLASSIFICATION, exposed so a parity test can pin that every
|
||||||
|
// `preserve-when-unchanged` row has a label here.
|
||||||
|
_FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
|
||||||
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
||||||
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
||||||
_setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void {
|
_setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void {
|
||||||
|
|||||||
@@ -651,6 +651,26 @@ describe('ADR-3408 §8.3 Matrix B: cmdMilestoneComplete preserves + warns (#3469
|
|||||||
const output = JSON.parse(result.output);
|
const output = JSON.parse(result.output);
|
||||||
assert.deepStrictEqual(output.preservation_warnings, []);
|
assert.deepStrictEqual(output.preservation_warnings, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ADR-3408 §8.5 Matrix B3 (#3471, regression-only): D1's guard deletion and
|
||||||
|
// D3's cmdStateJson change both scope explicitly to `state.cts`/the write
|
||||||
|
// seam — `cmdMilestoneComplete` (src/milestone.cts) is untouched by this
|
||||||
|
// phase (per the implementation report: "No changes to src/milestone.cts").
|
||||||
|
// Re-runs the exact B1/B3 shape above as this phase's own pin, so a future
|
||||||
|
// change cannot silently regress it without a Phase-4-owned test noticing.
|
||||||
|
test('B3 (#3471 regression pin): preservation_warnings shape and content unchanged by Phase 4', () => {
|
||||||
|
writeStateWithSession(tmpDir, { fmStoppedAt: 'Phase 7 verified PASS', sessionStoppedAt: 'Phase 3 work' });
|
||||||
|
|
||||||
|
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
output.preservation_warnings,
|
||||||
|
[{ field: 'stopped_at', reason: 'preserved-over-disagreeing-derived' }],
|
||||||
|
'cmdMilestoneComplete\'s preservation_warnings shape must be unaffected by Phase 4 (#3471)',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -6677,6 +6677,61 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
|||||||
assert.strictEqual(after.state, before.state, 'STATE.md must be unchanged — none of the three partially written');
|
assert.strictEqual(after.state, before.state, 'STATE.md must be unchanged — none of the three partially written');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// ADR-3408 §8.5 Matrix B (#3471): cmdPhaseComplete's preservation is now
|
||||||
|
// visible via `preservation_warnings` (D2 — #3374's "warnings: []"
|
||||||
|
// complaint, on the exact command it was filed against). Design:
|
||||||
|
// .gsd/phase/refactor-3471-stale-but-present/40-design.md. Matrix:
|
||||||
|
// .gsd/phase/refactor-3471-stale-but-present/50-test-matrix.md section B.
|
||||||
|
// Reuses setupPhase3517Project/runSdkQuery/writePassedVerificationForPhase
|
||||||
|
// from this same folded block (bug #3517 fixture).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ADR-3408 §8.5 Matrix B (#3471): cmdPhaseComplete preservation visibility (D2)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3471-b-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// B1 — consumer-level (ADR-3180 Decision 4(c)): a curated field the
|
||||||
|
// transition never touches (its body source is unchanged this write)
|
||||||
|
// must be named in `preservation_warnings`, closing #3374's exact
|
||||||
|
// "warnings: []" silence for the command it was filed against.
|
||||||
|
test('B1: cmdPhaseComplete names the field it preserved in preservation_warnings', () => {
|
||||||
|
setupPhase3517Project(tmpDir);
|
||||||
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||||
|
|
||||||
|
const before = fs.readFileSync(statePath, 'utf8');
|
||||||
|
fs.writeFileSync(statePath, before.replace(/^gsd_state_version: 1\.0$/m, 'gsd_state_version: 1.0\npaused_at: "curated pause note — must survive"'));
|
||||||
|
|
||||||
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
||||||
|
assert.ok(r.success, `call failed: ${r.error}`);
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(r.data.preservation_warnings), 'preservation_warnings must be an array');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
r.data.preservation_warnings,
|
||||||
|
[{ field: 'paused_at', reason: 'preserved-over-disagreeing-derived' }],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// B2 — negative: no false alarm. When nothing is preserved (no
|
||||||
|
// disagreeing curated field), `preservation_warnings` must be empty, not
|
||||||
|
// populated with a phantom entry.
|
||||||
|
test('B2: cmdPhaseComplete emits an empty preservation_warnings when nothing was preserved', () => {
|
||||||
|
setupPhase3517Project(tmpDir);
|
||||||
|
|
||||||
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
||||||
|
assert.ok(r.success, `call failed: ${r.error}`);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(r.data.preservation_warnings, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const fc = require('fast-check');
|
|||||||
// consumer's OBSERVABLE output is compared against (Decision 4c) — never the
|
// consumer's OBSERVABLE output is compared against (Decision 4c) — never the
|
||||||
// owner's return value against itself.
|
// owner's return value against itself.
|
||||||
const stateLib = require('../gsd-core/bin/lib/state.cjs');
|
const stateLib = require('../gsd-core/bin/lib/state.cjs');
|
||||||
|
const stateTransitionMod = require('../gsd-core/bin/lib/state-transition.cjs');
|
||||||
const stateDocument = require('../gsd-core/bin/lib/state-document.cjs');
|
const stateDocument = require('../gsd-core/bin/lib/state-document.cjs');
|
||||||
const frontmatterLib = require('../gsd-core/bin/lib/frontmatter.cjs');
|
const frontmatterLib = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||||
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
||||||
@@ -5057,6 +5058,745 @@ describe('ADR-3408 §8.3 Matrix C: cmdStateSync — the sanctioned exception (#3
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// ADR-3408 §8.5 Matrix (#3471): stale-but-present, and the report residue.
|
||||||
|
// Design: .gsd/phase/refactor-3471-stale-but-present/40-design.md
|
||||||
|
// Matrix: .gsd/phase/refactor-3471-stale-but-present/50-test-matrix.md
|
||||||
|
//
|
||||||
|
// As-built (probe-verified against gsd-core/bin/lib/state.cjs, not guessed):
|
||||||
|
// - The six #905 empty-only guards in `syncStateFrontmatter` are GATED behind
|
||||||
|
// a `sanctionedPermanentEmptyFallback` param, not deleted outright.
|
||||||
|
// `writeStateMd` (state sync / REGENERATE_STATE) passes `true`;
|
||||||
|
// `syncAndPreserveStateMd` (the write seam) passes nothing — so an empty
|
||||||
|
// derived value reaches `applyStatePreservation` unmolested there.
|
||||||
|
// - `cmdStateJson` routes exactly six fields (status, stopped_at, paused_at,
|
||||||
|
// current_phase, current_plan, current_phase_name — NOT last_activity_desc)
|
||||||
|
// through `applyPreserveWhenUnchanged` with a synthetic {pre,post} delta
|
||||||
|
// (same value twice — a read is never a "write").
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('ADR-3408 §8.5 Matrix (#3471): stale-but-present, and the report residue', () => {
|
||||||
|
// #3471: extractFrontmatter's mini-YAML parser returns nested `progress.*`
|
||||||
|
// scalars as raw strings unless a caller runs them through
|
||||||
|
// normalizeProgressNumbers (the sanctioned-permanent guard path does; the
|
||||||
|
// write-seam merge exercised throughout this block does not). This exact
|
||||||
|
// string-vs-number round-trip has bitten test authoring FOUR times in this
|
||||||
|
// phase alone (twice self-caught before shipping, once as A4, now as
|
||||||
|
// A2f) — route every progress-reading assertion in this block through this
|
||||||
|
// helper rather than comparing against numeric literals. Mirrors
|
||||||
|
// `tests/frontmatter.test.cjs`'s `readPersistedProgress` (which is
|
||||||
|
// file-path based, reading from disk); this variant operates on an
|
||||||
|
// already-extracted `fm.progress` object since not every case in this
|
||||||
|
// block round-trips through a file.
|
||||||
|
function numericProgress(fmProgress) {
|
||||||
|
assert.ok(fmProgress, 'frontmatter must have a progress block');
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(fmProgress).map(([key, value]) => [key, Number(value)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section A — deleting the six guards (D1) ──────────────────────────────
|
||||||
|
// A1-A4/A7 assert on the write seam's own returned content (not required to
|
||||||
|
// be consumer-level by the matrix's assertion rule). A5/A6 ARE in the
|
||||||
|
// consumer-level list (ADR-3180 Decision 4(c)) so they write the result to a
|
||||||
|
// real file and read it back, mirroring the existing A1/A2/A3 fast-check
|
||||||
|
// property's own pattern (tests/state.test.cjs "ADR-3408 §8.3 Matrix
|
||||||
|
// A1/A2/A3").
|
||||||
|
describe('Section A — deleting the six guards (D1)', () => {
|
||||||
|
test('A1: derived empty, body source UNCHANGED, curated frontmatter present — restored via the executor', (t) => {
|
||||||
|
const tmp = createTempDir('gsd-3471-a1-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const original = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase: "5"', 'current_phase_name: Curated Name', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Status: Executing', '',
|
||||||
|
].join('\n');
|
||||||
|
// Change something OTHER than the Phase line, so this is a real write
|
||||||
|
// whose Phase-line body source is unchanged pre/post.
|
||||||
|
const transformed = original.replace('Status: Executing', 'Status: Verifying');
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
const divergedFields = [];
|
||||||
|
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, undefined, divergedFields);
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(out);
|
||||||
|
assert.strictEqual(fm.current_phase, '5', 'current_phase must be restored by the executor, not lost');
|
||||||
|
assert.strictEqual(fm.current_phase_name, 'Curated Name', 'current_phase_name must be restored by the executor, not lost');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
divergedFields.slice().sort(),
|
||||||
|
['current_phase', 'current_phase_name'],
|
||||||
|
'both restores must be reported — preservation is visible',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A2 — deliberately SIX separate named tests, one per gated guard. A list
|
||||||
|
// over these six field names is easy to quietly shorten; six named tests
|
||||||
|
// are not (matrix's own stated rationale).
|
||||||
|
function a2Case(curatedFrontmatterLine, body) {
|
||||||
|
const original = ['---', 'gsd_state_version: 1.0', curatedFrontmatterLine, '---', '', '# Project State', '', ...body].join('\n');
|
||||||
|
const transformed = original.replace('Executing', 'Verifying');
|
||||||
|
const tmp = createTempDir('gsd-3471-a2-');
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
const divergedFields = [];
|
||||||
|
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, undefined, divergedFields);
|
||||||
|
cleanup(tmp);
|
||||||
|
return { fm: frontmatterLib.extractFrontmatter(out), divergedFields };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('A2a: stopped_at — restored', () => {
|
||||||
|
const { fm, divergedFields } = a2Case(
|
||||||
|
'stopped_at: "Phase 3, curated stop"',
|
||||||
|
['## Session', '', '**Last session:** 2026-01-01', ''],
|
||||||
|
);
|
||||||
|
assert.strictEqual(fm.stopped_at, 'Phase 3, curated stop');
|
||||||
|
assert.deepStrictEqual(divergedFields, ['stopped_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A2b: paused_at — restored', () => {
|
||||||
|
const { fm, divergedFields } = a2Case(
|
||||||
|
'paused_at: "Phase 3, curated pause"',
|
||||||
|
['## Session', '', '**Last session:** 2026-01-01', ''],
|
||||||
|
);
|
||||||
|
assert.strictEqual(fm.paused_at, 'Phase 3, curated pause');
|
||||||
|
assert.deepStrictEqual(divergedFields, ['paused_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A2c: current_phase — restored', () => {
|
||||||
|
const { fm, divergedFields } = a2Case(
|
||||||
|
'current_phase: "5"',
|
||||||
|
['## Current Position', '', 'Status: Executing', ''],
|
||||||
|
);
|
||||||
|
assert.strictEqual(fm.current_phase, '5');
|
||||||
|
assert.deepStrictEqual(divergedFields, ['current_phase']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A2d: current_phase_name — restored', () => {
|
||||||
|
const { fm, divergedFields } = a2Case(
|
||||||
|
'current_phase_name: Curated Name',
|
||||||
|
['## Current Position', '', 'Status: Executing', ''],
|
||||||
|
);
|
||||||
|
assert.strictEqual(fm.current_phase_name, 'Curated Name');
|
||||||
|
assert.deepStrictEqual(divergedFields, ['current_phase_name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A2e: current_plan — restored', () => {
|
||||||
|
const { fm, divergedFields } = a2Case(
|
||||||
|
'current_plan: 03-02-curated',
|
||||||
|
['## Current Position', '', 'Status: Executing', ''],
|
||||||
|
);
|
||||||
|
assert.strictEqual(fm.current_plan, '03-02-curated');
|
||||||
|
assert.deepStrictEqual(divergedFields, ['current_plan']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A2f: progress — restored (resync:false — preserve-always is resync-gated, unlike the other five)', () => {
|
||||||
|
const { fm, divergedFields } = a2Case(
|
||||||
|
['progress:', ' total_phases: 3', ' completed_phases: 2', ' total_plans: 5', ' completed_plans: 4', ' percent: 80'].join('\n'),
|
||||||
|
['## Current Position', '', 'Status: Executing', ''],
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
numericProgress(fm.progress),
|
||||||
|
{ total_phases: 3, completed_phases: 2, total_plans: 5, completed_plans: 4, percent: 80 },
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(divergedFields, ['progress']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3: derived empty, no curated value — stays empty, no throw', (t) => {
|
||||||
|
const tmp = createTempDir('gsd-3471-a3-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const original = ['---', 'gsd_state_version: 1.0', '---', '', '# Project State', '', '## Current Position', '', 'Status: Executing', ''].join('\n');
|
||||||
|
const transformed = original.replace('Status: Executing', 'Status: Verifying');
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
const divergedFields = [];
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, undefined, divergedFields);
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(out);
|
||||||
|
assert.strictEqual(fm.current_phase, undefined);
|
||||||
|
assert.strictEqual(fm.current_phase_name, undefined);
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(divergedFields, [], 'nothing curated existed, so nothing can have diverged');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A4 — the subtlest row: `progress` is a sub-object. A block the disk scan
|
||||||
|
// only PARTIALLY derived (here: only total_phases, from a body "Total
|
||||||
|
// Phases:" line, no phase dirs on disk) is not "empty" — the deleted
|
||||||
|
// empty-only guard's own condition (`!derivedFm['progress']`) never fired
|
||||||
|
// for a partial block even before this phase, so a partial block is
|
||||||
|
// governed purely by `applyPreserveAlways`'s existing #2440/#2969 merge
|
||||||
|
// (deriveProgressKeys:true): the derive-flagged keys (total_phases here)
|
||||||
|
// always take the freshly-derived value ("the derived block wins"), while
|
||||||
|
// the ratchet-protected keys (completed_phases/completed_plans) keep the
|
||||||
|
// curated value when the derived side lacks a greater one ("a partial
|
||||||
|
// block must NOT clobber curated" — #3242). Empirically verified against
|
||||||
|
// the compiled lib before writing this assertion.
|
||||||
|
test('A4: progress partially derived (block present, some keys missing) vs curated — derived wins for derive-flagged keys, curated survives for ratchet-protected keys', (t) => {
|
||||||
|
const tmp = createTempDir('gsd-3471-a4-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const original = [
|
||||||
|
'---', 'gsd_state_version: 1.0',
|
||||||
|
'progress:', ' total_phases: 3', ' completed_phases: 2', ' total_plans: 5', ' completed_plans: 4', ' percent: 80',
|
||||||
|
'---', '', '# Project State', '', '## Current Position', '', 'Total Phases: 3', '',
|
||||||
|
].join('\n');
|
||||||
|
const transformed = original.replace('Total Phases: 3', 'Total Phases: 10');
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, true);
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(out);
|
||||||
|
// #3471 review: extractFrontmatter's mini-YAML parser returns nested
|
||||||
|
// `progress.*` scalars as raw strings — see `numericProgress` above
|
||||||
|
// for why. Compare numerically, not by strict type.
|
||||||
|
const progress = numericProgress(fm.progress);
|
||||||
|
assert.strictEqual(progress.total_phases, 10, 'total_phases (derive-flagged) must take the freshly-derived disk value');
|
||||||
|
assert.strictEqual(progress.completed_phases, 2, 'completed_phases (ratchet-protected) must keep curated — the partial derive must not clobber it');
|
||||||
|
assert.strictEqual(progress.completed_plans, 4, 'completed_plans (ratchet-protected) must keep curated — the partial derive must not clobber it');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A5 — the D1 defect itself, at the CONSUMER's output (ADR-3180 Decision
|
||||||
|
// 4(c)): the transform DELETES the body Phase line (delta CHANGED, not
|
||||||
|
// merely absent-and-unchanged like A1/A3) — derived (empty) must win per
|
||||||
|
// the delta rule, AND the discard must be reported in `divergedFields`,
|
||||||
|
// never silently persist the stale curated value (the bug D1 closes).
|
||||||
|
test('A5: derived empty because the transform DELETED the body line (delta CHANGED) — derived wins, and the discard is reported', (t) => {
|
||||||
|
const tmp = createTempDir('gsd-3471-a5-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const original = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase: "5"', 'current_phase_name: Curated Name', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Phase: 5 (Curated Name)', '',
|
||||||
|
].join('\n');
|
||||||
|
const transformed = original.replace('Phase: 5 (Curated Name)\n', '');
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
fs.writeFileSync(statePath, original);
|
||||||
|
const divergedFields = [];
|
||||||
|
const written = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false, undefined, undefined, divergedFields);
|
||||||
|
fs.writeFileSync(statePath, written);
|
||||||
|
|
||||||
|
// Consumer-level: read the real file back, never compare the owner's
|
||||||
|
// return value to itself.
|
||||||
|
const onDisk = fs.readFileSync(statePath, 'utf8');
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(onDisk);
|
||||||
|
assert.strictEqual(fm.current_phase, undefined, 'the deleted body line must not leave a stale curated current_phase behind');
|
||||||
|
assert.strictEqual(fm.current_phase_name, undefined, 'the deleted body line must not leave a stale curated current_phase_name behind');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
divergedFields.slice().sort(),
|
||||||
|
['current_phase', 'current_phase_name'],
|
||||||
|
'the discard-to-empty must be visible in divergedFields — this is the D1 bug\'s exact silent-persistence shape, now closed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A6 — the regression wall, at the CONSUMER's output: Phases 1-3 already
|
||||||
|
// fixed the headline "non-empty stale body value, delta unchanged, loses
|
||||||
|
// to fresher curated frontmatter" case. If this test goes red, Phase 4
|
||||||
|
// broke what the epic was for.
|
||||||
|
test('A6: derived non-empty and STALE, delta unchanged, fresher frontmatter wins — must not regress', (t) => {
|
||||||
|
const tmp = createTempDir('gsd-3471-a6-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const original = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase_name: Fresh Curated Name', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Phase: 3 (Stale Body Name)', '',
|
||||||
|
].join('\n');
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
fs.writeFileSync(statePath, original);
|
||||||
|
const divergedFields = [];
|
||||||
|
// No transform this write — delta unchanged (transformedContent === originalContent).
|
||||||
|
const written = stateLib.syncAndPreserveStateMd(original, original, statePath, tmp, false, undefined, undefined, divergedFields);
|
||||||
|
fs.writeFileSync(statePath, written);
|
||||||
|
|
||||||
|
const onDisk = fs.readFileSync(statePath, 'utf8');
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(onDisk);
|
||||||
|
assert.strictEqual(fm.current_phase_name, 'Fresh Curated Name', 'fresher curated frontmatter must win over the stale body value — unchanged from Phases 1-3');
|
||||||
|
const onDiskBody = frontmatterLib.stripFrontmatter(onDisk);
|
||||||
|
const originalBody = frontmatterLib.stripFrontmatter(original);
|
||||||
|
assert.strictEqual(onDiskBody, originalBody, 'the stale body text itself is untouched (frontmatter wins, body prose is not rewritten)');
|
||||||
|
assert.deepStrictEqual(divergedFields, ['current_phase_name'], 'the restore must be reported');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A7: #2202 unknown-key carry-forward still works on the write seam', (t) => {
|
||||||
|
const tmp = createTempDir('gsd-3471-a7-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const original = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'custom_unknown_key: preserved-value', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Status: Executing', '',
|
||||||
|
].join('\n');
|
||||||
|
const transformed = original.replace('Executing', 'Verifying');
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
const out = stateLib.syncAndPreserveStateMd(original, transformed, statePath, tmp, false);
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(out);
|
||||||
|
assert.strictEqual(fm.custom_unknown_key, 'preserved-value', 'an unknown/custom frontmatter key must still carry forward — a different mechanism from the six deleted guards');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Section C — cmdStateJson (D3) ─────────────────────────────────────────
|
||||||
|
describe('Section C — cmdStateJson (D3)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = createFixture(); });
|
||||||
|
afterEach(() => { cleanup(tmpDir); });
|
||||||
|
|
||||||
|
// C1 — the D3 defect, at the CONSUMER's output: a stale non-empty body
|
||||||
|
// annotation must no longer automatically beat a fresher curated
|
||||||
|
// frontmatter value in `state json` — governed by the SAME
|
||||||
|
// preserve-when-unchanged executor the write path uses.
|
||||||
|
test('C1a: stale non-empty body current_phase_name loses to fresher curated frontmatter in `state json`', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase_name: Fresh Curated Name', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Phase: 3 (Stale Body Name)', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.current_phase_name, 'Fresh Curated Name', '#3395\'s shape, now closed outside the write seam too');
|
||||||
|
});
|
||||||
|
|
||||||
|
// C1b — the report's own call-out: `status` is a MATERIALLY LARGER change
|
||||||
|
// than the design's examples (which only used current_phase). Before D3,
|
||||||
|
// status only fell back on the literal sentinel 'unknown'; now curated
|
||||||
|
// wins over ANY disagreeing derived value, same as the other five fields.
|
||||||
|
test('C1b: a real (non-"unknown") curated status wins over a disagreeing derived body Status in `state json`', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'status: verifying', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Status: Executing', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(
|
||||||
|
output.status,
|
||||||
|
'verifying',
|
||||||
|
'curated status must win over ANY disagreeing derived value now, not just the literal \'unknown\' sentinel',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2: derived empty, curated present — falls back, unchanged', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase: "5"', 'current_phase_name: Curated Name', 'current_plan: 05-02', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', '(nothing recognizable here)', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.current_phase, '5');
|
||||||
|
assert.strictEqual(output.current_phase_name, 'Curated Name');
|
||||||
|
assert.strictEqual(output.current_plan, '05-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
// C3 — independence: `shouldPreserveExistingProgress`'s cross-milestone
|
||||||
|
// rule is a DIFFERENT policy from the six empty-only/D3 guards and must
|
||||||
|
// survive untouched. Exercised in the SAME `state json` call as one of
|
||||||
|
// D3's new six fields, proving the two coexist without interference.
|
||||||
|
test('C3: shouldPreserveExistingProgress cross-milestone progress preservation survives D3 untouched, alongside a D3-governed field', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'stopped_at: "curated stop must survive"',
|
||||||
|
'progress:', ' total_phases: 3', ' completed_phases: 2', ' total_plans: 20', ' completed_plans: 18', ' percent: 90',
|
||||||
|
'---', '', '# Project State', '', '## Session', '', '**Last session:** 2026-01-01', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.progress.completed_plans, 18, 'shouldPreserveExistingProgress must still preserve the higher curated count');
|
||||||
|
assert.strictEqual(output.stopped_at, 'curated stop must survive', 'D3\'s new six-field policy must still fire in the same read');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C4: `state json` never writes — STATE.md is byte-identical before and after', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase_name: Fresh Curated Name', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Phase: 3 (Stale Body Name)', '',
|
||||||
|
].join('\n');
|
||||||
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||||
|
fs.writeFileSync(statePath, content);
|
||||||
|
const before = fs.readFileSync(statePath, 'utf8');
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const after = fs.readFileSync(statePath, 'utf8');
|
||||||
|
assert.strictEqual(after, before, '`state json` must never write STATE.md, even when D3\'s policy decides a value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Section D — the sanctioned exceptions: the regression wall for §8.3 ──
|
||||||
|
// D4 (ratchet: exactly 2 sanctioned-permanent entries, not 0) is already
|
||||||
|
// covered by tests/state-write-path-drift-guard.test.cjs's E6/E7/E8 (the
|
||||||
|
// boundary triple 2/1/3) — not duplicated here.
|
||||||
|
describe('Section D — the sanctioned exceptions (regression wall for §8.3)', () => {
|
||||||
|
// D1 — consumer-level: `state sync` must still let a fresh body value win
|
||||||
|
// over a stale-but-EMPTY-only-fallback-eligible curated frontmatter value
|
||||||
|
// — proven here on a body that has NO annotation at all for six curated
|
||||||
|
// fields, so only the gated `sanctionedPermanentEmptyFallback` fallback
|
||||||
|
// (not the deleted-on-the-write-seam guards) can be keeping them alive.
|
||||||
|
test('D1: `state sync` still restores all six curated fields (plus last_activity_desc) from a blank body — the sanctioned fallback must survive D1\'s guard deletion', (t) => {
|
||||||
|
const tmp = createTempDir('gsd-3471-d1-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
const original = [
|
||||||
|
'---', 'gsd_state_version: 1.0',
|
||||||
|
'current_phase: "5"', 'current_phase_name: Curated Name', 'current_plan: 05-02-plan',
|
||||||
|
'stopped_at: Phase 5, curated stop', 'paused_at: Phase 5, curated pause',
|
||||||
|
'last_activity_desc: curated activity desc',
|
||||||
|
'---', '', '# Project State', '', '## Current Position', '', '## Session', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(statePath, original);
|
||||||
|
|
||||||
|
stateLib.writeStateMd(statePath, original, tmp);
|
||||||
|
|
||||||
|
const onDisk = fs.readFileSync(statePath, 'utf8');
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(onDisk);
|
||||||
|
assert.strictEqual(fm.current_phase, '5');
|
||||||
|
assert.strictEqual(fm.current_phase_name, 'Curated Name');
|
||||||
|
assert.strictEqual(fm.current_plan, '05-02-plan');
|
||||||
|
assert.strictEqual(fm.stopped_at, 'Phase 5, curated stop');
|
||||||
|
assert.strictEqual(fm.paused_at, 'Phase 5, curated pause');
|
||||||
|
assert.strictEqual(fm.last_activity_desc, 'curated activity desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
// D2 — identity: `state sync`'s output is pinned byte-for-byte (frozen
|
||||||
|
// clock, per CONTRIBUTING's clock-seam rule — mirrors the existing
|
||||||
|
// "ADR-3408 §8.3 Matrix A1/A2/A3" property test's own PINNED_MS pattern).
|
||||||
|
// Empirically derived from the compiled lib before being hardcoded here.
|
||||||
|
test('D2: `state sync` output is byte-identical for a fixed input under a frozen clock', (t) => {
|
||||||
|
const PINNED_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
||||||
|
t.mock.timers.enable(['Date']);
|
||||||
|
t.mock.timers.setTime(PINNED_MS);
|
||||||
|
|
||||||
|
const tmp = createTempDir('gsd-3471-d2-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
const original = [
|
||||||
|
'---', 'gsd_state_version: 1.0',
|
||||||
|
'current_phase: "5"', 'current_phase_name: Curated Name', 'current_plan: 05-02-plan',
|
||||||
|
'stopped_at: Phase 5, curated stop', 'paused_at: Phase 5, curated pause',
|
||||||
|
'last_activity_desc: curated activity desc',
|
||||||
|
'---', '', '# Project State', '', '## Current Position', '', '## Session', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(statePath, original);
|
||||||
|
|
||||||
|
stateLib.writeStateMd(statePath, original, tmp);
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'status: unknown', 'last_updated: "2023-11-14T22:13:20.000Z"',
|
||||||
|
'stopped_at: Phase 5, curated stop', 'paused_at: Phase 5, curated pause',
|
||||||
|
'current_phase: 5', 'current_phase_name: Curated Name', 'current_plan: 05-02-plan',
|
||||||
|
'last_activity_desc: curated activity desc',
|
||||||
|
'---', '', '# Project State', '', '## Current Position', '', '## Session', '',
|
||||||
|
].join('\n');
|
||||||
|
assert.strictEqual(fs.readFileSync(statePath, 'utf8'), expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// D3 — REGENERATE_STATE's exact call shape (health-diagnostic.cts:328-337):
|
||||||
|
// a wholly fresh `stateContent` with NO frontmatter block, passed to
|
||||||
|
// `writeStateMd` — it never re-reads the OLD statePath's frontmatter, so
|
||||||
|
// none of the discarded curated values can leak through, regardless of
|
||||||
|
// `sanctionedPermanentEmptyFallback`.
|
||||||
|
test('D3: REGENERATE_STATE\'s writeStateMd shape discards ALL prior curated values — factory reset, no preservation', (t) => {
|
||||||
|
const tmp = createTempDir('gsd-3471-d3-');
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const statePath = path.join(tmp, 'STATE.md');
|
||||||
|
const oldCurated = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase: "5"',
|
||||||
|
'current_phase_name: Curated Name To Discard', 'stopped_at: should not survive regenerate',
|
||||||
|
'paused_at: should not survive regenerate', 'current_plan: should not survive regenerate',
|
||||||
|
'---', '', '# Project State', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(statePath, oldCurated);
|
||||||
|
|
||||||
|
// The regenerated content health-diagnostic.cts builds: no frontmatter
|
||||||
|
// block at all.
|
||||||
|
const regenerated = [
|
||||||
|
'# Session State', '', '## Position', '',
|
||||||
|
'**Current phase:** (determining...)', '**Status:** Resuming', '',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
stateLib.writeStateMd(statePath, regenerated, tmp);
|
||||||
|
|
||||||
|
const onDisk = fs.readFileSync(statePath, 'utf8');
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(onDisk);
|
||||||
|
assert.notStrictEqual(fm.current_phase_name, 'Curated Name To Discard', 'the factory reset must discard the old curated name');
|
||||||
|
assert.notStrictEqual(fm.stopped_at, 'should not survive regenerate');
|
||||||
|
assert.notStrictEqual(fm.paused_at, 'should not survive regenerate');
|
||||||
|
assert.notStrictEqual(fm.current_plan, 'should not survive regenerate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Section E — report reconciliation (D4 — §8.4's residue) ──────────────
|
||||||
|
// E7 (cmdStatePatch, independence — fix(#3351) not regressed) is already
|
||||||
|
// covered by the existing "#3351: state.patch report reconciled against
|
||||||
|
// persisted STATE.md" describe block above — not duplicated here.
|
||||||
|
describe('Section E — report reconciliation (D4)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = createFixture(); });
|
||||||
|
afterEach(() => { cleanup(tmpDir); });
|
||||||
|
|
||||||
|
test('E1: cmdStateUpdate — `updated` reflects the persisted change, and `preserved` names a field the update never touched but preservation restored', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase_name: Curated Name', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Status: Executing', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'update', 'Status', 'Verifying'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, true);
|
||||||
|
assert.deepStrictEqual(output.preserved, ['Current Phase Name'], '#3345\'s direction: a field the update never named that preservation restored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2: cmdStateAdvancePlan — `updated` names only the fields whose persisted value actually changed (happy path)', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Current Plan: 1', 'Total Plans in Phase: 3', 'Status: Executing', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.deepStrictEqual(output.updated.slice().sort(), ['Current Plan', 'Status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// E6 (#3345's direction — the direction nothing has ever tested): a field
|
||||||
|
// preservation restored that advancePlanCore's OWN transform never
|
||||||
|
// touched at all IS in `updated`. Also demonstrates the report's item 4:
|
||||||
|
// cmdStateAdvancePlan previously exposed NO `updated` array whatsoever.
|
||||||
|
test('E6: cmdStateAdvancePlan — a field preservation restored, that the transform never touched, IS in `updated`', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase: "99"', 'current_phase_name: Curated Stale Name', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Phase: 1 (Old Name)', 'Current Plan: 3', 'Total Plans in Phase: 3', 'Status: Executing', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
// Last plan in the phase — advance-plan reports readiness without
|
||||||
|
// touching the body Phase line, so its delta is unchanged and the
|
||||||
|
// curated current_phase/current_phase_name must be restored and
|
||||||
|
// reported, even though advancePlanCore's own intent never named them
|
||||||
|
// (confirmed empirically: the SAME fixture minus the curated conflict
|
||||||
|
// reports `updated: ["Status"]` only).
|
||||||
|
const result = runGsdTools(['state', 'advance-plan'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.ok(output.updated.includes('Current Phase'), `expected 'Current Phase' in updated: ${JSON.stringify(output.updated)}`);
|
||||||
|
assert.ok(output.updated.includes('Current Phase Name'), `expected 'Current Phase Name' in updated: ${JSON.stringify(output.updated)}`);
|
||||||
|
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'));
|
||||||
|
assert.strictEqual(fm.current_phase, '99', 'the reported field must match what was actually persisted');
|
||||||
|
assert.strictEqual(fm.current_phase_name, 'Curated Stale Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E3: cmdStateBeginPhase — `updated` names only the fields whose persisted value actually changed (happy path)', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Status: Not started', '', '## Session', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'begin-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.deepStrictEqual(output.updated, ['Status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E3 (preservation): cmdStateBeginPhase — a curated field the transition never touches IS in `updated`', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'stopped_at: "curated stop must survive"', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Status: Not started', '', '## Session', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'begin-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.deepStrictEqual(output.updated.slice().sort(), ['Status', 'Stopped At']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E4: cmdStateRecordSession — `updated` names only the fields whose persisted value actually changed (happy path)', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', '---', '',
|
||||||
|
'# Project State', '', '## Session', '', '**Last session:** old', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'record-session', '--stopped-at', 'Phase 1 complete'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.deepStrictEqual(output.updated.slice().sort(), ['Last session', 'Stopped At']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E4 (preservation): cmdStateRecordSession — a curated field the transform never touches IS in `updated`', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'current_phase_name: Curated Name', '---', '',
|
||||||
|
'# Project State', '', '## Session', '', '**Last session:** old', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'record-session', '--stopped-at', 'Phase 1 complete'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.deepStrictEqual(output.updated.slice().sort(), ['Current Phase Name', 'Last session', 'Stopped At']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// E8 — untraced in the design's own analysis pass: cmdStatePlannedPhase
|
||||||
|
// and cmdStateCompletePhase (the DIFFERENT legacy hand-rolled one) are
|
||||||
|
// traced here rather than assumed, per the design's own instruction.
|
||||||
|
test('E8a: cmdStatePlannedPhase reconciles the same way as cmdStateBeginPhase (traced, not assumed)', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'stopped_at: "curated stop must survive"', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Status: Not started', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.deepStrictEqual(output.updated, ['Stopped At']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// E8b — cmdStateCompletePhase (the legacy hand-rolled path, NOT the
|
||||||
|
// transitionCore-based one cmdPhaseComplete uses): its `updated` mixes
|
||||||
|
// FIELD names with the SECTION name 'Current Position'. Reconciliation
|
||||||
|
// must apply only to the field-shaped entries and pass 'Current Position'
|
||||||
|
// through unconditionally, never dropping it as a false negative.
|
||||||
|
test('E8b: cmdStateCompletePhase (legacy) reconciles field entries and passes the "Current Position" section entry through unconditionally', () => {
|
||||||
|
const content = [
|
||||||
|
'---', 'gsd_state_version: 1.0', 'paused_at: "curated pause must survive"', 'current_phase: 1', '---', '',
|
||||||
|
'# Project State', '', '## Current Position', '', 'Phase: 1', 'Status: Executing', 'Last activity: 2026-01-01', '',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||||
|
|
||||||
|
const result = runGsdTools(['state', 'complete-phase'], tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.ok(output.updated.includes('Current Position'), 'the whole-section entry must not be dropped as a false negative by field-shaped reconciliation');
|
||||||
|
assert.ok(output.updated.includes('Paused At'), '#3345\'s direction must also apply to this legacy path');
|
||||||
|
|
||||||
|
const fm = frontmatterLib.extractFrontmatter(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'));
|
||||||
|
assert.strictEqual(fm.paused_at, 'curated pause must survive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Required fast-check property: D3's copy really is gone ───────────────
|
||||||
|
// For any (curated frontmatter value, body-source value) pair on one of the
|
||||||
|
// six D3-governed fields, with the body delta held UNCHANGED (cmdStateJson
|
||||||
|
// is a read, never a write, so its synthetic delta is definitionally
|
||||||
|
// unchanged — {pre:v, post:v}) — the value the write seam would PERSIST
|
||||||
|
// equals the value `state json` REPORTS for the identical document. Both
|
||||||
|
// sides now dispatch through the exact same `applyPreserveWhenUnchanged`
|
||||||
|
// executor (state-transition.cts), so this is the "D3's copy really is
|
||||||
|
// gone" identity the matrix requires, at the consumer's output on both
|
||||||
|
// sides (write: the real persisted file; read: the real `state json` CLI).
|
||||||
|
// Seed pinned, runs bounded, replay data printed on failure, frozen clock
|
||||||
|
// (Phase 2's property could never pass without this — `last_updated` moves
|
||||||
|
// between the two invocations otherwise).
|
||||||
|
describe('Required property: write-seam persistence and `state json` agree for the same document (D3)', () => {
|
||||||
|
const FIELD_BODY = {
|
||||||
|
current_phase: (v) => ({ section: '## Current Position', line: `Current Phase: ${v}` }),
|
||||||
|
current_phase_name: (v) => ({ section: '## Current Position', line: `Phase: 1 (${v})` }),
|
||||||
|
current_plan: (v) => ({ section: '## Current Position', line: `Current Plan: ${v}` }),
|
||||||
|
stopped_at: (v) => ({ section: '## Session', line: `Stopped At: ${v}` }),
|
||||||
|
paused_at: (v) => ({ section: '## Session', line: `Paused At: ${v}` }),
|
||||||
|
status: (v) => ({ section: '## Current Position', line: `Status: ${v}` }),
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDoc(field, curated, derived) {
|
||||||
|
const fmLines = ['gsd_state_version: 1.0'];
|
||||||
|
if (curated !== null) fmLines.push(`${field}: ${JSON.stringify(curated)}`);
|
||||||
|
const spec = derived !== null ? FIELD_BODY[field](derived) : null;
|
||||||
|
const sections = spec
|
||||||
|
? [spec.section, '', spec.line, '']
|
||||||
|
: ['## Current Position', '', '(no annotation)', ''];
|
||||||
|
return ['---', ...fmLines, '---', '', '# Project State', '', ...sections].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('property: write-seam persisted value equals `state json` reported value', (t) => {
|
||||||
|
const PINNED_MS = 1_700_000_000_000;
|
||||||
|
t.mock.timers.enable(['Date']);
|
||||||
|
t.mock.timers.setTime(PINNED_MS);
|
||||||
|
|
||||||
|
const safeString = fc.array(
|
||||||
|
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-'.split('')),
|
||||||
|
{ minLength: 1, maxLength: 12 },
|
||||||
|
).map((chars) => chars.join(''));
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.constantFrom('current_phase', 'current_phase_name', 'current_plan', 'stopped_at', 'paused_at', 'status'),
|
||||||
|
fc.option(safeString, { nil: null }),
|
||||||
|
fc.option(safeString, { nil: null }),
|
||||||
|
(field, curated, derived) => {
|
||||||
|
const tmp = createFixture();
|
||||||
|
t.after(() => cleanup(tmp));
|
||||||
|
const statePath = path.join(tmp, '.planning', 'STATE.md');
|
||||||
|
const content = buildDoc(field, curated, derived);
|
||||||
|
fs.writeFileSync(statePath, content);
|
||||||
|
|
||||||
|
// Write-side: syncAndPreserveStateMd with an UNCHANGED delta
|
||||||
|
// (transformedContent === originalContent) — the same regime
|
||||||
|
// cmdStateJson's synthetic {pre,post} delta represents.
|
||||||
|
const written = stateLib.syncAndPreserveStateMd(content, content, statePath, tmp, false);
|
||||||
|
const writeFm = frontmatterLib.extractFrontmatter(written);
|
||||||
|
const writeVal = writeFm[field] !== undefined && writeFm[field] !== null ? String(writeFm[field]) : null;
|
||||||
|
|
||||||
|
// Read-side: the real `state json` CLI, on the SAME on-disk document.
|
||||||
|
const jsonResult = runGsdTools(['state', 'json'], tmp);
|
||||||
|
if (!jsonResult.success) {
|
||||||
|
throw new Error(`state json failed: field=${field} curated=${JSON.stringify(curated)} derived=${JSON.stringify(derived)}\n${jsonResult.error}`);
|
||||||
|
}
|
||||||
|
const readFm = JSON.parse(jsonResult.output);
|
||||||
|
const readVal = readFm[field] !== undefined && readFm[field] !== null ? String(readFm[field]) : null;
|
||||||
|
|
||||||
|
if (writeVal !== readVal) {
|
||||||
|
throw new Error(
|
||||||
|
`D3 copy divergence: field=${field} curated=${JSON.stringify(curated)} derived=${JSON.stringify(derived)}\n` +
|
||||||
|
`write-seam persisted=${JSON.stringify(writeVal)} vs state-json reported=${JSON.stringify(readVal)}\n` +
|
||||||
|
`document:\n${content}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ seed: 3471, numRuns: 40 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Parity: FRONTMATTER_KEY_TO_BODY_LABEL vs FIELD_CLASSIFICATION (#3471 review) ─
|
||||||
|
// A second hand-maintained table beside FIELD_CLASSIFICATION is exactly the
|
||||||
|
// "policy declared in one table, a second table beside it drifting quietly"
|
||||||
|
// shape this epic exists to remove — CLAUDE.md's "Generative Fix
|
||||||
|
// Divergence" entry requires a parity assertion for any two surfaces
|
||||||
|
// sharing a constant. `bodyLabelFor` (state.cts) throws for a
|
||||||
|
// preserve-when-unchanged field missing here (mirrors `throwUnwiredRow` in
|
||||||
|
// state-transition.cts) rather than silently falling back to the raw
|
||||||
|
// snake_case name — this test is what keeps that throw unreachable.
|
||||||
|
describe('Parity: every preserve-when-unchanged row has a FRONTMATTER_KEY_TO_BODY_LABEL entry (#3471 review)', () => {
|
||||||
|
test('FRONTMATTER_KEY_TO_BODY_LABEL has a label for every FIELD_CLASSIFICATION preserve-when-unchanged row', () => {
|
||||||
|
const preserveWhenUnchangedFields = Object.keys(stateTransitionMod.FIELD_CLASSIFICATION)
|
||||||
|
.filter((field) => stateTransitionMod.FIELD_CLASSIFICATION[field].preservation === 'preserve-when-unchanged');
|
||||||
|
|
||||||
|
// Sanity: the table this test pins is non-empty — a passing loop over
|
||||||
|
// zero fields would be a vacuous-truth false green (CLAUDE.md's Test
|
||||||
|
// Cleanup rule).
|
||||||
|
assert.ok(preserveWhenUnchangedFields.length > 0, 'expected at least one preserve-when-unchanged row to pin');
|
||||||
|
|
||||||
|
const missing = preserveWhenUnchangedFields.filter(
|
||||||
|
(field) => !Object.prototype.hasOwnProperty.call(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL, field),
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(missing, [], `preserve-when-unchanged field(s) with no body label, would hit bodyLabelFor's throw: ${JSON.stringify(missing)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reverse direction: every label row IS a real field, and is either
|
||||||
|
// preserve-when-unchanged (the contract this table documents) or absent
|
||||||
|
// from FIELD_CLASSIFICATION entirely — never a preserve-always /
|
||||||
|
// preserve-if-placeholder field masquerading with a stale label.
|
||||||
|
test('every FRONTMATTER_KEY_TO_BODY_LABEL row is a preserve-when-unchanged FIELD_CLASSIFICATION field', () => {
|
||||||
|
const wrongPolicy = Object.keys(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL).filter((field) => {
|
||||||
|
const cls = stateTransitionMod.getFieldClassification(field);
|
||||||
|
return cls !== null && cls.preservation !== 'preserve-when-unchanged';
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(wrongPolicy, [], `FRONTMATTER_KEY_TO_BODY_LABEL row(s) whose FIELD_CLASSIFICATION policy is not preserve-when-unchanged: ${JSON.stringify(wrongPolicy)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Bug #2444: stopped_at frontmatter must not be overwritten by historical body prose
|
// Bug #2444: stopped_at frontmatter must not be overwritten by historical body prose
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user