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:
125
src/state.cts
125
src/state.cts
@@ -69,6 +69,13 @@ import planDependencyGraphMod = require('./plan-dependency-graph.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import verificationMod = require('./verification.cjs');
|
||||
const { isPhaseComplete } = verificationMod;
|
||||
// #4129: the single owner of "count the ROADMAP's milestone Complete rows"
|
||||
// (phase-lifecycle.cts) — reused for the completed-phases numerator floor so
|
||||
// this scan cannot grow a second ROADMAP parser. Pure computation module (no
|
||||
// I/O), so it introduces no cycle on this path.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseLifecycleMod = require('./phase-lifecycle.cjs');
|
||||
const { deriveProgressFromRoadmap } = phaseLifecycleMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
@@ -3014,6 +3021,34 @@ function buildStateFrontmatter(
|
||||
// write silently clobbered the three stored siblings with the
|
||||
// under-scoped disk numbers.
|
||||
const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone;
|
||||
// #4129: floor the completed-phases numerator at the ROADMAP's own
|
||||
// milestone Complete-row count. The disk numerator counts ONLY
|
||||
// phase dirs whose *-VERIFICATION.md routes `passed` (isPhaseComplete,
|
||||
// #2957 disk-strict — the gate stays untouched), so a completed
|
||||
// phase whose verification reads `stale` (a SUMMARY committed or
|
||||
// edited after it, #2348 clean-commit-time clock) or `missing`
|
||||
// (pre-verification era, hand-flipped ROADMAP row) drops out of the
|
||||
// count forever — while every other surface (the ROADMAP row
|
||||
// `phase complete` just flipped, the body `Completed Phases` field
|
||||
// completePhaseCore derives from deriveProgressFromRoadmap) still
|
||||
// asserts the phase complete. max(disk, ROADMAP) keeps the disk
|
||||
// signal for gap detection (a verification-passed phase whose ROADMAP
|
||||
// row is not yet flipped still counts) while never UNDER-counting
|
||||
// what the ROADMAP asserts. Scoped exactly like the denominator:
|
||||
// the same milestone window (roadmapScope), the same
|
||||
// safeToUseRoadmapCount gate, and never under the #3354/#3573
|
||||
// withhold — a whole-document Complete-row count must not leak
|
||||
// through an untrustworthy scope. Reuses deriveProgressFromRoadmap
|
||||
// (phase-lifecycle.cts, the one owner of "read the Progress table")
|
||||
// — no second ROADMAP parser here. A ROADMAP without a canonical
|
||||
// `## Progress` table resolves no table → floor inert (disk count
|
||||
// stands), the owner's own answer to "what is countable".
|
||||
const roadmapCompletedPhases = roadmapScope !== null && safeToUseRoadmapCount && !diskCountsWithheld
|
||||
? deriveProgressFromRoadmap(roadmapScope).completedPhases
|
||||
: null;
|
||||
const flooredCompletedPhases = roadmapCompletedPhases !== null
|
||||
? Math.max(diskCompletedPhases, roadmapCompletedPhases)
|
||||
: diskCompletedPhases;
|
||||
return {
|
||||
// The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
|
||||
// roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
|
||||
@@ -3024,7 +3059,7 @@ function buildStateFrontmatter(
|
||||
? null
|
||||
: (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
|
||||
milestoneBounded,
|
||||
completedPhases: diskCountsWithheld ? null : diskCompletedPhases,
|
||||
completedPhases: diskCountsWithheld ? null : flooredCompletedPhases,
|
||||
totalPlans: diskCountsWithheld ? null : diskTotalPlans,
|
||||
completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
|
||||
phaseDirScope,
|
||||
@@ -3395,6 +3430,76 @@ function readStoredCompletedPlans(existingFm: Record<string, unknown> | null | u
|
||||
return readStoredProgressCounter(existingFm, 'completed_plans');
|
||||
}
|
||||
|
||||
/**
|
||||
* #4129: is this authoritativeFm value a PARTIAL progress intent? The #2736
|
||||
* seam was string-only (names); #4129 extends it with one object direction —
|
||||
* the `progress` key carrying the sub-keys a transition resolved
|
||||
* authoritatively (completePhase's ROADMAP-derived completed_phases/percent).
|
||||
* Anything else keeps the seam's existing contract untouched.
|
||||
*/
|
||||
function isPartialProgressIntent(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* #4129: merge a PARTIAL progress intent (see isPartialProgressIntent) into a
|
||||
* frontmatter object's `progress` block. Sub-keys are accepted only when they
|
||||
* are a declared `progress.*` row in FIELD_CLASSIFICATION — the single policy
|
||||
* source (ADR-3408 §8.5) decides which leaves exist; an intent may not invent
|
||||
* one. Returns whether anything changed.
|
||||
*
|
||||
* `completedOnlyRaise` (both application sites use it): completed counters
|
||||
* apply only when strictly greater than what is already in the block, so no
|
||||
* intent can LOWER a count another trustworthy signal already established —
|
||||
* at the pre-preservation site the disk derivation's own count (a
|
||||
* verification-passed phase whose ROADMAP row drifted behind), at the
|
||||
* post-preservation re-assert the #2969 monotonic property preservation just
|
||||
* enforced. `percent` follows its sibling: it is applied when a completed
|
||||
* counter moved this call (the intent percent was computed from the intent
|
||||
* counters and is coherent with them) or when the block has no percent to
|
||||
* lose (a repair, never a regression of an upstream withhold — the withhold
|
||||
* nulled percent upstream precisely so no write would re-assert one over
|
||||
* untrustworthy counts; here the intent's own counts ARE the trustworthy
|
||||
* source, the post-completion ROADMAP).
|
||||
*/
|
||||
function applyAuthoritativeProgressSubkeys(
|
||||
fm: Record<string, unknown>,
|
||||
intent: Record<string, unknown>,
|
||||
opts: { completedOnlyRaise: boolean },
|
||||
): boolean {
|
||||
const current = fm['progress'];
|
||||
const base: Record<string, unknown> = isPartialProgressIntent(current)
|
||||
? { ...current }
|
||||
: {};
|
||||
let changed = false;
|
||||
let completedMoved = false;
|
||||
for (const [subkey, value] of Object.entries(intent)) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
|
||||
if (!getFieldClassification(`progress.${subkey}`)) continue;
|
||||
const isCompletedCounter = subkey === 'completed_phases' || subkey === 'completed_plans';
|
||||
if (isCompletedCounter && opts.completedOnlyRaise) {
|
||||
const currentNum = toFiniteNumber(base[subkey]);
|
||||
if (currentNum !== null && currentNum >= value) continue;
|
||||
}
|
||||
if (isCompletedCounter && !Object.is(base[subkey], value)) completedMoved = true;
|
||||
if (!Object.is(base[subkey], value)) {
|
||||
base[subkey] = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// percent: applied only when a completed counter moved (coherent with the
|
||||
// counters that just landed) or when no percent exists to contradict.
|
||||
const intentPercent = intent['percent'];
|
||||
if (typeof intentPercent === 'number' && Number.isFinite(intentPercent) && (completedMoved || toFiniteNumber(base['percent']) === null)) {
|
||||
if (!Object.is(base['percent'], intentPercent)) {
|
||||
base['percent'] = intentPercent;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) fm['progress'] = base;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function syncStateFrontmatter(
|
||||
content: string,
|
||||
cwd: string | undefined,
|
||||
@@ -3620,10 +3725,20 @@ function syncStateFrontmatter(
|
||||
// parenthetical (`Closer-ruling measurement (D1a)` → `D1a`) — never runs
|
||||
// the final word on a field the transition just resolved. The prose parser
|
||||
// remains the fallback for genuinely unknown prose only.
|
||||
// #4129: the `progress` key carries a PARTIAL block (the object direction of
|
||||
// this seam — see applyAuthoritativeProgressSubkeys) for the same reason:
|
||||
// completePhase holds the POST-completion ROADMAP, and the disk scan this
|
||||
// function drives reads the PRE-completion one. The intent is applied as a
|
||||
// FLOOR here too (completedOnlyRaise): a derivation that already counted
|
||||
// MORE completed phases than the ROADMAP table asserts (verification-passed
|
||||
// phases whose table rows drifted behind) must not be lowered by the intent
|
||||
// — the two signals agree on direction (up), never on subtraction.
|
||||
if (authoritativeFm) {
|
||||
for (const [key, value] of Object.entries(authoritativeFm)) {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
derivedFm[key] = value;
|
||||
} else if (key === 'progress' && isPartialProgressIntent(value)) {
|
||||
applyAuthoritativeProgressSubkeys(derivedFm, value, { completedOnlyRaise: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4194,12 +4309,20 @@ function applyPostSyncPreservation(
|
||||
// (equal), so the #1695 restore fires and would put the stale pre-transition
|
||||
// name back over the authoritative one. Intent beats both the prose
|
||||
// re-derivation and the curated restore — the transition just resolved it.
|
||||
// #4129: for the `progress` key the re-assert is a FLOOR, not an override —
|
||||
// the #2969 monotonic property preservation just enforced (completed
|
||||
// counters never move down) must not be undone by the intent, so completed
|
||||
// sub-keys apply only-raise here (see applyAuthoritativeProgressSubkeys).
|
||||
let authoritativeReasserted = false;
|
||||
if (authoritativeFm) {
|
||||
for (const [key, value] of Object.entries(authoritativeFm)) {
|
||||
if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
|
||||
preservation.postFm[key] = value;
|
||||
authoritativeReasserted = true;
|
||||
} else if (key === 'progress' && isPartialProgressIntent(value)) {
|
||||
if (applyAuthoritativeProgressSubkeys(preservation.postFm, value, { completedOnlyRaise: true })) {
|
||||
authoritativeReasserted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user