* wip(#3217): rule-4 scope withholding — parked, two open findings Implemented but NOT shippable. An isolated review found buildStateFrontmatter still hardcodes SCOPE.COMPLETE, so state json reports percent 0 where roadmap analyze, stats and query progress all correctly report null on the same disk state - rule 4 reintroduced at a site this phase claims to close. Also: roadmap analyze emits scope complete beside progress_percent null with nothing explaining it. Parked to build Phase 4 (#3186) first, which is unblocked. Findings recorded in .gsd/phase/refactor-3217-completion-ratio-scoping/60-review.json. * fix(#3217): withhold the sync percentage on a non-complete scope The parked blocker is fixed - buildStateFrontmatter no longer hardcodes SCOPE.COMPLETE, and the prose Progress fallback is gated too, which was a second leak found while tracing the first. roadmap analyze exposes progress_scope so a consumer can tell WHY a percentage is absent from the JSON alone. Then a residual gap was reproduced rather than assumed. cmdStateSync carried the same hardcode behind a written reason claiming it did not reproduce. It did: on a TRUNCATED window and on UNSCOPED row 4, state sync wrote Progress 0 percent to 100 percent while state json, roadmap analyze, stats and query progress all withheld - and it persisted a self-contradictory file, body claiming 100 percent while its own frontmatter correctly omitted percent. The excuse was also wrong. syncRoadmapRaw is already parsed in that function and is exactly what produces a real scope, so there was a scope to pass. Threaded through listMilestonePhaseDirs; a non-complete scope now skips the write with a reason in changes. milestoneBounded stays as the orthogonal 1761 guard for row 5. Second time this epic a does-not-reproduce claim was too generous. Recorded in ADR Amendment 8 as a correction rather than a quiet rewrite. Verified on the remote runner. * test(#3217): give the withholding fixtures a resolvable scope 40 matrix failures, all fixture drift - no code regression. My own hypothesis that this was over-withholding was wrong and is recorded as such: the worry case, a plain ROADMAP with Phase entries and no version heading, resolves to complete exactly as ADR 7.1 says it should. The real causes were two fixture shapes. Most had no ROADMAP.md at all, which is unreadable via a pre-existing graceful path, and asserted a numeric percent. The five vscode, pi-extension, mcp-server and shell-projection failures were that shape - bare temp dirs using progress json as a reachability proxy while asserting typeof percent is number, which under rule 4 is now null. The rest had a version token in a title or heading with no STATE.md milestone pointer to resolve it, which is classification row 4, versioned but unresolved, so withholding is correct per the contract. Verified on the remote runner. * test(#3217): make the LM-tools reachability tests dispatch against their fixture The gsd_progress reachability test was never testing its fixture. invoke() resolves cwd from vscode.workspace.workspaceFolders by design (the real LanguageModelToolInvocationOptions has no cwd field, per the 2103 fix in extension.js), the mock had no workspace at all, and the test passed a cwd option nothing reads - so it dispatched against the repo working directory. Writing a ROADMAP into the temp dir had no effect. Rule 4 only made it visible. Fixed by mocking workspaceFolders. The two siblings in the same file carried the identical dead cwd and were dispatching against the repo too; they were not failing only because their assertions did not touch scope-dependent output. Both now use their own fixture with assertions unchanged - the no-planning fallback paths already satisfy them honestly. Re-scanned the other five reachability files: no further instances. They thread cwd into parameters that genuinely read it, not through an options shape that ignores it. Verified on the remote runner. * chore(#3217): backfill changeset PR number pr:0 placeholder replaced with the real number now that #3318 exists. * ci(#3217): give the coverage merge enough heap for the merged shards The coverage gate OOMed at exit 134. c8 report merges three shard artifacts, roughly 358MB of V8 dumps in coverage/tmp, and died holding their per-file position maps at the ~4GB default heap. Verified as this branch's delta rather than pre-existing: the same job succeeded on next at 14:18, after phases 4 and 5 merged. Both coverage-gate steps get the bump because both re-slice the same merged data. 8192 doubles what failed and leaves headroom on a 16GB ubuntu runner, matching the idiom the shard step already uses at 6144. This is a memory bound, not a change to what is measured. No threshold was touched. The test file was checked for gratuitous subprocess spawning and is already reasonable at 43 spawns, each a distinct fixture-by-surface pairing. Verified on the remote runner. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1774,15 +1774,25 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
const percent = clampPercent(totalSummaries, totalPlans);
|
||||
// #3217 (ADR-3180 §7.6 rule 4): `phaseScope` was already computed above
|
||||
// (Phase 3, #3222) but never consulted before rendering — a percentage was
|
||||
// rendered from counts the scope said were not answers (TRUNCATED /
|
||||
// UNSCOPED / UNREADABLE). Withhold the percentage itself (never `0` — a
|
||||
// real `0` under COMPLETE must still render, rule 2's territory) when the
|
||||
// scope is not COMPLETE. `phaseScope` stays `null` only if the try block
|
||||
// above threw before assigning it; treat that the same as non-COMPLETE.
|
||||
const percent: number | null = phaseScope === SCOPE.COMPLETE
|
||||
? clampPercent(totalSummaries, totalPlans)
|
||||
: null;
|
||||
|
||||
if (format === 'table') {
|
||||
// Render markdown table
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((percent / 100) * barWidth);
|
||||
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
||||
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
|
||||
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
|
||||
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
|
||||
out += `| Phase | Name | Plans | Status |\n`;
|
||||
out += `|-------|------|-------|--------|\n`;
|
||||
for (const p of phases) {
|
||||
@@ -1791,9 +1801,10 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
|
||||
output({ rendered: out }, raw, out);
|
||||
} else if (format === 'bar') {
|
||||
const barWidth = 20;
|
||||
const filled = Math.round((percent / 100) * barWidth);
|
||||
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
|
||||
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
||||
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
|
||||
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
|
||||
} else {
|
||||
// JSON format
|
||||
@@ -2130,8 +2141,12 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
|
||||
const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
|
||||
const completedPhases = phases.filter(p => p.status === 'Complete').length;
|
||||
const planPercent = clampPercent(totalSummaries, totalPlans);
|
||||
const percent = clampPercent(completedPhases, phases.length);
|
||||
// #3217 (ADR-3180 §7.6 rule 4): both percentages here are derived from the
|
||||
// same `phaseScope`-carrying directory enumeration above (Phase 3, #3222) —
|
||||
// withhold both when that scope is not COMPLETE, same rationale as
|
||||
// cmdProgressRender above. A real `0` under COMPLETE still renders.
|
||||
const planPercent: number | null = phaseScope === SCOPE.COMPLETE ? clampPercent(totalSummaries, totalPlans) : null;
|
||||
const percent: number | null = phaseScope === SCOPE.COMPLETE ? clampPercent(completedPhases, phases.length) : null;
|
||||
|
||||
// Requirements stats
|
||||
let requirementsTotal = 0;
|
||||
@@ -2193,11 +2208,12 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
|
||||
if (format === 'table') {
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((percent / 100) * barWidth);
|
||||
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
|
||||
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
|
||||
if (totalPlans > 0) {
|
||||
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
||||
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
|
||||
if (totalPlans > 0 && planPercent !== null) {
|
||||
out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
|
||||
}
|
||||
out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
|
||||
|
||||
@@ -17,7 +17,11 @@ import phaseIdMod = require('./phase-id.cjs');
|
||||
const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, isSentinelPhaseId } = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
const { findPhaseInternal } = phaseLocatorMod;
|
||||
const { findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
type Scope = planningScopeMod.Scope;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserModule = require('./roadmap-parser.cjs');
|
||||
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone, listMilestoneHeadings } = roadmapParserModule;
|
||||
@@ -493,6 +497,38 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
const detailPhases = new Set(phases.map(p => p.number));
|
||||
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhaseId(p));
|
||||
|
||||
// #3217 (ADR-3180 §7.6 rules 3-4): `progress_percent` used to accumulate
|
||||
// `totalPlans`/`totalSummaries` above — a heading-matched enumeration
|
||||
// (`phasePattern` over the milestone-windowed `content`) paired against
|
||||
// `_phaseDirNames`, a DELIBERATELY unscoped physical directory listing
|
||||
// (see its own comment above: it is a heading->directory lookup index,
|
||||
// not a milestone enumeration). That set is not the same set
|
||||
// `listMilestonePhaseDirs` scopes for `query progress` / `stats` (#3185
|
||||
// Phase 3), so `progress_percent` could silently diverge from both siblings
|
||||
// on the same project (rule 3). Route `progress_percent`'s own
|
||||
// numerator/denominator through the single scoped owner instead — mirrors
|
||||
// cmdProgressRender/cmdStats's own aggregation — and withhold the
|
||||
// percentage entirely when THAT scope is not COMPLETE (rule 4), never
|
||||
// returning `0` for "could not compute". This does not touch `total_plans`
|
||||
// / `total_summaries` / `phases` / `completed_phases` above — those stay
|
||||
// the heading-matched detail view; only `progress_percent`'s own inputs
|
||||
// move onto the scoped owner.
|
||||
let scopedTotalPlans = 0;
|
||||
let scopedTotalSummaries = 0;
|
||||
let progressScope: Scope = SCOPE.UNREADABLE;
|
||||
try {
|
||||
const { value: progressDirs, scope: scopedResult } = listMilestonePhaseDirs(phasesDir, { cwd });
|
||||
progressScope = scopedResult;
|
||||
for (const dir of progressDirs) {
|
||||
const scan = scanPhasePlans(path.join(phasesDir, dir));
|
||||
scopedTotalPlans += scan.planCount;
|
||||
scopedTotalSummaries += scan.summaryCount;
|
||||
}
|
||||
} catch { /* progressScope stays the pessimistic SCOPE.UNREADABLE default */ }
|
||||
const progressPercent = progressScope === SCOPE.COMPLETE
|
||||
? clampPercent(scopedTotalSummaries, scopedTotalPlans)
|
||||
: null;
|
||||
|
||||
const result = {
|
||||
milestones,
|
||||
phases,
|
||||
@@ -500,7 +536,24 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
completed_phases: completedPhases,
|
||||
total_plans: totalPlans,
|
||||
total_summaries: totalSummaries,
|
||||
progress_percent: clampPercent(totalSummaries, totalPlans),
|
||||
progress_percent: progressPercent,
|
||||
// #3217 finding 2: `progress_percent` is gated by a SECOND, independently
|
||||
// computed `listMilestonePhaseDirs` scope (`progressScope` above) — not
|
||||
// by the top-level `scope` field, which describes the heading-windowing
|
||||
// identity `phases`/`total_plans`/`total_summaries`/`completed_phases`
|
||||
// were built from. Those two scopes can legitimately disagree (e.g.
|
||||
// `scope: "complete"` alongside a genuinely unreadable phases directory),
|
||||
// and per the documented contract "scope tells you whether the counts
|
||||
// are trustworthy", a consumer seeing `progress_percent: null` needs a
|
||||
// field to tell WHY without reading source. Exposing `progress_scope`
|
||||
// (rather than reconciling the two scopes into one, or re-deriving
|
||||
// `total_plans`/`phases`/etc. from the scoped set) preserves the
|
||||
// deliberate, already-documented choice a few lines up: `phases`/
|
||||
// `total_plans`/`total_summaries`/`completed_phases` stay the
|
||||
// heading-matched detail view (`_phaseDirNames` is a lookup index, not a
|
||||
// milestone enumeration — see its comment); only `progress_percent`'s own
|
||||
// inputs move onto the scoped owner.
|
||||
progress_scope: progressScope,
|
||||
current_phase: currentPhase ? currentPhase.number : null,
|
||||
next_phase: nextPhase ? nextPhase.number : null,
|
||||
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
|
||||
|
||||
@@ -404,12 +404,25 @@ export function normalizeStateStatus(status: string | null | undefined, pausedAt
|
||||
return normalizedStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-3180 §7.6 rule 4 (#3217): `scope` is the `listMilestonePhaseDirs`-owner
|
||||
* discriminator for the phase/plan set these four counts were derived from.
|
||||
* A caller that cannot vouch for `scope === SCOPE.COMPLETE` must pass the
|
||||
* scope it actually has — this function refuses to compose a percentage
|
||||
* from counts whose scope says they are not a trustworthy answer, returning
|
||||
* `null` (never `0`; see the module's already-existing "no data" `null`
|
||||
* below, which this generalizes) exactly like its pre-existing "no data"
|
||||
* case. `scope` is REQUIRED (no default) so a caller cannot silently opt out
|
||||
* of rule 4 by omission.
|
||||
*/
|
||||
export function computeProgressPercent(
|
||||
completedPlans: number | null,
|
||||
totalPlans: number | null,
|
||||
completedPhases: number | null,
|
||||
totalPhases: number | null
|
||||
totalPhases: number | null,
|
||||
scope: Scope
|
||||
): number | null {
|
||||
if (scope !== SCOPE.COMPLETE) return null;
|
||||
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
|
||||
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
|
||||
if (!hasPlanData && !hasPhaseData)
|
||||
|
||||
@@ -36,6 +36,7 @@ const { isPhaseComplete } = verificationMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
type Scope = planningScopeMod.Scope;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
||||
@@ -177,6 +178,12 @@ const _diskScanCache = new Map<string, {
|
||||
totalPlans: number;
|
||||
completedPlans: number;
|
||||
milestoneBounded: boolean;
|
||||
// #3217 (ADR-3180 §7.6 rule 4, finding 1): the real `listMilestonePhaseDirs`
|
||||
// scope for `allMatchingDirs` below, threaded through the cache so the
|
||||
// percent computation at the bottom of buildStateFrontmatter can gate on it
|
||||
// instead of hardcoding SCOPE.COMPLETE. Distinct from `milestoneBounded`
|
||||
// (a heading-existence guard, #1761) — this one is disk-readability.
|
||||
phaseDirScope: Scope;
|
||||
}>();
|
||||
|
||||
// Track all lock files held by this process so they can be removed on exit.
|
||||
@@ -756,6 +763,7 @@ function cmdStateUpdateProgress(cwd: string, raw: boolean): void {
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
let totalPlans = 0;
|
||||
let totalSummaries = 0;
|
||||
let phaseScope: Scope = SCOPE.UNREADABLE;
|
||||
|
||||
{
|
||||
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
|
||||
@@ -764,7 +772,8 @@ function cmdStateUpdateProgress(cwd: string, raw: boolean): void {
|
||||
// excluded sentinels, unlike the owner). The owner already handles an
|
||||
// absent phasesDir as a real empty, so the fs.existsSync guard folds
|
||||
// into it.
|
||||
const phaseDirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
|
||||
const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
|
||||
phaseScope = scope;
|
||||
for (const dir of phaseDirs) {
|
||||
const { planCount, summaryCount } = scanPhasePlans(path.join(phasesDir, dir));
|
||||
totalPlans += planCount;
|
||||
@@ -772,6 +781,25 @@ function cmdStateUpdateProgress(cwd: string, raw: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a non-COMPLETE scope means the counts
|
||||
// above are not a trustworthy answer — do not write a percentage derived
|
||||
// from them into STATE.md at all (A7). This is the write path, so
|
||||
// "withhold" means "make no edit" rather than emitting a null value.
|
||||
if (phaseScope !== SCOPE.COMPLETE) {
|
||||
// #3217 finding 3 (decided: surface a warning, not silent-only
|
||||
// disclosure): the JSON `reason` field alone is easy for a caller to
|
||||
// never read, and STATE.md's Progress field goes stale with no
|
||||
// user-visible signal beyond it. Mirrors the established
|
||||
// `[gsd-tools] WARNING:` stderr convention this file already uses
|
||||
// (stateReplaceFieldWithFallback above) for a comparable silent no-op.
|
||||
process.stderr.write(
|
||||
`[gsd-tools] WARNING: state update-progress skipped — phase scope is ${phaseScope}, not complete. ` +
|
||||
`STATE.md's Progress field was left unchanged.\n`
|
||||
);
|
||||
output({ updated: false, reason: `phase scope is ${phaseScope}, not complete` }, raw, 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const percent = clampPercent(totalSummaries, totalPlans);
|
||||
const barWidth = 10;
|
||||
const filled = Math.round(percent / 100 * barWidth);
|
||||
@@ -1706,6 +1734,14 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
// #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;
|
||||
// #3217 (ADR-3180 §7.6 rule 4, finding 1): the real listMilestonePhaseDirs
|
||||
// scope for the disk-scanned counts below, set from cached.phaseDirScope
|
||||
// when a fresh disk scan runs. SCOPE.COMPLETE is the correct default here
|
||||
// — NOT a rule-4 hardcode — for the cases where no disk scan happens at all
|
||||
// (no cwd, or phasesDir absent): totalPhases/totalPlans then come straight
|
||||
// from the pre-existing frontmatter fields parsed above, a path this phase
|
||||
// does not touch and which predates listMilestonePhaseDirs entirely.
|
||||
let diskScope: Scope = SCOPE.COMPLETE;
|
||||
|
||||
if (cwd) {
|
||||
try {
|
||||
@@ -1738,7 +1774,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
// CURRENT (stored) milestone" — routed through the canonical owner
|
||||
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
||||
// (which also never excluded sentinels, unlike the owner).
|
||||
const allMatchingDirs = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null }).value;
|
||||
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null });
|
||||
|
||||
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
||||
// .planning/phases/ alongside new dirs with the same phase number,
|
||||
@@ -1865,6 +1901,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
completedPhases: diskCompletedPhases,
|
||||
totalPlans: diskTotalPlans,
|
||||
completedPlans: diskTotalSummaries,
|
||||
phaseDirScope,
|
||||
};
|
||||
})();
|
||||
_diskScanCache.set(cwd, cached);
|
||||
@@ -1874,6 +1911,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
totalPlans = cached.totalPlans;
|
||||
completedPlans = cached.completedPlans;
|
||||
milestoneUnbounded = cached.milestoneBounded === false;
|
||||
diskScope = cached.phaseDirScope;
|
||||
}
|
||||
/* best-effort (#2245 audit): this is a READ path building STATE.md's
|
||||
* display frontmatter. The real throw source is fs.readdirSync(phasesDir)
|
||||
@@ -1890,11 +1928,30 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
// ROADMAP-declared-but-unrealized future phases cap the reported completion
|
||||
// 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);
|
||||
// #3217 (ADR-3180 §7.6 rule 4, finding 1): computeProgressPercent requires
|
||||
// a `Scope` for its own rule-4 gate. `diskScope` is the real
|
||||
// `listMilestonePhaseDirs` scope threaded through `_diskScanCache`
|
||||
// (`phaseDirScope` above) when a fresh disk scan ran — an UNREADABLE
|
||||
// phases dir now withholds here exactly as it does at every sibling
|
||||
// surface, closing the cross-surface disagreement the isolated review
|
||||
// caught. When no disk scan ran at all (no cwd, or phasesDir absent)
|
||||
// `diskScope` keeps its SCOPE.COMPLETE default, preserving this
|
||||
// function's pre-existing behavior on that (unrelated, pre-dating
|
||||
// listMilestonePhaseDirs) fallback path. This call site also keeps its own
|
||||
// orthogonal `milestoneUnbounded` null-out below (#1761) — a different
|
||||
// guard (ROADMAP heading boundedness, not disk readability).
|
||||
let progressPercent = computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases, diskScope);
|
||||
// #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) {
|
||||
// #3217 finding 1 (follow-on): a non-COMPLETE diskScope must withhold the
|
||||
// percentage EVERYWHERE, including this prose fallback — without the
|
||||
// `diskScope === SCOPE.COMPLETE` guard, a stale/existing "Progress: N%"
|
||||
// body line would silently defeat computeProgressPercent's rule-4 null,
|
||||
// re-introducing a rendered percentage on the exact scope this phase
|
||||
// withholds for (this is how the reviewer's UNREADABLE-phases fixture
|
||||
// could still surface a number even after the scope threading above).
|
||||
if (progressPercent === null && progressRaw && !milestoneUnbounded && diskScope === SCOPE.COMPLETE) {
|
||||
const pctMatch = progressRaw.match(/(\d+)%/);
|
||||
if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
|
||||
}
|
||||
@@ -3164,8 +3221,24 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
if (!milestoneBounded) {
|
||||
changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
|
||||
} else {
|
||||
const p = computeProgressPercent(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
|
||||
percent = p !== null ? p : 0;
|
||||
// #3217 (ADR-3180 §7.6 rule 4) BLOCKER fix: the prior comment here claimed
|
||||
// `entries` (the raw fs.readdirSync listing above) was "never routed
|
||||
// through listMilestonePhaseDirs, so there is no real Scope to pass" —
|
||||
// that was factually wrong. The same `syncRoadmapRaw`/`syncRoadmapScope`
|
||||
// already parsed above (~3104) is precisely what
|
||||
// `listMilestonePhaseDirs` (via `getMilestonePhaseFilter`) re-derives
|
||||
// from `cwd` to produce a real `Scope` — the identical shape already
|
||||
// threaded through `buildStateFrontmatter`'s `diskScope` above. Calling
|
||||
// it here (discarding `.value`, which duplicates `entries`'s own
|
||||
// retired-phase-filtered listing) gets the real scope without changing
|
||||
// the disk-scan totals computed above.
|
||||
const syncScope: Scope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
|
||||
if (syncScope !== SCOPE.COMPLETE) {
|
||||
changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
|
||||
} else {
|
||||
const p = computeProgressPercent(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases, syncScope);
|
||||
percent = p !== null ? p : 0;
|
||||
}
|
||||
}
|
||||
|
||||
const syncResult = transitionCore(
|
||||
|
||||
@@ -439,6 +439,27 @@ export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs)
|
||||
// invariant above throws first) and matters only for the legacy unscoped
|
||||
// path, where the denominator is a roadmap heading count that a caller
|
||||
// cannot guarantee bounds the numerator.
|
||||
//
|
||||
// #3217 (ADR-3180 §7.6 rule 4) — WRITTEN REASON this site is NOT migrated
|
||||
// onto the `SCOPE` enum this phase: `buildWorkstreamInventory` is a pure
|
||||
// projection (no I/O — see the module header) fed `BuildWorkstreamInventoryInputs`
|
||||
// by `workstream-inventory.cts`. Its own `milestoneScoped` is a pre-ADR-3180
|
||||
// bespoke boolean, not a `SCOPE` value, and its caller does not currently
|
||||
// thread a real `listMilestonePhaseDirs` scope into these inputs. Doing
|
||||
// this honestly requires ONE of: (a) widening `BuildWorkstreamInventoryInputs`
|
||||
// with a `Scope` field and `WorkstreamInventory.progress_percent`'s type
|
||||
// from `number` to `number | null` — the exact "re-architecting
|
||||
// StateProjection/WorkstreamInventory return types" the design phase
|
||||
// (`.gsd/phase/refactor-3217-completion-ratio-scoping/40-design.md`,
|
||||
// "Known limits") states is OUT of this phase's scope; or (b) silently
|
||||
// reusing `milestoneScoped` as a `Scope` stand-in, which would be exactly
|
||||
// the kind of proxy-for-a-data-flow-property this same phase's guard
|
||||
// section explicitly rejects (a `boolean` cannot distinguish TRUNCATED
|
||||
// from UNSCOPED from UNREADABLE, so a caller could not tell which
|
||||
// non-answer it got). Left un-migrated rather than done dishonestly;
|
||||
// `workstream inventory`'s `progress_percent` can still render a number
|
||||
// derived from an under-scoped set (A8 in the phase's test matrix is
|
||||
// NOT covered here for that reason — see this phase's PR description).
|
||||
progress_percent: clampPercent(completedPhases, effectivePhaseCount),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user