fix(#4129): derive completed_phases from the ROADMAP authority; honor the progress-ratchet on every state write (#4359)
* test(#4129): failing-first regressions — completed_phases clobber on resyncing writes and phase-complete failure to increment * fix(#4129): completed_phases derives from the ROADMAP authority and the write path honors the progress-ratchet Three coordinated prongs (diagnosis in .gsd/bug/fix-4129-completed-phases-recompute/): P1 — buildStateFrontmatter's disk scan floors the completed-phases numerator at the milestone-scoped ROADMAP Complete-row count (deriveProgressFromRoadmap, the one owner), gated inside the same safeToUseRoadmapCount / not-withheld branch that owns the denominator. A completed phase whose verification routes stale (#2348 clean-commit-time drift) or is missing no longer under-counts forever. P2 — applyPreserveAlways's resync arm merges instead of wholesale-replacing on a measured scan: totals derived both directions (#2440), completed counters up-only (#2969 — the schema-declared progress-ratchet, now enforced on the write path like the read path always has), percent recomputed from the merged counters. The #3756 unmeasured guard and the #3242 explicit-progress contract are unchanged. P3 — phase complete's atomic 3-file commit passes the post-completion ROADMAP-derived counters through the #2736 authoritativeFm seam (new object direction for the progress key; completedOnlyRaise at the post-preservation re-assert), because the transaction's disk scan reads the pre-completion ROADMAP and failed to increment on the completing phase's own write. * fix(#4129): adversarial-review hardening — intent is a floor at BOTH authoritativeFm sites The pre-preservation merge could lower a correctly-higher disk-derived counter (a verification-passed phase whose ROADMAP table row drifted behind the disk signal). completedOnlyRaise now governs both application sites: the intent and the derivation agree on direction (up), never on subtraction. * fix(#4129): the ratchet merge keeps derived values verbatim when numerically equal The re-parsed derived block carries string scalars ("2") while the curated snapshot carries numbers (2); substituting the curated spelling over an equal derived one was a no-op in substance but a shape churn the ADR-3473 §8.7 reporting loop surfaced as a phantom preserved-over-disagreeing-derived warning on phase complete (ADR-3408 §8.5 Matrix B). Only a strictly-greater curated counter replaces the derived value now; percent gets the same verbatim rule. * changeset(#4129): backfill PR 4359 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -4348,9 +4348,19 @@ describe('ADR-3473 §8.6 matrix rows 13-17 (+10/11 pinned): the measured-vs-unme
|
||||
});
|
||||
|
||||
test('stringNonZeroTotalIsMeasured', () => {
|
||||
// #4129 superseded the observable: a measured resyncing write no longer
|
||||
// lets the derived block wholesale-replace the curated one — the
|
||||
// declared progress-ratchet now merges (totals derived both directions,
|
||||
// completed counters up-only, #2969). The measured/unmeasured BOUNDARY
|
||||
// this row pins is unchanged and still observable here: the derived
|
||||
// TOTALS stand (a wholesale curated restore would have written 5/32).
|
||||
const r = restoreWithDerivedTotals({ total_phases: '1', total_plans: '0', completed_phases: '0', completed_plans: '0' });
|
||||
assert.strictEqual(r.mutated, false, 'a measured scan (total_phases:"1") must win — derived stands untouched');
|
||||
assert.deepStrictEqual(r.postFm.progress, { total_phases: '1', total_plans: '0', completed_phases: '0', completed_plans: '0' });
|
||||
assert.strictEqual(r.mutated, true, 'a measured scan (total_phases:"1") must not wholesale-restore the curated block — the #4129 ratchet merge runs');
|
||||
assert.deepStrictEqual(
|
||||
r.postFm.progress,
|
||||
{ total_phases: '1', total_plans: '0', completed_phases: 5, completed_plans: 32 },
|
||||
'measured: totals take the derived value (#2440 both directions); completed counters keep curated ("0" < 5/#2969 up-only); derived carried no percent so none is invented',
|
||||
);
|
||||
});
|
||||
|
||||
test('absentTotalsAreUnmeasured', () => {
|
||||
@@ -4379,15 +4389,18 @@ describe('ADR-3473 §8.6 matrix rows 13-17 (+10/11 pinned): the measured-vs-unme
|
||||
|
||||
// Pinned mirror pair from the design's row 11/the existing matrix's row
|
||||
// 10 — grouped here so the boundary (only both-zero is unmeasured) reads
|
||||
// legibly against the coercion cases above.
|
||||
// legibly against the coercion cases above. #4129 superseded the observable
|
||||
// to the ratchet merge (see stringNonZeroTotalIsMeasured above): "measured"
|
||||
// is still decided by EITHER total being non-zero, and still observable
|
||||
// because the derived TOTALS stand rather than a curated wholesale restore.
|
||||
test('oneNonZeroTotalCountsAsMeasuredEitherDirection', () => {
|
||||
const r1 = restoreWithDerivedTotals({ total_phases: 1, total_plans: 0, completed_phases: 0, completed_plans: 0 });
|
||||
assert.strictEqual(r1.mutated, false, 'total_phases:1, total_plans:0 must count as measured');
|
||||
assert.deepStrictEqual(r1.postFm.progress, { total_phases: 1, total_plans: 0, completed_phases: 0, completed_plans: 0 });
|
||||
assert.strictEqual(r1.mutated, true, 'total_phases:1, total_plans:0 must count as measured (#4129 ratchet merge ran, not a curated restore)');
|
||||
assert.deepStrictEqual(r1.postFm.progress, { total_phases: 1, total_plans: 0, completed_phases: 5, completed_plans: 32 });
|
||||
|
||||
const r2 = restoreWithDerivedTotals({ total_phases: 0, total_plans: 1, completed_phases: 0, completed_plans: 0 });
|
||||
assert.strictEqual(r2.mutated, false, 'total_phases:0, total_plans:1 must count as measured (the mirror) — only both-zero is unmeasured');
|
||||
assert.deepStrictEqual(r2.postFm.progress, { total_phases: 0, total_plans: 1, completed_phases: 0, completed_plans: 0 });
|
||||
assert.strictEqual(r2.mutated, true, 'total_phases:0, total_plans:1 must count as measured (the mirror) — only both-zero is unmeasured');
|
||||
assert.deepStrictEqual(r2.postFm.progress, { total_phases: 0, total_plans: 1, completed_phases: 5, completed_plans: 32 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4441,3 +4454,121 @@ describe('ADR-3473 §8.6 matrix row 36: property — preservation never drops a
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4129: the resync arm of `applyPreserveAlways` (rows 8-14 of .gsd/bug/
|
||||
// fix-4129-completed-phases-recompute/50-test-matrix.md). A resyncing write
|
||||
// whose scan MEASURED something now runs the declared `progress-ratchet`
|
||||
// merge instead of wholesale-replacing the curated block — the write path
|
||||
// finally enforces the same monotonic property the read path
|
||||
// (`shouldPreserveExistingProgress`) always has. The #3756 unmeasured guard
|
||||
// and the #3242/#3871 explicit-progress contract are unchanged (pinned by the
|
||||
// blocks above and by tests/frontmatter.test.cjs).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#4129: resyncing measured write ratchets the progress block', () => {
|
||||
function resyncMerge(curatedProgress, derivedProgress, extra = {}) {
|
||||
const tx = openStateTransaction({
|
||||
snapshot: { progress: { ...curatedProgress } },
|
||||
resync: true,
|
||||
bodyDeltas: neutralBodyDeltasForMatrix(),
|
||||
...extra,
|
||||
});
|
||||
return applyStatePreservation({ transaction: tx, postFm: { progress: { ...derivedProgress } } });
|
||||
}
|
||||
|
||||
test('resyncRatchetKeepsCuratedCompletedWhenDerivedUnderCounts', () => {
|
||||
// The issue's exact shape: derived 2 (a stale-dated sibling verification)
|
||||
// vs curated 3 (the ROADMAP truth a hand-fix or earlier correct write left).
|
||||
const r = resyncMerge(
|
||||
{ total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 },
|
||||
{ total_phases: 18, completed_phases: 2, total_plans: 6, completed_plans: 6, percent: 11 },
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
r.postFm.progress,
|
||||
{ total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 },
|
||||
'#4129: a measured resync must never move completed counters DOWN — totals derived, completed curated, percent recomputed from the merged counters (3/18=17)',
|
||||
);
|
||||
assert.strictEqual(r.mutated, true, 'the merge actually changed the derived block');
|
||||
});
|
||||
|
||||
test('resyncRatchetStillLetsCompletedMoveUp', () => {
|
||||
// Genuine completion: derived ABOVE curated must ratchet up, and percent follows.
|
||||
const r = resyncMerge(
|
||||
{ total_phases: 18, completed_phases: 2, total_plans: 6, completed_plans: 6, percent: 11 },
|
||||
{ total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 },
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
r.postFm.progress,
|
||||
{ total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 },
|
||||
'#4129: the ratchet is up-only, never a freeze — a genuine increment still lands',
|
||||
);
|
||||
});
|
||||
|
||||
test('resyncRatchetMixedSidesRecomputePercentFromMergedCounters', () => {
|
||||
// Mixed: derived completed_plans ratchets up, curated completed_phases
|
||||
// survives — percent must be recomputed from the MERGED counters
|
||||
// (min(54/54 plans, 5/? phases capped by plan fraction), never either
|
||||
// side's stale stored percent.
|
||||
const r = resyncMerge(
|
||||
{ total_plans: 54, completed_plans: 50, completed_phases: 5, percent: 93 },
|
||||
{ total_plans: 54, completed_plans: 54, completed_phases: 1, percent: 50 },
|
||||
);
|
||||
assert.strictEqual(r.postFm.progress.completed_plans, 54, 'derived completed_plans (54 > 50) ratchets up');
|
||||
assert.strictEqual(r.postFm.progress.completed_phases, 5, 'curated completed_phases (5 > 1) survives — never down');
|
||||
// min(54/54, 5/5-with-no-total... completed_phases 5, total_phases absent) —
|
||||
// computeProgressPercent with only plan data present: min(1.0, plan)=100? No:
|
||||
// phase data absent → phaseFraction=1 → min(1, 1) = 100.
|
||||
assert.strictEqual(r.postFm.progress.percent, 100, 'percent is recomputed from the merged counters through the completion-ratio kernel');
|
||||
});
|
||||
|
||||
test('resyncRatchetCoercesStringScalarsThroughToFiniteNumber', () => {
|
||||
// Frontmatter scalars arrive as STRINGS ("2", not 2) — the comparison
|
||||
// must coerce (scanMeasuredSomething's own convention), never `typeof`.
|
||||
const r = resyncMerge(
|
||||
{ total_phases: 18, completed_phases: 3, percent: 17 },
|
||||
{ total_phases: '18', total_plans: '6', completed_phases: '2', completed_plans: '6', percent: 11 },
|
||||
);
|
||||
assert.strictEqual(r.postFm.progress.completed_phases, 3, 'string "2" must compare numerically against curated 3 — curated survives');
|
||||
assert.strictEqual(r.postFm.progress.total_phases, '18', 'string totals take the derived value verbatim (both directions)');
|
||||
assert.strictEqual(r.postFm.progress.percent, 17, 'percent recomputed from merged counters (3/18)');
|
||||
});
|
||||
|
||||
test('resyncRatchetIsANoOpWhenDerivedEqualsCurated', () => {
|
||||
// The #4094 withheld shape arrives here: the scan fell back to the stored
|
||||
// counters, so derived == curated and the merge must not report a
|
||||
// mutation (the #948 no-op-write family).
|
||||
const block = { total_phases: 18, completed_phases: 3, total_plans: 6, completed_plans: 6, percent: 17 };
|
||||
const r = resyncMerge(block, { ...block });
|
||||
assert.strictEqual(r.mutated, false, 'derived === curated → no mutation (withheld shape is untouched)');
|
||||
assert.deepStrictEqual(r.postFm.progress, block);
|
||||
});
|
||||
|
||||
test('resyncRatchetDoesNotResurrectAWithheldPercent', () => {
|
||||
// Derived percent ABSENT (an upstream #1761/#3217 withhold nulled it) —
|
||||
// the merge must not recompute one over counters it was withheld for.
|
||||
// Percent follows the derived side (the deriveProgressKeys branch's own
|
||||
// convention) and recomputation is gated on the derived block HAVING
|
||||
// carried one — an absent percent stays absent, exactly as the
|
||||
// pre-#4129 wholesale replace left it.
|
||||
const r = resyncMerge(
|
||||
{ total_phases: 5, completed_phases: 5, percent: 100 },
|
||||
{ total_phases: 5, completed_phases: 2 },
|
||||
);
|
||||
assert.strictEqual(r.postFm.progress.completed_phases, 5, 'curated completed counter survives');
|
||||
assert.ok(!('percent' in r.postFm.progress), 'no recomputed percent may appear when the derived block carried none');
|
||||
});
|
||||
|
||||
test('explicitProgressFieldStillWholesaleReplacesOnMeasuredScan', () => {
|
||||
// #3242/#3871 contract, unchanged by #4129: when the caller NAMED a
|
||||
// progress field, the resync they asked for wins outright — the escape
|
||||
// hatch for deliberate downward correction stays open.
|
||||
const r = resyncMerge(
|
||||
{ total_phases: 18, completed_phases: 3, percent: 17 },
|
||||
{ total_phases: 18, completed_phases: 2, percent: 11 },
|
||||
{ explicitProgressField: true },
|
||||
);
|
||||
assert.strictEqual(r.mutated, false, 'explicit progress write: the derived block stands untouched');
|
||||
assert.deepStrictEqual(r.postFm.progress, { total_phases: 18, completed_phases: 2, percent: 11 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user