fix(#4294): reserve a full progress bar for 100% and give the render half one owner (#4473)

* fix(#4294): reserve a full progress bar for 100% and give the render half one owner

Six call sites each carried `Math.round((percent / 100) * width)` inline, and
every copy rounded to a full bar before the percent reached 100: from 95 up at
width 10, from 98 up at width 20. A project at 19/20 plans drew the same bar as
a shipped one beside a number that said otherwise, and an out-of-range percent
threw `RangeError` from the unguarded `'░'.repeat`.

ADR-3180 Decision 7 gave the completion-RATIO derivation one owner
(`clampPercentFromFraction`). This gives the RENDER half the same:
`progressBarFilledCells` / `renderProgressBar` in phase-lifecycle.cts, with the
`progress` table and bar renderers, the stats renderer, the gsd2 import writer,
and #4231's `formatProgressMachineSegment` (which now serves both STATE.md
writers) all drawing through it.

Contract: below 100 the fill is held one cell short of the width, so only the
saturating percents move (95-99 at width 10, 98-99 at width 20) and every other
value in 0-100 renders as before — pinned by an exhaustive comparison against
the legacy formula at both widths. Null / non-finite renders an empty bar;
out-of-range is clamped, never thrown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hbFn24VWaxJBw8DUWjAmU

* chore(#4294): set changeset fragment pr to 4473

* docs(#4294): correct the pre-fix inline call-site count to five

The kernel's doc comment said SIX call sites carried their own
`Math.round((percent / 100) * width)`. The base tree has five: three in
`commands.cts` plus one each in `gsd2-import.cts` and
`formatProgressMachineSegment`, the latter two using `/ 10` with the width
already substituted (`pct` and `clamped` respectively).

The six is #4294's count of consumers -- it counts `cmdStateUpdateProgress`
and `syncCore` separately, but #4231 had already routed both through
`formatProgressMachineSegment` (as it does `applyPostSyncPreservation`), so by
this branch's base they share one copy. The comment now states the tree's count
and records where the six comes from, so neither number reads as an error
later.

Comment-only; no behaviour change, and no change to compiled output.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
0xdhx
2026-09-10 23:42:55 -05:00
committed by GitHub
parent 523be34133
commit 7249bdddac
7 changed files with 180 additions and 21 deletions

View File

@@ -58,7 +58,7 @@ import modelProfiles = require('./model-profiles.cjs');
const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
import { clampPercent, renderProgressBar } from './phase-lifecycle.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planScanMod = require('./plan-scan.cjs');
const { scanPhasePlans } = planScanMod;
@@ -2772,9 +2772,7 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
if (format === 'table') {
// Render markdown table
const barWidth = 10;
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
const bar = renderProgressBar(percent, 10);
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
@@ -2785,9 +2783,7 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
}
output({ rendered: out }, raw, out);
} else if (format === 'bar') {
const barWidth = 20;
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
const bar = renderProgressBar(percent, 20);
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
@@ -3256,9 +3252,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
};
if (format === 'table') {
const barWidth = 10;
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
const bar = renderProgressBar(percent, 10);
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;