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:
@@ -7380,6 +7380,167 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// #4129 row 1: `phase complete N` failed to increment completed_phases when
|
||||
// a SIBLING completed phase's verification routes `stale` (its SUMMARY was
|
||||
// touched after the verification — the issue's real-world drift). The
|
||||
// transaction flips this phase's ROADMAP row and derives the BODY counters
|
||||
// from the post-completion ROADMAP, but the frontmatter disk scan reads the
|
||||
// PRE-completion ROADMAP and the stale-dated sibling, so the persisted
|
||||
// counter stays pinned at the under-count. See .gsd/bug/
|
||||
// fix-4129-completed-phases-recompute/{10-diagnosis,50-test-matrix}.md.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('#4129: phase complete increments completed_phases to the ROADMAP truth', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4129-phase-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Body Progress percent through the repo's own field extractor (never raw
|
||||
// substring matching on rendered STATE.md — CONTRIBUTING.md prohibits it).
|
||||
function bodyProgressPercentFromState(stateContent) {
|
||||
const raw = stateExtractField(stateContent, 'Progress');
|
||||
if (raw === null) return null;
|
||||
const match = raw.match(/(\d{1,3})%/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function setupStaleSiblingProject(tmpDir) {
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(phasesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'REPRO' }));
|
||||
|
||||
const roadmapLines = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v1.0',
|
||||
'',
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|----------------|--------|-----------|',
|
||||
'| 1. | 2/2 | Complete | 2026-01-01 |',
|
||||
'| 2. | 2/2 | Complete | 2026-01-02 |',
|
||||
'| 3. | 2/2 | In Progress | |',
|
||||
];
|
||||
for (let i = 4; i <= 18; i += 1) roadmapLines.push(`| ${i}. | 0/2 | Not Started | |`);
|
||||
roadmapLines.push('', '- [x] Phase 1: Alpha (completed 2026-01-01)', '- [x] Phase 2: Beta (completed 2026-01-02)', '- [ ] Phase 3: Gamma');
|
||||
for (let i = 4; i <= 18; i += 1) roadmapLines.push(`- [ ] Phase ${i}: P${i}`);
|
||||
for (let i = 1; i <= 18; i += 1) {
|
||||
roadmapLines.push('', `### Phase ${i}: P${i}`, '', '**Goal:** goal', '**Plans:** 2 plans', '');
|
||||
}
|
||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmapLines.join('\n'));
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v1.0',
|
||||
'milestone_name: Programme',
|
||||
'status: executing',
|
||||
'current_phase: 3',
|
||||
'last_updated: 2026-01-02T10:00:00.000Z',
|
||||
'progress:',
|
||||
' total_phases: 18',
|
||||
' completed_phases: 2',
|
||||
' total_plans: 4',
|
||||
' completed_plans: 4',
|
||||
' percent: 11',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 3 of 18 (Gamma) — EXECUTING',
|
||||
'Plan: 2 of 2',
|
||||
'Status: Executing Phase 3',
|
||||
'Last activity: 2026-01-02',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'Progress: [█░░░░░░░░░] 11% (2/18 phases complete)',
|
||||
'',
|
||||
'## Session Continuity',
|
||||
'',
|
||||
'Last session: 2026-01-02T10:00:00.000Z',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
for (const p of [1, 2, 3]) {
|
||||
const pp = String(p).padStart(2, '0');
|
||||
const dir = path.join(phasesDir, `${pp}-p${p}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const i of [1, 2]) {
|
||||
fs.writeFileSync(path.join(dir, `${pp}-0${i}-PLAN.md`), '# Plan\n');
|
||||
fs.writeFileSync(path.join(dir, `${pp}-0${i}-SUMMARY.md`), '# Summary\n');
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${pp}-VERIFICATION.md`),
|
||||
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// The drift: phase 1's summary touched after its verification. No git
|
||||
// repo → the #2348 clock compares mtimes; the newer summary mtime
|
||||
// routes phase 1's verification `stale` (verified via
|
||||
// `verification status` in the diagnosis repro).
|
||||
const older = new Date('2026-01-01T00:00:00Z');
|
||||
const newer = new Date('2026-03-01T00:00:00Z');
|
||||
fs.utimesSync(path.join(phasesDir, '01-p1', '01-VERIFICATION.md'), older, older);
|
||||
fs.utimesSync(path.join(phasesDir, '01-p1', '01-01-SUMMARY.md'), newer, newer);
|
||||
|
||||
return { planningDir };
|
||||
}
|
||||
|
||||
test('phaseCompleteIncrementsCompletedPhasesPastStaleSibling', () => {
|
||||
setupStaleSiblingProject(tmpDir);
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
|
||||
const r = runSdkQuery(['phase.complete', '3'], tmpDir);
|
||||
assert.ok(r.success, `phase complete 3 failed: ${r.error}`);
|
||||
|
||||
const state = fs.readFileSync(statePath, 'utf8');
|
||||
const fm = extractFrontmatter(state);
|
||||
assert.ok(fm.progress, 'progress block must exist after phase complete');
|
||||
assert.equal(
|
||||
Number(fm.progress.completed_phases),
|
||||
3,
|
||||
`#4129: completing phase 3 must increment completed_phases to the ROADMAP truth 3 (phases 1-3 Complete), got ${fm.progress.completed_phases}`,
|
||||
);
|
||||
assert.equal(
|
||||
Number(fm.progress.percent),
|
||||
17,
|
||||
`#4129: percent must follow the incremented counter (3/18), got ${fm.progress.percent}`,
|
||||
);
|
||||
// The body bar must stay coherent with the persisted percent (#4129 AC7).
|
||||
assert.equal(bodyProgressPercentFromState(state), 17, 'the body Progress bar must carry the same 17% as the frontmatter percent');
|
||||
// The ROADMAP row this very transaction flipped is the authority.
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf8');
|
||||
assert.ok(/^- \[x\] Phase 3: Gamma/m.test(roadmap), 'precondition: the transaction flipped the phase 3 ROADMAP checkbox');
|
||||
assert.ok(/^\| 3\.\s*\|\s*2\/2\s*\|\s*Complete\s*\|/m.test(roadmap), 'precondition: the transaction flipped the phase 3 table row');
|
||||
});
|
||||
|
||||
test('phaseCompleteIsIdempotentOnTheRoadmapTruth', () => {
|
||||
setupStaleSiblingProject(tmpDir);
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
|
||||
const r1 = runSdkQuery(['phase.complete', '3'], tmpDir);
|
||||
assert.ok(r1.success, `first call failed: ${r1.error}`);
|
||||
const r2 = runSdkQuery(['phase.complete', '3'], tmpDir);
|
||||
assert.ok(r2.success, `second call failed: ${r2.error}`);
|
||||
|
||||
const fm = extractFrontmatter(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.equal(Number(fm.progress.completed_phases), 3, '#4129: double-complete stays at the ROADMAP truth 3 (idempotent)');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ADR-3408 §8.3 Matrix A (#3469): cmdPhaseComplete now calls the ONE
|
||||
// write-seam composition (syncAndPreserveStateMd) directly instead of
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18163,6 +18163,237 @@ describe('#3871 / #3756: curated progress must survive a write on an archived mi
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// #4129: progress.completed_phases is recomputed to a WRONG value on every
|
||||
// resyncing state write, and hand-fixes never survive (.gsd/bug/
|
||||
// fix-4129-completed-phases-recompute/{10-diagnosis,50-test-matrix}.md).
|
||||
//
|
||||
// The repro shape (issue rows 1/4): a completed phase whose SUMMARY was
|
||||
// touched after its verification passed — a later reformat/re-run commit, or
|
||||
// any dirty working-tree edit — permanently stale-dates that phase's
|
||||
// verification under the #2348 clean-commit-time clock. isPhaseComplete
|
||||
// (#2957 disk-strict, correctly) refuses to count it, so
|
||||
// buildStateFrontmatter's disk numerator UNDER-counts vs the ROADMAP
|
||||
// Complete rows that `phase complete` itself maintains, and the resync arm
|
||||
// of applyPreserveAlways wholesale-replaces the stored block with the
|
||||
// under-count on every default-resync write (record-session / add-decision /
|
||||
// begin-phase / ...), clobbering any hand-corrected value.
|
||||
//
|
||||
// The fixture below is git-free: outside a repo the #2348 clock falls back
|
||||
// to filesystem mtimes, so a newer-mtime SUMMARY reproduces the exact stale
|
||||
// routing the real git clock produces (verified against the same
|
||||
// `verification status` CLI the reporter used).
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('#4129: completed_phases derives from the ROADMAP authority and survives resyncing writes', () => {
|
||||
/**
|
||||
* The #4129 STALE shape: milestone v1.0, 18 phases, phases 1-3 Complete in
|
||||
* the ROADMAP (canonical 4-column Progress table + checklist), each with
|
||||
* plans/summaries and a passing verification; phase 1's SUMMARY carries a
|
||||
* NEWER mtime than its verification → `verification status` routes `stale`
|
||||
* → disk numerator 2, ROADMAP truth 3. STATE.md starts at the truth (3).
|
||||
* `includeStoredProgress: false` writes STATE.md with NO stored progress
|
||||
* block, so the reported counters are exactly what the derivation computes
|
||||
* (the read-path ratchet has no stored block to lean on).
|
||||
*/
|
||||
function buildStaleVerificationFixture(cwd, initialCompleted = 3, includeStoredProgress = true) {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(phasesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'REPRO' }));
|
||||
|
||||
const roadmapLines = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v1.0',
|
||||
'',
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|----------------|--------|-----------|',
|
||||
'| 1. | 2/2 | Complete | 2026-01-01 |',
|
||||
'| 2. | 2/2 | Complete | 2026-01-02 |',
|
||||
'| 3. | 2/2 | Complete | 2026-01-03 |',
|
||||
];
|
||||
for (let i = 4; i <= 18; i += 1) roadmapLines.push(`| ${i}. | 0/2 | Not Started | |`);
|
||||
roadmapLines.push('', '- [x] Phase 1: Alpha (completed 2026-01-01)', '- [x] Phase 2: Beta (completed 2026-01-02)', '- [x] Phase 3: Gamma (completed 2026-01-03)');
|
||||
for (let i = 4; i <= 18; i += 1) roadmapLines.push(`- [ ] Phase ${i}: P${i}`);
|
||||
for (let i = 1; i <= 18; i += 1) {
|
||||
roadmapLines.push('', `### Phase ${i}: P${i}`, '', '**Goal:** goal', '**Plans:** 2 plans', '');
|
||||
}
|
||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmapLines.join('\n'));
|
||||
|
||||
const stateLines = [
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v1.0',
|
||||
'milestone_name: Programme',
|
||||
'status: executing',
|
||||
'current_phase: 4',
|
||||
'last_updated: 2026-01-03T10:00:00.000Z',
|
||||
];
|
||||
if (includeStoredProgress) {
|
||||
stateLines.push(
|
||||
'progress:',
|
||||
' total_phases: 18',
|
||||
` completed_phases: ${initialCompleted}`,
|
||||
' total_plans: 6',
|
||||
' completed_plans: 6',
|
||||
' percent: 17',
|
||||
);
|
||||
}
|
||||
stateLines.push(
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 4 of 18 (P4) — EXECUTING',
|
||||
'Plan: 1 of 2',
|
||||
'Status: Executing Phase 4',
|
||||
'Last activity: 2026-01-03',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
`Progress: [██░░░░░░░░] 17% (${initialCompleted}/18 phases complete)`,
|
||||
'',
|
||||
'## Session',
|
||||
'',
|
||||
'Last session: 2026-01-03T10:00:00.000Z',
|
||||
'Stopped at: Finished phase 3',
|
||||
'Resume file: None',
|
||||
'',
|
||||
);
|
||||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateLines.join('\n'));
|
||||
|
||||
for (const p of [1, 2, 3]) {
|
||||
const pp = String(p).padStart(2, '0');
|
||||
const dir = path.join(phasesDir, `${pp}-p${p}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const i of [1, 2]) {
|
||||
fs.writeFileSync(path.join(dir, `${pp}-0${i}-PLAN.md`), '# Plan\n');
|
||||
fs.writeFileSync(path.join(dir, `${pp}-0${i}-SUMMARY.md`), '# Summary\n');
|
||||
}
|
||||
writePassedVerification(cwd, `${pp}-p${p}`, pp);
|
||||
}
|
||||
|
||||
// The drift: phase 1's summary edited AFTER the verification was written.
|
||||
// No git repo → the #2348 clock compares mtimes; the newer summary mtime
|
||||
// routes the phase-1 verification `stale` exactly as a later commit would.
|
||||
const verificationPath = path.join(phasesDir, '01-p1', '01-VERIFICATION.md');
|
||||
const driftedSummary = path.join(phasesDir, '01-p1', '01-01-SUMMARY.md');
|
||||
const older = new Date('2026-01-01T00:00:00Z');
|
||||
const newer = new Date('2026-03-01T00:00:00Z');
|
||||
fs.utimesSync(verificationPath, older, older);
|
||||
fs.utimesSync(driftedSummary, newer, newer);
|
||||
|
||||
return { planningDir, phasesDir };
|
||||
}
|
||||
|
||||
function readProgress(cwd) {
|
||||
const content = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf-8');
|
||||
const fm = frontmatterLib.extractFrontmatter(content);
|
||||
assert.ok(fm && fm.progress, 'STATE.md frontmatter must carry a progress block');
|
||||
return { progress: fm.progress, content };
|
||||
}
|
||||
|
||||
// Row 1 of the 50-test-matrix — the failing-first regression. On current
|
||||
// `next` each of these verbs clobbers the stored 3 down to the
|
||||
// stale-verification disk count 2 (percent 17 → 11), which is the issue's
|
||||
// "any hand-correction is silently reverted by the next one".
|
||||
test('handFixedCompletedPhasesSurvivesEveryResyncingWrite', (t) => {
|
||||
const cwd = createTempDir('gsd-4129-handfix-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildStaleVerificationFixture(cwd, 3);
|
||||
|
||||
// Precondition — the drift is really in place: phase 1 routes stale, so
|
||||
// the disk numerator is 2 while ROADMAP + stored say 3.
|
||||
const staleCheck = runGsdTools(['verification', 'status', path.join('.planning', 'phases', '01-p1')], cwd);
|
||||
assert.ok(staleCheck.success, `verification status failed: ${staleCheck.error}`);
|
||||
assert.strictEqual(JSON.parse(staleCheck.output).status, 'stale', 'fixture precondition: phase 1 verification must route stale (newer summary)');
|
||||
|
||||
for (const [label, args] of [
|
||||
['state record-session', ['state', 'record-session', '--stopped-at', 'Finished phase 3 verification']],
|
||||
['state add-decision', ['state', 'add-decision', '--summary', 'Ship it']],
|
||||
['state begin-phase', ['state', 'begin-phase', '--phase', '4', '--name', 'P4']],
|
||||
]) {
|
||||
const result = runGsdTools(args, cwd);
|
||||
assert.ok(result.success, `${label} failed: ${result.error}`);
|
||||
const { progress, content } = readProgress(cwd);
|
||||
assert.strictEqual(
|
||||
Number(progress.completed_phases),
|
||||
3,
|
||||
`#4129: ${label} must not clobber the ROADMAP-correct completed_phases 3 down to the stale-verification disk count (${progress.completed_phases})`,
|
||||
);
|
||||
assert.strictEqual(Number(progress.percent), 17, `#4129: ${label} — percent follows the surviving counters (3/18), not the discarded disk count`);
|
||||
assert.strictEqual(bodyProgressPercent(content), 17, `#4129: ${label} — the body Progress bar must stay coherent with the persisted percent`);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 2 — the DERIVED block itself must carry the ROADMAP-floored numerator.
|
||||
// state json applies the read-path ratchet (shouldPreserveExistingProgress),
|
||||
// which would mask a still-wrong derivation whenever a stored block exists —
|
||||
// so this asserts on a STATE.md whose stored block is ABSENT: what json
|
||||
// reports is then exactly what buildStateFrontmatter derived.
|
||||
test('stateJsonDerivesCompletedPhasesFromRoadmapAuthority', (t) => {
|
||||
const cwd = createTempDir('gsd-4129-jsonfloor-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildStaleVerificationFixture(cwd, 3, false);
|
||||
|
||||
const result = runGsdTools(['state', 'json'], cwd);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const reported = JSON.parse(result.output).progress;
|
||||
assert.strictEqual(
|
||||
Number(reported.completed_phases),
|
||||
3,
|
||||
`#4129: the derived completed_phases must agree with the ROADMAP Complete rows (3), not the stale-verification disk count (${reported.completed_phases})`,
|
||||
);
|
||||
assert.strictEqual(Number(reported.percent), 17, '#4129: percent derives from the floored numerator');
|
||||
});
|
||||
|
||||
// Row 3 — the flip side: a counter STUCK LOW (the issue's row-1 aftermath)
|
||||
// must move UP to the ROADMAP truth on the next write, not stay pinned.
|
||||
test('stuckLowCounterIncrementsToRoadmapTruthOnNextWrite', (t) => {
|
||||
const cwd = createTempDir('gsd-4129-stucklow-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildStaleVerificationFixture(cwd, 2);
|
||||
|
||||
const result = runGsdTools(['state', 'record-session', '--stopped-at', 'x'], cwd);
|
||||
assert.ok(result.success, `record-session failed: ${result.error}`);
|
||||
const { progress } = readProgress(cwd);
|
||||
assert.strictEqual(Number(progress.completed_phases), 3, '#4129: a stored 2 below the ROADMAP truth must rise to 3, not be re-derived as 2');
|
||||
assert.strictEqual(Number(progress.percent), 17, '#4129: percent follows the corrected numerator');
|
||||
});
|
||||
|
||||
// Row 5 — negative space: no canonical Progress table (checklist-only
|
||||
// ROADMAP) → deriveProgressFromRoadmap resolves no table → the floor is
|
||||
// inert and the disk-verification count stands. The floor must not invent
|
||||
// a parser for checklist bullets (one-owner rule).
|
||||
test('floorIsInertWithoutCanonicalProgressTable', (t) => {
|
||||
const cwd = createTempDir('gsd-4129-notable-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildStaleVerificationFixture(cwd, 2, false);
|
||||
|
||||
// Rewrite the ROADMAP with the table stripped — checklist only.
|
||||
// CRLF-tolerant line split (local/no-crlf-fragile-split).
|
||||
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
||||
const withoutTable = fs
|
||||
.readFileSync(roadmapPath, 'utf-8')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !line.trimStart().startsWith('|'))
|
||||
.join('\n');
|
||||
fs.writeFileSync(roadmapPath, withoutTable);
|
||||
|
||||
const result = runGsdTools(['state', 'json'], cwd);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const reported = JSON.parse(result.output).progress;
|
||||
assert.strictEqual(
|
||||
Number(reported.completed_phases),
|
||||
2,
|
||||
'#4129 negative space: without a canonical Progress table the completed count stays the disk-verification count (the floor reuses deriveProgressFromRoadmap, which reads only the table)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// #3872 / ADR-3473 §8.7: what a command reports it wrote
|
||||
// (.gsd/phase/feat-3872-transaction-diff-reporting/{40-design,50-test-matrix}.md)
|
||||
|
||||
Reference in New Issue
Block a user