fix(#4094): withhold all four STATE.md progress counters under the milestone-unbounded guard (#4322)
* test(#4094): failing-first matrix for withholding all four progress counters * fix(#4094): withhold all four progress counters under the milestone-unbounded guard completed_phases/total_plans/completed_plans are accumulated from the same phaseDirs walk as total_phases, so the #3354/#3573 withhold condition makes them equally untrustworthy — yet only total_phases was withheld, and every resyncing state.* write silently clobbered the three stored siblings with the under-scoped disk numbers. Extend the withhold-then-fall-back-to-stored pattern to all three siblings: null sentinels in the disk-scan cache value, three new stored-counter readers threaded through all three buildStateFrontmatter call sites, and the same cached-else-stored consumer fallback. Milestone-bounded projects are untouched (gate-conditional). * fix(#4094): scope-requires for the new test block, keep the (#3573) warning token, and update two #3578 rows to the withheld-counter contract - the #4094 describe sat after the closing brace of the section that owned the module-level beforeEach destructure, so it needs its own local requires (mirroring the #3642 block); - the #3573 warning keeps its literal '(#3573)' tag (asserted by an existing test) with '#4094' appended as a separate token; - two #3578 status-guard rows in tests/state.test.cjs asserted the pre-#4094 unconditional disk-scan assignment of completed_phases under the roadmap-absent withhold — exactly the silent clobber #4094 removes; the status-guard conclusion (must not fire) is unchanged, the counter-value assertions now pin the withheld contract. * test(#4094): lint conformance — splitLines for the persisted-progress parser, local seeder, scoped rmSync disable * changeset(#4094) * changeset(#4094): backfill PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
101
src/state.cts
101
src/state.cts
@@ -331,9 +331,12 @@ const _diskScanCache = new Map<string, {
|
||||
// ROADMAP total, so the caller must keep the pre-existing value (stored
|
||||
// frontmatter, body annotation) or omit the key. Never a scan result.
|
||||
totalPhases: number | null;
|
||||
completedPhases: number;
|
||||
totalPlans: number;
|
||||
completedPlans: number;
|
||||
// #4094: the three sibling counters carry the same null WITHHOLD sentinel
|
||||
// under the same condition — they come from the identical phaseDirs walk,
|
||||
// so when the walk's scope is untrustworthy they are withheld together.
|
||||
completedPhases: number | null;
|
||||
totalPlans: number | null;
|
||||
completedPlans: number | null;
|
||||
milestoneBounded: boolean;
|
||||
// #3217 (ADR-3180 §7.6 rule 4, finding 1): the real `listMilestonePhaseDirs`
|
||||
// scope for `allMatchingDirs` below, threaded through the cache so the
|
||||
@@ -1385,7 +1388,7 @@ function computeUpdateProgressPreview(statePath: string, cwd: string): UpdatePro
|
||||
const existingFm = extractFrontmatter(preContent, statePath) as Record<string, unknown>;
|
||||
const preBody = stripFrontmatter(preContent);
|
||||
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
||||
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
||||
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
||||
const progress = builtFm['progress'] as Record<string, unknown> | undefined;
|
||||
const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
|
||||
const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
|
||||
@@ -2688,7 +2691,20 @@ function countRoadmapPhaseHeadings(
|
||||
* a YAML frontmatter object. Allows hooks and scripts to read state
|
||||
* reliably via `state json` instead of fragile regex parsing.
|
||||
*/
|
||||
function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, storedMilestone?: string | null, storedTotalPhases?: number | null): Record<string, unknown> {
|
||||
function buildStateFrontmatter(
|
||||
bodyContent: string,
|
||||
cwd: string | undefined,
|
||||
storedMilestone?: string | null,
|
||||
storedTotalPhases?: number | null,
|
||||
// #4094: the stored siblings of storedTotalPhases, threaded from each call
|
||||
// site exactly the same way — see readStoredProgressCounter below. Under the
|
||||
// #3354/#3573 withhold condition the disk scan returns null for all four
|
||||
// counters, and these stored values are what the progress block falls back
|
||||
// to (else the keys are omitted).
|
||||
storedCompletedPhases?: number | null,
|
||||
storedTotalPlans?: number | null,
|
||||
storedCompletedPlans?: number | null,
|
||||
): Record<string, unknown> {
|
||||
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
|
||||
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
|
||||
// below). Phase canonically lives in ## Current Position (templates/state.md);
|
||||
@@ -2965,7 +2981,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
|
||||
if (milestonedButUnbounded) {
|
||||
process.stderr.write(
|
||||
`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354/#3642)\n`
|
||||
`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3354/#3642/#4094)\n`
|
||||
);
|
||||
}
|
||||
// #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
|
||||
@@ -2985,22 +3001,32 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
storedMilestone.trim() !== '';
|
||||
if (roadmapAbsentWithAssertedMilestone) {
|
||||
process.stderr.write(
|
||||
`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3573)\n`
|
||||
`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3573) (#4094)\n`
|
||||
);
|
||||
}
|
||||
// #4094: the withhold condition covers ALL FOUR progress counters,
|
||||
// not just total_phases. completed_phases / total_plans /
|
||||
// completed_plans are accumulated from the exact same phaseDirs
|
||||
// walk as total_phases (same loop, same scope, same filters), so
|
||||
// whenever that walk's scope is known-untrustworthy — the exact
|
||||
// condition #3354 established — they are equally untrustworthy.
|
||||
// Pre-#4094 only totalPhases was nulled here, so every resyncing
|
||||
// write silently clobbered the three stored siblings with the
|
||||
// under-scoped disk numbers.
|
||||
const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone;
|
||||
return {
|
||||
// The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
|
||||
// roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
|
||||
// safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
|
||||
// vacuously true (its gate requires roadmapRaw), so the safe-count
|
||||
// arm would otherwise swallow the withhold.
|
||||
totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
|
||||
totalPhases: diskCountsWithheld
|
||||
? null
|
||||
: (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
|
||||
milestoneBounded,
|
||||
completedPhases: diskCompletedPhases,
|
||||
totalPlans: diskTotalPlans,
|
||||
completedPlans: diskTotalSummaries,
|
||||
completedPhases: diskCountsWithheld ? null : diskCompletedPhases,
|
||||
totalPlans: diskCountsWithheld ? null : diskTotalPlans,
|
||||
completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
|
||||
phaseDirScope,
|
||||
};
|
||||
})();
|
||||
@@ -3017,9 +3043,28 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
} else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
|
||||
totalPhases = storedTotalPhases;
|
||||
}
|
||||
completedPhases = cached.completedPhases;
|
||||
totalPlans = cached.totalPlans;
|
||||
completedPlans = cached.completedPlans;
|
||||
// #4094: the same withhold-then-fall-back-to-stored pattern for the
|
||||
// three sibling counters. They are derived from the identical
|
||||
// phaseDirs walk, so cached.* === null here means the SAME withheld
|
||||
// condition — keep the stored frontmatter value when the caller can
|
||||
// supply it; else leave null (key omitted). Note completedPhases /
|
||||
// completedPlans have NO body-annotation fallback (only the totals
|
||||
// have body annotations), so an unstored-withheld counter is omitted.
|
||||
if (cached.completedPhases !== null) {
|
||||
completedPhases = cached.completedPhases;
|
||||
} else if (storedCompletedPhases !== null && storedCompletedPhases !== undefined) {
|
||||
completedPhases = storedCompletedPhases;
|
||||
}
|
||||
if (cached.totalPlans !== null) {
|
||||
totalPlans = cached.totalPlans;
|
||||
} else if (storedTotalPlans !== null && storedTotalPlans !== undefined) {
|
||||
totalPlans = storedTotalPlans;
|
||||
}
|
||||
if (cached.completedPlans !== null) {
|
||||
completedPlans = cached.completedPlans;
|
||||
} else if (storedCompletedPlans !== null && storedCompletedPlans !== undefined) {
|
||||
completedPlans = storedCompletedPlans;
|
||||
}
|
||||
milestoneUnbounded = cached.milestoneBounded === false;
|
||||
diskScope = cached.phaseDirScope;
|
||||
}
|
||||
@@ -3318,16 +3363,38 @@ function readStateHeadFreshness(
|
||||
* instead of being clobbered by the on-disk phase-directory count.
|
||||
*/
|
||||
function readStoredTotalPhases(existingFm: Record<string, unknown> | null | undefined): number | null {
|
||||
return readStoredProgressCounter(existingFm, 'total_phases');
|
||||
}
|
||||
|
||||
/**
|
||||
* #4094: the three sibling readers of readStoredTotalPhases, one per progress
|
||||
* counter the #3354/#3573 withhold now protects. All four counters come from
|
||||
* the same disk-scan walk and are withheld together; these readers feed the
|
||||
* stored-value fallback for the three that previously had none.
|
||||
*/
|
||||
function readStoredProgressCounter(existingFm: Record<string, unknown> | null | undefined, key: string): number | null {
|
||||
if (!existingFm || typeof existingFm !== 'object') return null;
|
||||
const progress = existingFm['progress'];
|
||||
if (!progress || typeof progress !== 'object') return null;
|
||||
const raw = (progress as Record<string, unknown>)['total_phases'];
|
||||
const raw = (progress as Record<string, unknown>)[key];
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw === 'string' && raw.trim() === '') return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function readStoredCompletedPhases(existingFm: Record<string, unknown> | null | undefined): number | null {
|
||||
return readStoredProgressCounter(existingFm, 'completed_phases');
|
||||
}
|
||||
|
||||
function readStoredTotalPlans(existingFm: Record<string, unknown> | null | undefined): number | null {
|
||||
return readStoredProgressCounter(existingFm, 'total_plans');
|
||||
}
|
||||
|
||||
function readStoredCompletedPlans(existingFm: Record<string, unknown> | null | undefined): number | null {
|
||||
return readStoredProgressCounter(existingFm, 'completed_plans');
|
||||
}
|
||||
|
||||
function syncStateFrontmatter(
|
||||
content: string,
|
||||
cwd: string | undefined,
|
||||
@@ -3375,7 +3442,7 @@ function syncStateFrontmatter(
|
||||
// milestoned-but-unbounded withhold can preserve it across the write
|
||||
// (the derived progress sub-block replaces the stored one wholesale below,
|
||||
// so an omitted key would otherwise DELETE the stored value).
|
||||
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
||||
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
||||
|
||||
// Preserve existing frontmatter status when body-derived status is 'unknown'.
|
||||
// This prevents a missing Status: field in the body from overwriting a
|
||||
@@ -4750,7 +4817,7 @@ function cmdStateJson(cwd: string, raw: boolean): void {
|
||||
// reports the phase-directory count while the persisted file preserves the
|
||||
// stored total, exactly the write/read divergence #3354 closed for its shape.
|
||||
const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
||||
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
|
||||
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
||||
|
||||
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
|
||||
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
|
||||
|
||||
Reference in New Issue
Block a user