* fix(#4243): anchor stateReplaceProgressPercent bold form to line start The bold branch of stateReplaceProgressPercent carried no ^ and no /m flag, so a bold percent-ish label quoted MID-SENTENCE inside prose — an Accumulated Context bullet mentioning **Progress:** — captured the machine-segment rewrite and destroyed the rest of its line, silently, while the real Progress line stayed stale (and the frontmatter moved on without it, breaking the #4213 surfaces-agree contract). Every caller (cmdStateUpdateProgress, syncCore's percent arm, applyPostSyncPreservation) feeds the whole document, so all three were exposed. Anchored to ^([ \t]*\*\*Progress:\*\*[ \t]*)([^\r\n]*)$ with /im — the exact idiom #4453 applied to stateReplaceField's bold branch (same-line confinement per #4010: the leading class is [ \t]*, deliberately not \s*, which can consume the newlines before the label into the match; $ is explicit-and-inert and documents end-of-line). #2177's recorded requirements all stand: frontmatter is stripped before matching, the suffix-preserving machine-segment swap is untouched, and bold-beats-plain priority now governs line-start forms, so an earlier free-text plain Progress: line still cannot capture the rewrite ahead of the real bold status line. Per the maintainer ruling (2026-09-07), #2177's incidental bold-anywhere matching was not load-bearing. * test(#4243): scope the C4 region check with splitLines, not a bare \n split lint:ci (local/no-crlf-fragile-split) flagged the free-text-plain-line row's content.split(/\n## /)[0] — a bare \n split on readFileSync content is CRLF-fragile under Windows autocrlf. Same scoping via splitLines() (src/text-lines.cts), which splits on \r?\n. * chore(#4243): backfill PR number in changeset --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/serene-foxes-hum.md
Normal file
5
.changeset/serene-foxes-hum.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4474
|
||||
---
|
||||
**progress-percent bold fields no longer rewrite mid-sentence lookalikes** — anchored to line-start like #4243's stateReplaceField fix. (#4243 follow-up; supersedes the #2177 bold-anywhere reading per maintainer ruling)
|
||||
@@ -55,11 +55,29 @@ export function formatProgressMachineSegment(percent: number): string {
|
||||
// `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;
|
||||
// #2177: bold `**Progress:**` takes priority over the plain `^Progress:`
|
||||
// form, so an earlier free-text line starting with `Progress:` cannot
|
||||
// capture the rewrite ahead of the real status line.
|
||||
//
|
||||
// #4243 (follow-up to #4453, maintainer ruling 2026-09-07): the bold form
|
||||
// is also ANCHORED to line start, with same-line leading whitespace only —
|
||||
// the exact idiom #4453 applied to stateReplaceField's bold branch. The
|
||||
// pre-fix pattern carried no `^` and no `m` flag, so a bold percent-ish
|
||||
// label quoted MID-SENTENCE inside prose (an Accumulated Context bullet
|
||||
// mentioning `**Progress:**`) captured the machine-segment rewrite and
|
||||
// destroyed the rest of its line, silently, while the real Progress line
|
||||
// stayed stale — every caller (cmdStateUpdateProgress, syncCore's percent
|
||||
// arm, applyPostSyncPreservation) feeds the whole document. #2177's own
|
||||
// recorded requirements are unaffected: the frontmatter is stripped before
|
||||
// matching (its defect was the YAML `progress:` key shadowing the body
|
||||
// line), the suffix-preserving machine-segment swap is untouched, and the
|
||||
// bold-beats-plain priority now governs LINE-START forms. The leading class
|
||||
// is `[ \t]*`, deliberately NOT `\s*` — `^\s*\*\*` can consume the newlines
|
||||
// before the label into the match and drop them on rebuild (#4010's
|
||||
// same-line confinement hazard). `$` is explicit-and-inert (`[^\r\n]*`
|
||||
// never crosses line terminators) and documents that the match ends at
|
||||
// end-of-line.
|
||||
const boldProgressPattern = /^([ \t]*\*\*Progress:\*\*[ \t]*)([^\r\n]*)$/im;
|
||||
const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
|
||||
const pattern = boldProgressPattern.test(body)
|
||||
? boldProgressPattern
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
getPreserveWhenUnchangedFields,
|
||||
STATE_MD_SECTIONS,
|
||||
sliceCurrentPositionSection,
|
||||
stateReplaceProgressPercent,
|
||||
} = require('../gsd-core/bin/lib/state-transition.cjs');
|
||||
const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
const { STATE_FIELD_SCHEMA } = require('../gsd-core/bin/lib/state-md-schema.cjs');
|
||||
@@ -4572,3 +4573,211 @@ describe('#4129: resyncing measured write ratchets the progress block', () => {
|
||||
assert.deepStrictEqual(r.postFm.progress, { total_phases: 18, completed_phases: 2, percent: 11 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4243 follow-up: stateReplaceProgressPercent's bold branch is anchored to
|
||||
// line start, exactly like stateReplaceField's #4453 fix. The pre-fix bold
|
||||
// pattern carried no ^ and no /m, so a bold percent-ish label quoted
|
||||
// MID-SENTENCE inside prose — an Accumulated Context bullet mentioning
|
||||
// `**Progress:**` — captured the machine-segment rewrite and destroyed the
|
||||
// rest of its line, silently, while the real Progress line stayed stale (the
|
||||
// callers — cmdStateUpdateProgress, syncCore's percent arm,
|
||||
// applyPostSyncPreservation — all feed the whole document). #2177's recorded
|
||||
// protections (frontmatter stripped, suffix preserved, plain-form fallback,
|
||||
// bold-beats-plain priority among LINE-START forms) are unchanged; per the
|
||||
// maintainer ruling (2026-09-07), #2177's incidental bold-anywhere matching
|
||||
// was not load-bearing.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('stateReplaceProgressPercent — anchored bold form leaves prose lookalikes untouched (#4243 follow-up)', () => {
|
||||
// The corruption shape: a bold label quoted for documentation purposes
|
||||
// inside a bullet, with the real status line in the plain template form.
|
||||
// The value after the label has NO percent, so the pre-fix whole-value
|
||||
// replacement destroyed the rest of the sentence.
|
||||
const PROSE_LINE =
|
||||
'- [2026-07-15] Progress dashboard: the **Progress:** field is machine-managed by state update-progress; do not hand-edit.';
|
||||
|
||||
const SEGMENT = (bars) => `[${'█'.repeat(bars)}${'░'.repeat(10 - bars)}]`;
|
||||
|
||||
// ROW 1 — the failing-first regression. The lookalike must survive
|
||||
// byte-identically and the REAL plain line must take the update.
|
||||
test('issue repro: mid-sentence **Progress:** lookalike survives, real plain line updates', () => {
|
||||
const input = [
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 1 of 1',
|
||||
'Plan: 2 of 2',
|
||||
'Status: Executing Phase 1',
|
||||
'',
|
||||
'Progress: [█████░░░░░] 50% (1/2 plans done)',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
PROSE_LINE,
|
||||
'',
|
||||
].join('\n');
|
||||
const result = stateReplaceProgressPercent(input, 0);
|
||||
assert.notEqual(result, null, 'the real plain Progress line must still match');
|
||||
assert.ok(
|
||||
result.includes(PROSE_LINE),
|
||||
`prose lookalike must survive byte-identically, got:\n${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes(`Progress: ${SEGMENT(0)} 0% (1/2 plans done)`),
|
||||
`the real plain line's machine segment must update with the suffix intact, got:\n${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('the **Progress:** ['),
|
||||
'the rewrite must not bleed a machine segment into the prose occurrence',
|
||||
);
|
||||
});
|
||||
|
||||
test('lookalike ordered BEFORE the real bold line: prose survives, line-start bold line updates', () => {
|
||||
const input = [
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
PROSE_LINE,
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'**Progress:** [█████░░░░░] 50% (2/4 plans done; blocked on API keys)',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = stateReplaceProgressPercent(input, 75);
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.includes(PROSE_LINE), `prose lookalike must survive, got:\n${result}`);
|
||||
assert.ok(
|
||||
result.includes(`**Progress:** ${SEGMENT(8)} 75% (2/4 plans done; blocked on API keys)`),
|
||||
`the real line-start bold line's machine segment must update with the suffix intact, got:\n${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('lookalike whose value carries a percent: prose percent is not swapped, real plain line updates', () => {
|
||||
const lookalike = '- The **Progress:** bar read 20% last week; see the archived thread.';
|
||||
const input = [
|
||||
'Progress: [█████░░░░░] 50% (1/2 plans done)',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
lookalike,
|
||||
'',
|
||||
].join('\n');
|
||||
const result = stateReplaceProgressPercent(input, 0);
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(
|
||||
result.includes(lookalike),
|
||||
`the prose percent must not be swapped into a machine segment, got:\n${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes(`Progress: ${SEGMENT(0)} 0% (1/2 plans done)`),
|
||||
'the real plain line must take the update',
|
||||
);
|
||||
});
|
||||
|
||||
test('mid-sentence lookalike with no real line: returns null (honest absence), never a rewrite', () => {
|
||||
const input = `Some prose sentence quoting a **Progress:** label mid-sentence, plus trailing words.`;
|
||||
assert.equal(stateReplaceProgressPercent(input, 40), null);
|
||||
});
|
||||
|
||||
test('mid-word lookalike with no real line: returns null', () => {
|
||||
const input = 'Prose mentions text**Progress:**tail mid-word and nothing else.';
|
||||
assert.equal(stateReplaceProgressPercent(input, 40), null);
|
||||
});
|
||||
|
||||
// Negative space: an INDENTED line-start bold line is still the status line
|
||||
// (the leading class is same-line whitespace only, #4010's idiom), and the
|
||||
// indent is preserved.
|
||||
test('indented line-start bold line still updates, indent preserved', () => {
|
||||
const input = ' **Progress:** [█████░░░░░] 50%';
|
||||
const result = stateReplaceProgressPercent(input, 100);
|
||||
assert.equal(result, ` **Progress:** ${SEGMENT(10)} 100%`);
|
||||
});
|
||||
|
||||
// Negative space + fix-shape pin: leading blank lines before the label are
|
||||
// NOT swallowed. The anchor's leading class is same-line whitespace only
|
||||
// (`[ \t]*`); the naive `^\s*` variant would consume the newlines into the
|
||||
// match and drop them on rebuild (#4010 hazard, rejected in #4453).
|
||||
test('leading blank lines before a line-start bold label survive byte-identically', () => {
|
||||
const input = '\n\n**Progress:** [█████░░░░░] 50%';
|
||||
const result = stateReplaceProgressPercent(input, 40);
|
||||
assert.equal(result, `\n\n**Progress:** ${SEGMENT(4)} 40%`);
|
||||
});
|
||||
|
||||
// Negative space: #2177's priority is unchanged among LINE-START forms — a
|
||||
// real bold status line still beats the plain form, so an earlier free-text
|
||||
// plain `Progress:` line cannot capture the rewrite ahead of it.
|
||||
test('line-start bold still beats the plain form; earlier free-text plain line untouched (#2177)', () => {
|
||||
const freeText = 'Progress: tracked in the weekly thread, do not edit this line by hand';
|
||||
const input = `${freeText}\n\n**Progress:** [█████░░░░░] 50%\n`;
|
||||
const result = stateReplaceProgressPercent(input, 75);
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.includes(freeText), 'the free-text plain line must stay byte-identical');
|
||||
assert.ok(
|
||||
result.includes(`**Progress:** ${SEGMENT(8)} 75%`),
|
||||
'the line-start bold status line is the one rewritten',
|
||||
);
|
||||
});
|
||||
|
||||
// Negative space: first-occurrence-wins among line-start bold lines.
|
||||
test('two line-start bold occurrences: only the first is replaced', () => {
|
||||
const input = '**Progress:** [█████░░░░░] 50%\n**Progress:** [████░░░░░░] 40%';
|
||||
const result = stateReplaceProgressPercent(input, 10);
|
||||
assert.equal(result, `**Progress:** ${SEGMENT(1)} 10%\n**Progress:** [████░░░░░░] 40%`);
|
||||
});
|
||||
|
||||
// Negative space: the plain-form fallback (#2177's plain path) is unchanged
|
||||
// when no bold line exists at all — machine-segment-only swap, suffix kept.
|
||||
test('plain-form fallback unchanged: no bold anywhere, plain line updates with suffix intact', () => {
|
||||
const input = 'Progress: [██░░░░░░░░] 20% (1/2 plans done; next: verification)';
|
||||
const result = stateReplaceProgressPercent(input, 50);
|
||||
assert.notEqual(result, null);
|
||||
assert.equal(result, `Progress: ${SEGMENT(5)} 50% (1/2 plans done; next: verification)`);
|
||||
});
|
||||
|
||||
// Negative space: #2177's core — the YAML frontmatter `progress:` key is
|
||||
// never a match target; the block survives byte-identically.
|
||||
test('frontmatter progress: key never matched — block survives byte-identically (#2177)', () => {
|
||||
const fm = [
|
||||
'---',
|
||||
'progress:',
|
||||
' total_plans: 2',
|
||||
' completed_plans: 1',
|
||||
' percent: 50',
|
||||
'---',
|
||||
'',
|
||||
].join('\n');
|
||||
const input = `${fm}# Project State\n\nProgress: [█████░░░░░] 50% (1/2 plans done)\n\n## Accumulated Context\n\n${PROSE_LINE}\n`;
|
||||
const result = stateReplaceProgressPercent(input, 0);
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(
|
||||
result.startsWith(fm),
|
||||
`the frontmatter block must survive byte-identically, got:\n${result}`,
|
||||
);
|
||||
assert.ok(result.includes(' percent: 50'), 'the frontmatter percent line is untouched');
|
||||
assert.ok(result.includes(PROSE_LINE), 'the prose lookalike survives');
|
||||
assert.ok(
|
||||
result.includes(`Progress: ${SEGMENT(0)} 0% (1/2 plans done)`),
|
||||
'the real plain line takes the update',
|
||||
);
|
||||
});
|
||||
|
||||
test('CRLF document: lookalike survives with CRLF intact, real plain line updates', () => {
|
||||
const input = [
|
||||
'Progress: [█████░░░░░] 50% (1/2 plans done)',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
PROSE_LINE,
|
||||
'',
|
||||
].join('\r\n');
|
||||
const result = stateReplaceProgressPercent(input, 0);
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.includes(PROSE_LINE), `prose lookalike must survive, got:\n${result}`);
|
||||
assert.ok(result.includes('\r\n'), 'CRLF endings must be preserved');
|
||||
assert.ok(
|
||||
result.includes(`Progress: ${SEGMENT(0)} 0% (1/2 plans done)`),
|
||||
'the real plain line must take the update',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4141,6 +4141,204 @@ describe('#4243: begin-phase leaves prose lookalikes untouched, preserves unknow
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4243 follow-up — update-progress: the bold branch of
|
||||
// stateReplaceProgressPercent is anchored to line start (same fix as #4453's
|
||||
// stateReplaceField anchoring), so prose bold-percent lookalikes stay
|
||||
// untouched and the real Progress line takes the machine-segment rewrite.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#4243 follow-up: update-progress leaves prose **Progress:** lookalikes untouched', () => {
|
||||
const PROSE_LINE =
|
||||
'- [2026-07-15] Progress dashboard: the **Progress:** field is machine-managed by state update-progress; do not hand-edit.';
|
||||
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
// Same #3217 free-form ROADMAP as the update-progress block above: a
|
||||
// no-version ROADMAP is COMPLETE scope, so the percent is computed rather
|
||||
// than withheld.
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function seedOnePhaseTwoPlans() {
|
||||
// 1 of 2 plans summarized, no *-VERIFICATION.md → min-capped 0% (the
|
||||
// existing update-progress rows' derivation; exact value is incidental —
|
||||
// what matters is WHERE the rewrite lands).
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||||
}
|
||||
|
||||
// The repro verbatim: the real status line in the plain template form, the
|
||||
// lookalike quoted mid-sentence inside an Accumulated Context bullet whose
|
||||
// value has no percent (the pre-fix whole-value replacement shape).
|
||||
test('issue repro: prose lookalike is byte-identical, real plain line updates, surfaces agree', () => {
|
||||
writeState(tmpDir, [
|
||||
'# Project State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'Phase: 1 of 1',
|
||||
'Plan: 2 of 2',
|
||||
'Status: Executing Phase 1',
|
||||
'Last activity: 2026-08-01 — did a thing',
|
||||
'',
|
||||
'Progress: [█████░░░░░] 50% (1/2 plans done)',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
PROSE_LINE,
|
||||
'',
|
||||
].join('\n'));
|
||||
seedOnePhaseTwoPlans();
|
||||
|
||||
const result = runGsdTools('state update-progress', tmpDir);
|
||||
assert.ok(result.success, `update-progress failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.updated, true, 'the real plain Progress line must still match');
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(
|
||||
content.includes(PROSE_LINE),
|
||||
`prose lookalike must survive update-progress byte-identically, got:\n${content}`,
|
||||
);
|
||||
// The whole-document Progress extractor (stateExtractField) is itself
|
||||
// bold-anywhere on the read side — with a bold lookalike in prose it
|
||||
// extracts the PROSE value, so it cannot witness this write. Assert on
|
||||
// the Current Position section body instead (#4453's precedent for
|
||||
// prose-lookalike fixtures), plus the frontmatter surface for agreement.
|
||||
const pos = sectionMatchOf(content, 'Current Position');
|
||||
assert.ok(pos, 'Current Position section should exist');
|
||||
assert.match(
|
||||
pos[1],
|
||||
new RegExp(`^Progress: \\[${'░'.repeat(10)}\\] ${out.percent}% \\(1/2 plans done\\)$`, 'm'),
|
||||
'the real plain line takes the machine-segment rewrite with its suffix intact',
|
||||
);
|
||||
const fm = frontmatterLib.extractFrontmatter(content);
|
||||
assert.strictEqual(
|
||||
Number(fm.progress && fm.progress.percent),
|
||||
out.percent,
|
||||
'frontmatter percent must agree with the reported percent (#4213 surfaces-agree)',
|
||||
);
|
||||
});
|
||||
|
||||
// Corruption shape 2: lookalike section ordered BEFORE the status line,
|
||||
// real field in the line-start bold form.
|
||||
test('lookalike before Current Position: prose survives, real line-start bold line updates', () => {
|
||||
writeState(tmpDir, [
|
||||
'# Project State',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
PROSE_LINE,
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'**Progress:** [█████░░░░░] 50% (2/4 plans done; blocked on API keys)',
|
||||
'',
|
||||
].join('\n'));
|
||||
seedOnePhaseTwoPlans();
|
||||
|
||||
const result = runGsdTools('state update-progress', tmpDir);
|
||||
assert.ok(result.success, `update-progress failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.updated, true);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(
|
||||
content.includes(PROSE_LINE),
|
||||
`prose lookalike must survive update-progress byte-identically, got:\n${content}`,
|
||||
);
|
||||
// Read-side extractor is bold-anywhere (see C1 note): assert on the
|
||||
// Current Position section body instead.
|
||||
const pos = sectionMatchOf(content, 'Current Position');
|
||||
assert.ok(pos, 'Current Position section should exist');
|
||||
assert.match(
|
||||
pos[1],
|
||||
new RegExp(`^\\*\\*Progress:\\*\\* \\[${'░'.repeat(10)}\\] ${out.percent}% \\(2/4 plans done; blocked on API keys\\)$`, 'm'),
|
||||
'the real line-start bold line takes the machine-segment rewrite with its suffix intact',
|
||||
);
|
||||
});
|
||||
|
||||
// Honest absence: with only a prose lookalike (no line-start Progress line
|
||||
// at all), the command must report updated:false with the #3957 body-layer
|
||||
// reason — not a false success that corrupts the prose.
|
||||
test('lookalike only, no body Progress line: updated:false, file unchanged', () => {
|
||||
const before = [
|
||||
'# Project State',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
PROSE_LINE,
|
||||
'',
|
||||
].join('\n');
|
||||
writeState(tmpDir, before);
|
||||
seedOnePhaseTwoPlans();
|
||||
|
||||
const result = runGsdTools('state update-progress', tmpDir);
|
||||
assert.ok(result.success, `update-progress failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.updated, false, 'a prose lookalike is not a Progress line');
|
||||
assert.strictEqual(
|
||||
out.reason,
|
||||
'no Progress: line found in STATE.md body to update (frontmatter progress data is unaffected)',
|
||||
);
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(content.includes(PROSE_LINE), 'the prose must be untouched');
|
||||
});
|
||||
|
||||
// #2177 priority restated at CLI level among LINE-START forms: an earlier
|
||||
// free-text plain `Progress:` line must not capture the rewrite ahead of
|
||||
// the real bold status line.
|
||||
test('free-text plain Progress: line above the real bold line stays byte-identical (#2177)', () => {
|
||||
const freeText = 'Progress: tracked in the weekly thread, do not edit this line by hand';
|
||||
writeState(tmpDir, [
|
||||
'# Project State',
|
||||
'',
|
||||
freeText,
|
||||
'',
|
||||
'**Progress:** [█████░░░░░] 50%',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'',
|
||||
PROSE_LINE,
|
||||
'',
|
||||
].join('\n'));
|
||||
seedOnePhaseTwoPlans();
|
||||
|
||||
const result = runGsdTools('state update-progress', tmpDir);
|
||||
assert.ok(result.success, `update-progress failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.updated, true);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(content.includes(freeText), 'the free-text plain line must stay byte-identical');
|
||||
assert.ok(content.includes(PROSE_LINE), 'the prose lookalike must stay byte-identical');
|
||||
// Read-side extractor is bold-anywhere (see the C1 note above); the free-
|
||||
// text plain line and the bold status line live in the top-of-body region
|
||||
// between the title and the first ## heading. Scope with splitLines
|
||||
// (CRLF-safe per local/no-crlf-fragile-split), never a bare \n split.
|
||||
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
|
||||
const lines = splitLines(content);
|
||||
const firstSectionIdx = lines.findIndex((l) => l.startsWith('## '));
|
||||
const beforeFirstSection = lines.slice(0, firstSectionIdx === -1 ? lines.length : firstSectionIdx).join('\n');
|
||||
assert.match(
|
||||
beforeFirstSection,
|
||||
new RegExp(`^\\*\\*Progress:\\*\\* \\[${'░'.repeat(10)}\\] ${out.percent}%$`, 'm'),
|
||||
'the line-start bold status line is the one rewritten',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bug #1589 — progress counters not updated during plan execution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user