fix(#2440): per-counter progress ratchet — total_plans always takes derived value (#2468)

* fix(#2440): per-counter progress ratchet — total_plans always takes derived value

Two sites fixed (targeted — existing body-only write tests preserved):

Site A — read path: shouldPreserveExistingProgress (state-document.cts:167)
removed total_plans from the all-or-nothing ratchet check. It now joins
total_phases as an always-derived counter. Only completed_phases and
completed_plans keep ratchet behaviour (they are monotonic). This fixes
gsd-tools query state.json reporting stale total_plans when a curated
completed_plans triggers the ratchet.

Site B — write path: applyStatePreservation (state-transition.cts:162)
gained a deriveProgressKeys opt-in flag. When true (passed by
cmdStatePlannedPhase only), total_plans and total_phases take the
derived (post-sync) value instead of the wholesale curated restore.
When false (the default — state.update, state.patch), the existing
#3242 wholesale protection stays fully in force. This fixes the
state planned-phase verb writing a stale total_plans.

The opt-in approach preserves all 8 existing #3242/#1264/#500 body-only
write tests that assert wholesale progress preservation during non-
progress updates.

Tests:
- tests/state.test.cjs: 4 unit tests for shouldPreserveExistingProgress
  (total_plans upward/downward/equality + completed_plans ratchet active).
- tests/state-transition.test.cjs: 2 #2440 regression tests for
  deriveProgressKeys=true (total_plans takes derived; boundary at equality).
  The existing !resync wholesale-restore test stays unchanged (default
  behavior preserved).

References: #2440; #1446 (total_phases read-path fix — same principle);
#3242 Bug A (body-only preservation — protection preserved via the opt-in
gate); ADR-1769 (applyStatePreservation table-driven preservation).

* chore(#2440): backfill pr:2468 in .changeset/mellow-eagles-chatter.md
This commit is contained in:
Tom Boucher
2026-07-20 19:24:21 -04:00
committed by GitHub
parent bb97ffb5aa
commit a54feb4216
8 changed files with 156 additions and 16 deletions

View File

@@ -1534,13 +1534,16 @@ Last activity: TBD
const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(md, /Total Plans in Phase:\s*3/, 'per-phase Total Plans in Phase should be updated to 3');
// ...but the curated milestone-wide progress block is preserved verbatim.
assert.deepEqual(readProgress(), {
total_plans: 99,
completed_plans: 88,
total_phases: 7,
completed_phases: 5,
}, 'curated milestone progress.* must survive a planned-phase write');
// #2440: total_plans now takes the derived value (it must correct in both
// directions). It must NOT be the stale curated 99 — that was the bug.
// completed_plans and completed_phases keep curated protection (#500/#3242).
const progress = readProgress();
assert.notEqual(progress.total_plans, 99,
'total_plans must NOT be the stale curated 99 — it should correct to the derived value (#2440)');
assert.strictEqual(progress.completed_plans, 88,
'completed_plans must stay curated (88) — #3242/#500 protection still in force');
assert.strictEqual(progress.completed_phases, 5,
'completed_phases must stay curated (5) — #3242/#500 protection still in force');
});
});
});

View File

