* fix(#4213): keep STATE.md progress surfaces synchronized * fix(#4213): clamp the shared progress bar and keep bold-first priority, changeset + property tests - formatProgressMachineSegment clamps through clampPercentFromFraction (ADR-3180 Decision 7 kernel) with a 0 floor, so a hand-edited out-of-range persisted percent renders a clamped bar instead of throwing RangeError on repeat() inside the write seam - stateReplaceProgressPercent restores the #2177 bold-first priority: **Progress:** anywhere in the body wins; a plain ^Progress: line is the fallback, so free text starting with Progress: cannot capture the rewrite ahead of the real status line - cross-reference comment names the three consumers and the cmdStateSync sanctioned exception (ADR-3408 §8.3) - CONTEXT.md: applyPostSyncPreservation reconciliation documented in the STATE.md Transition Module entry - property tests (never-throws/well-formed, idempotency, round-trip, bold-first) + two regression rows through the CLI --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -20,7 +20,7 @@ import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stat
|
||||
import { KNOWN_TEMPLATE_DEFAULTS, toFiniteNumber } from './state-document.cjs';
|
||||
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
|
||||
import type { HeadingToken } from './markdown-sectionizer.cjs';
|
||||
import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs';
|
||||
import { deriveProgressFromRoadmap, clampPercent, clampPercentFromFraction } from './phase-lifecycle.cjs';
|
||||
import { escapeRegex } from './pattern.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateMdSchemaMod = require('./state-md-schema.cjs');
|
||||
@@ -29,6 +29,47 @@ type StateFieldSchema = stateMdSchemaMod.StateFieldSchema;
|
||||
|
||||
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatter;
|
||||
|
||||
export function formatProgressMachineSegment(percent: number): string {
|
||||
// ADR-3180 Decision 7: rounding and the 100 ceiling belong to the
|
||||
// completion-ratio kernel. The floor is added here because this helper is
|
||||
// also fed persisted frontmatter values (hand-editable, unlike the
|
||||
// count-shaped entries into that kernel), and `'░'.repeat` throws on a
|
||||
// negative count. Bar and printed percent use the clamped value so the two
|
||||
// halves of the segment can never disagree.
|
||||
const clamped = Math.max(0, clampPercentFromFraction(percent / 100));
|
||||
const filled = Math.round(clamped / 10);
|
||||
return `[${'█'.repeat(filled)}${'░'.repeat(10 - filled)}] ${clamped}%`;
|
||||
}
|
||||
|
||||
// Consumers (a future STATE.md writer that bypasses all three reintroduces the
|
||||
// #4213 divergence class): `cmdStateUpdateProgress` and `syncCore`'s progress
|
||||
// intent (both in this module) plus the post-sync body reconciliation in
|
||||
// `applyPostSyncPreservation` (src/state.cts). `cmdStateSync` never reaches
|
||||
// that reconciliation — ADR-3408 §8.3: `state sync` lets the body win, so
|
||||
// preservation must NOT run — which is why its correctness comes from
|
||||
// `syncCore`'s call here.
|
||||
export function stateReplaceProgressPercent(content: string, percent: number): string | null {
|
||||
const body = stripFrontmatter(content);
|
||||
// #2177: bold `**Progress:**` anywhere in the body wins outright; the plain
|
||||
// `^Progress:` form is the fallback only when no bold line exists, so an
|
||||
// earlier free-text line starting with `Progress:` cannot capture the
|
||||
// rewrite ahead of the real status line.
|
||||
const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
|
||||
const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
|
||||
const pattern = boldProgressPattern.test(body)
|
||||
? boldProgressPattern
|
||||
: plainProgressPattern.test(body)
|
||||
? plainProgressPattern
|
||||
: null;
|
||||
if (!pattern) return null;
|
||||
const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
|
||||
const progress = formatProgressMachineSegment(percent);
|
||||
const updatedBody = body.replace(pattern, (_match: string, prefix: string, value: string) => (
|
||||
`${prefix}${machineSegment.test(value) ? value.replace(machineSegment, progress) : progress}`
|
||||
));
|
||||
return content.slice(0, content.length - body.length) + updatedBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
|
||||
* `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a
|
||||
@@ -2771,13 +2812,12 @@ function syncCore(
|
||||
if (currentProgress) {
|
||||
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
|
||||
if (currentPercent !== intent.percent) {
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((intent.percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
const progressStr = `[${bar}] ${intent.percent}%`;
|
||||
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
|
||||
const result = stateReplaceField(modified, 'Progress', progressStr);
|
||||
if (result) { modified = result; updated.push('Progress'); }
|
||||
const result = stateReplaceProgressPercent(modified, intent.percent);
|
||||
if (result) {
|
||||
const progressStr = formatProgressMachineSegment(intent.percent);
|
||||
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
|
||||
modified = result; updated.push('Progress');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user