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:
@@ -59,6 +59,12 @@ const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phas
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
||||
import roadmapParserMod = require('./roadmap-parser.cjs');
|
||||
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
|
||||
// #4129: the single owner of "count the ROADMAP's milestone Complete rows"
|
||||
// (pure computation, no I/O — no cycle on this path) for the intent-first
|
||||
// progress counters the phase-complete transaction passes downstream.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
|
||||
import phaseLifecycleMod = require('./phase-lifecycle.cjs');
|
||||
const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||||
@@ -4395,14 +4401,57 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
const bodyHasPhaseField =
|
||||
stateExtractField(fmBody, 'Current Phase') != null ||
|
||||
stateExtractField(fmBody, 'Phase') != null;
|
||||
const authoritativeFm: Record<string, string> | undefined = nextPhaseDisplayName
|
||||
? bodyHasPhaseField || !nextPhaseNum
|
||||
? { current_phase_name: nextPhaseDisplayName }
|
||||
: {
|
||||
current_phase: String(nextPhaseNum),
|
||||
current_phase_name: nextPhaseDisplayName,
|
||||
}
|
||||
: undefined;
|
||||
// #4129: the POST-completion progress counters, derived from the very
|
||||
// ROADMAP this transaction just mutated (still in memory — it hits disk
|
||||
// only at writePlanningFileSet, AFTER this content was assembled).
|
||||
// buildStateFrontmatter's disk scan inside syncAndPreserveStateMd
|
||||
// reads the PRE-completion ROADMAP (and any stale-dated sibling
|
||||
// verification), so without this intent the persisted counter failed
|
||||
// to increment on the completing phase's own transaction. Routed
|
||||
// through the #2736 authoritativeFm seam's object direction: the
|
||||
// pre-preservation merge makes it the derived truth the ratchet
|
||||
// compares, and the post-preservation re-assert (completedOnlyRaise)
|
||||
// is a floor no preservation branch can drop below. clampPercent is
|
||||
// completePhaseCore's own percent formula (state-transition.cts),
|
||||
// reused so the frontmatter and the body `Progress:` line agree.
|
||||
const postCompletionRoadmapScope = roadmapContent !== null
|
||||
? extractCurrentMilestone(roadmapContent, cwd)
|
||||
: null;
|
||||
const postCompletionRoadmapProgress = postCompletionRoadmapScope !== null
|
||||
? deriveProgressFromRoadmapForIntent(postCompletionRoadmapScope)
|
||||
: null;
|
||||
const authoritativeProgress: Record<string, number> | undefined =
|
||||
postCompletionRoadmapProgress && postCompletionRoadmapProgress.completedPhases !== null
|
||||
? postCompletionRoadmapProgress.totalPhases !== null && postCompletionRoadmapProgress.totalPhases > 0
|
||||
? {
|
||||
completed_phases: postCompletionRoadmapProgress.completedPhases,
|
||||
percent: clampPercentForIntent(
|
||||
postCompletionRoadmapProgress.completedPhases,
|
||||
postCompletionRoadmapProgress.totalPhases,
|
||||
),
|
||||
}
|
||||
: { completed_phases: postCompletionRoadmapProgress.completedPhases }
|
||||
: undefined;
|
||||
const authoritativeFm: Record<string, unknown> | undefined = authoritativeProgress
|
||||
? {
|
||||
...(nextPhaseDisplayName
|
||||
? bodyHasPhaseField || !nextPhaseNum
|
||||
? { current_phase_name: nextPhaseDisplayName }
|
||||
: {
|
||||
current_phase: String(nextPhaseNum),
|
||||
current_phase_name: nextPhaseDisplayName,
|
||||
}
|
||||
: {}),
|
||||
progress: authoritativeProgress,
|
||||
}
|
||||
: nextPhaseDisplayName
|
||||
? bodyHasPhaseField || !nextPhaseNum
|
||||
? { current_phase_name: nextPhaseDisplayName }
|
||||
: {
|
||||
current_phase: String(nextPhaseNum),
|
||||
current_phase_name: nextPhaseDisplayName,
|
||||
}
|
||||
: undefined;
|
||||
// ADR-3408 §8.3 / #3469: this deliberately bypasses
|
||||
// readModifyWriteStateMd (STATE.md is committed atomically with
|
||||
// ROADMAP/REQUIREMENTS), so it calls the single write-seam
|
||||
|
||||
Reference in New Issue
Block a user