@@ -1254,6 +1254,7 @@ describe('ADR-1769 #1796: applyStatePreservation — table-driven post-sync cons
};
test('progress: restores curated block when table=preserve-always and transition is not re-deriving (!resync)', () => {
// Default behavior: wholesale curated restore. #3242 Bug A protection.
const curated = { progress: { total_phases: 4, completed_phases: 3, percent: 75 } };
const r = applyStatePreservation({
preFm: curated,
@@ -1266,6 +1267,42 @@ describe('ADR-1769 #1796: applyStatePreservation — table-driven post-sync cons
assert.equal(r.mutated, true);
});
test('#2440: deriveProgressKeys=true — total_plans takes derived value under !resync', () => {
// The cmdStatePlannedPhase caller opts in via deriveProgressKeys. total_plans
// and total_phases take the derived (post-sync) value; completed_plans and
// completed_phases keep curated protection.
const curated = { progress: { total_plans: 50, completed_plans: 50, total_phases: 2, completed_phases: 1, percent: 100 } };
const r = applyStatePreservation({
preFm: curated,
preFmSnapshot: curated,
postFm: { progress: { total_plans: 64, completed_plans: 49, total_phases: 2, completed_phases: 1, percent: 77 } },
resync: false,
deriveProgressKeys: true,
...untouched,
});
assert.equal(r.postFm.progress.total_plans, 64,
'total_plans must take derived value (64) when deriveProgressKeys=true (#2440)');
assert.equal(r.postFm.progress.completed_plans, 50,
'completed_plans must keep curated value (50 > 49 triggers ratchet)');
assert.equal(r.postFm.progress.total_phases, 2,
'total_phases takes derived value (same as curated here — identity)');
assert.equal(r.mutated, true);
});
test('#2440 boundary: deriveProgressKeys=true, total_plans derived == curated → identity', () => {
const curated = { progress: { total_plans: 64, completed_plans: 49 } };
const r = applyStatePreservation({
preFm: curated,
preFmSnapshot: curated,
postFm: { progress: { total_plans: 64, completed_plans: 49, percent: 77 } },
resync: false,
deriveProgressKeys: true,
...untouched,
});
assert.equal(r.postFm.progress.total_plans, 64,
'total_plans equality → derived value (identity)');
});
test('progress: NOT restored when transition re-derives from disk (resync=true) — sync/advancePlan/completePhase path', () => {
const recomputed = { progress: { total_phases: 5, completed_phases: 1, percent: 20 } };
const r = applyStatePreservation({

View File

@@ -10659,5 +10659,43 @@ describe('bug #1514 — extractRetiredPhaseNumbers property: returns exactly the
);
});
});
// ─── #2440: total_plans excluded from progress ratchet (per-counter) ──────────
//
// Pre-fix: shouldPreserveExistingProgress included total_plans in the ratchet.
// Fix: total_plans joins total_phases as always-derived (both move in both
// directions). Only completed_phases and completed_plans keep ratchet behaviour.
describe('bug #2440 — shouldPreserveExistingProgress does not ratchet total_plans', () => {
const { shouldPreserveExistingProgress } = require('../gsd-core/bin/lib/state-document.cjs');
test('existing total_plans:50 > derived:64 → returns false (upward correction)', () => {
const existing = { total_phases: 2, completed_phases: 1, total_plans: 50, completed_plans: 49 };
const derived = { total_phases: 2, completed_phases: 1, total_plans: 64, completed_plans: 49 };
assert.equal(shouldPreserveExistingProgress(existing, derived), false,
'total_plans upward correction must NOT trigger ratchet');
});
test('existing total_plans:50 > derived:30 → returns false (downward correction)', () => {
const existing = { total_phases: 2, completed_phases: 1, total_plans: 50, completed_plans: 30 };
const derived = { total_phases: 2, completed_phases: 1, total_plans: 30, completed_plans: 30 };
assert.equal(shouldPreserveExistingProgress(existing, derived), false,
'total_plans downward correction must NOT trigger ratchet');
});
test('boundary: existing total_plans == derived → false', () => {
const existing = { total_phases: 2, completed_phases: 1, total_plans: 50, completed_plans: 50 };
const derived = { total_phases: 2, completed_phases: 1, total_plans: 50, completed_plans: 50 };
assert.equal(shouldPreserveExistingProgress(existing, derived), false,
'total_plans equality must NOT trigger ratchet');
});
test('completed_plans:5 > derived:2 → true (ratchet still active for completed_plans)', () => {
const existing = { total_phases: 2, completed_phases: 1, total_plans: 6, completed_plans: 5 };
const derived = { total_phases: 2, completed_phases: 1, total_plans: 6, completed_plans: 2 };
assert.equal(shouldPreserveExistingProgress(existing, derived), true,
'completed_plans ratchet must still work');
});
});
});
}