* fix(#1761): skip conflated progress in state json read-path when milestone unbounded ADR-1769 Phase 7 (#1794) closed the state sync WRITE path — when a milestone version is asserted in frontmatter but the ROADMAP has no versioned heading for it, sync leaves Progress untouched. But the state json READ path rebuilds progress via buildStateFrontmatter, whose roadmapPhaseCount loop counts phase headings across the WHOLE document when extractCurrentMilestone can't bound the milestone. state json therefore reported a conflated total_phases (sum of sibling milestones) + a derived percent — exactly the value the sync guard was added to prevent. (Repro from the issue: total_phases 8 = 4+4, percent 13.) Mirror the cmdStateSync guard inside buildStateFrontmatter: when the asserted milestone cannot be bounded to a versioned ROADMAP heading (the same versionedHeading test the sync path uses), fall back to the on-disk phase-dir count for total_phases and skip percent. Bounded milestones (versioned ROADMAP, or no milestone asserted) are unchanged. The signal rides on the existing _diskScanCache (new milestoneBounded field) so neither extractCurrentMilestone's return contract nor its other callers change. Regression: extend tests/bug-1761-state-sync-wrong-progress.test.cjs with the read-path case (unbounded → no percent, no conflated total_phases) and a bounded control (versioned ROADMAP → unchanged percent + total_phases). * docs(#1761): add changeset fragment for state json read-path fix
This commit is contained in:
@@ -144,6 +144,7 @@ const _diskScanCache = new Map<string, {
|
||||
completedPhases: number;
|
||||
totalPlans: number;
|
||||
completedPlans: number;
|
||||
milestoneBounded: boolean;
|
||||
}>();
|
||||
|
||||
// Track all lock files held by this process so they can be removed on exit.
|
||||
@@ -1359,6 +1360,9 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
let completedPhases: number | null = null;
|
||||
let totalPlans: number | null = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
||||
let completedPlans: number | null = null;
|
||||
// #1761 read-path: set from cached.milestoneBounded inside the disk-scan
|
||||
// block; consumed at the percent computation to mirror the cmdStateSync guard.
|
||||
let milestoneUnbounded = false;
|
||||
|
||||
if (cwd) {
|
||||
try {
|
||||
@@ -1373,10 +1377,11 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
// exclusion (#1514). Computed before the disk scan so retired phases
|
||||
// can be dropped from the dir set too.
|
||||
let roadmapScope: string | null = null;
|
||||
let roadmapRaw: string | null = null;
|
||||
let retiredPhaseNums = new Set<string>();
|
||||
try {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw !== null) {
|
||||
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
|
||||
@@ -1449,20 +1454,39 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
}
|
||||
}
|
||||
|
||||
cached = {
|
||||
totalPhases: roadmapPhaseCount > 0
|
||||
? Math.max(phaseDirs.length, roadmapPhaseCount)
|
||||
: phaseDirs.length,
|
||||
completedPhases: diskCompletedPhases,
|
||||
totalPlans: diskTotalPlans,
|
||||
completedPlans: diskTotalSummaries,
|
||||
};
|
||||
cached = (() => {
|
||||
// #1761 read-path: mirror the cmdStateSync guard (#1794). When the
|
||||
// asserted milestone version can't be bounded to a versioned ROADMAP
|
||||
// heading, extractCurrentMilestone falls back to the whole document
|
||||
// and roadmapPhaseCount conflates sibling milestones. In that case
|
||||
// don't substitute the whole-doc count — fall back to the on-disk
|
||||
// phase-dir count only, and mark unbounded so percent is skipped
|
||||
// downstream (mirrors the sync write-path guard).
|
||||
let milestoneBounded = true;
|
||||
if (milestone && roadmapRaw !== null) {
|
||||
const versionedHeading = new RegExp(
|
||||
`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`,
|
||||
'mi',
|
||||
);
|
||||
milestoneBounded = versionedHeading.test(roadmapRaw);
|
||||
}
|
||||
return {
|
||||
totalPhases: (!milestoneBounded || roadmapPhaseCount === 0)
|
||||
? phaseDirs.length
|
||||
: Math.max(phaseDirs.length, roadmapPhaseCount),
|
||||
milestoneBounded,
|
||||
completedPhases: diskCompletedPhases,
|
||||
totalPlans: diskTotalPlans,
|
||||
completedPlans: diskTotalSummaries,
|
||||
};
|
||||
})();
|
||||
_diskScanCache.set(cwd, cached);
|
||||
}
|
||||
totalPhases = cached.totalPhases;
|
||||
completedPhases = cached.completedPhases;
|
||||
totalPlans = cached.totalPlans;
|
||||
completedPlans = cached.completedPlans;
|
||||
milestoneUnbounded = cached.milestoneBounded === false;
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
@@ -1473,7 +1497,10 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
// instead of a false 100% from plan-only coverage (#3242 Bug B).
|
||||
// Falls back to the body Progress: field only when no plan files exist on disk.
|
||||
let progressPercent = computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases);
|
||||
if (progressPercent === null && progressRaw) {
|
||||
// #1761 read-path: when the milestone can't be bounded, percent would be
|
||||
// derived from a conflated/understated total — skip it (mirror cmdStateSync).
|
||||
if (milestoneUnbounded) progressPercent = null;
|
||||
if (progressPercent === null && progressRaw && !milestoneUnbounded) {
|
||||
const pctMatch = progressRaw.match(/(\d+)%/);
|
||||
if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user