* test(#4247): failing-first regressions for checklist-form update-plan-progress * fix(#4247): refuse update-plan-progress when the roadmap has no writable phase entry * fix(#4247): single local source for the phase-heading anchor grammar * docs(#4247): note the missing_phase_details refusal in cli-tools reference * docs(#4247): backfill pr number in changeset --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/brave-finches-zip.md
Normal file
5
.changeset/brave-finches-zip.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 4468
|
||||||
|
---
|
||||||
|
**`roadmap update-plan-progress` no longer false-greens on checklist-form ROADMAPs** — a phase whose entry is a `- [ ] **Phase N: …**` checklist bullet with no writable Progress-table row or detail section now declines with `updated: false` and a typed `missing_phase_details` reason, leaving ROADMAP.md byte-identical, instead of reporting success off an unrelated checkbox mark while the phase row stayed untouched and blank lines were injected mid-sentence in other phases' entries. (#4247)
|
||||||
@@ -434,6 +434,12 @@ node gsd-tools.cjs roadmap analyze
|
|||||||
node gsd-tools.cjs roadmap update-plan-progress <N>
|
node gsd-tools.cjs roadmap update-plan-progress <N>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When the phase has no writable ROADMAP entry — no matching Progress-table row,
|
||||||
|
no `### Phase N` detail section, and no checklist bullet this command can update
|
||||||
|
(the checklist-only form) — the command declines with `updated: false` and a
|
||||||
|
`missing_phase_details` reason instead of claiming success, and leaves
|
||||||
|
`ROADMAP.md` byte-identical.
|
||||||
|
|
||||||
### Milestone window scope (`roadmap analyze`)
|
### Milestone window scope (`roadmap analyze`)
|
||||||
|
|
||||||
`roadmap analyze` scopes its phase list to the current milestone's section of
|
`roadmap analyze` scopes its phase list to the current milestone's section of
|
||||||
|
|||||||
@@ -1032,6 +1032,23 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
|||||||
|
|
||||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||||
let updated = false;
|
let updated = false;
|
||||||
|
// #4247: the refusal flag. The write/report decision below must be keyed to
|
||||||
|
// "a writable roadmap representation of THIS phase was found", never to "any
|
||||||
|
// byte moved". On a checklist-form ROADMAP (`- [ ] **Phase N: …**`, the
|
||||||
|
// roadmapper's own summary-checklist form) every phase-targeted grammar
|
||||||
|
// below requires an ATX `#{2,4} Phase N` heading and therefore finds
|
||||||
|
// nothing; the Progress-table row is the only other writable target, and
|
||||||
|
// when its Phase cell does not match `phaseCellRe` (e.g. a word-prefixed
|
||||||
|
// `Phase 68` cell — deliberately unrecognized on the read side too,
|
||||||
|
// `deriveProgressFromRoadmap`'s `/^\d/` data-row filter) the command used
|
||||||
|
// to fall through to unrelated byte deltas (an UN-scoped plan-checkbox mark
|
||||||
|
// anywhere in the document) and report `updated: true` while the phase's
|
||||||
|
// own row stayed untouched — with the file-global write then letting the
|
||||||
|
// platform write seam's markdown normalization inject blank lines around
|
||||||
|
// other phases' bullets, splitting hand-wrapped sentences mid-entry. The
|
||||||
|
// refusal below declines with the analyzer's own `missing_phase_details`
|
||||||
|
// vocabulary and leaves ROADMAP.md byte-identical.
|
||||||
|
let missingPhaseDetails = false;
|
||||||
withPlanningLock(cwd, () => {
|
withPlanningLock(cwd, () => {
|
||||||
// #3957 (B9.4): captured BEFORE any transform runs, so the write/report
|
// #3957 (B9.4): captured BEFORE any transform runs, so the write/report
|
||||||
// decision below reflects whether the transforms actually changed
|
// decision below reflects whether the transforms actually changed
|
||||||
@@ -1041,6 +1058,35 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
|||||||
const originalContent = fs.readFileSync(roadmapPath, 'utf-8');
|
const originalContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
let roadmapContent = originalContent;
|
let roadmapContent = originalContent;
|
||||||
const phasePattern = phaseMarkdownRegexSource(phaseNum);
|
const phasePattern = phaseMarkdownRegexSource(phaseNum);
|
||||||
|
// #4247: ONE local source for the ATX phase-heading anchor that every
|
||||||
|
// section-scoped writer below (`planCountPattern`,
|
||||||
|
// `insertRowsPatternA|B`) starts with — extracted so the target-detection
|
||||||
|
// gate below reads the SAME grammar the writers anchor on, and a future
|
||||||
|
// edit to one cannot drift from the other three copies.
|
||||||
|
const phaseHeadingAnchor = `#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`;
|
||||||
|
// #4247: target detection runs against the ORIGINAL content's active
|
||||||
|
// (post-</details>) region — the same milestone scoping every writer
|
||||||
|
// below applies — so the gate asks "does the file carry a writable phase
|
||||||
|
// representation" rather than "did some regex fire mid-transform".
|
||||||
|
const gateDetailsClose = originalContent.lastIndexOf('</details>');
|
||||||
|
const gateActiveRegion = gateDetailsClose === -1
|
||||||
|
? originalContent
|
||||||
|
: originalContent.slice(gateDetailsClose + '</details>'.length);
|
||||||
|
// Heading target: the exact grammar `planCountPattern` /
|
||||||
|
// `insertRowsPatternA|B` anchor on (an ATX phase heading for this phase).
|
||||||
|
const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(gateActiveRegion);
|
||||||
|
// Checklist target: when the phase is complete, its own checklist bullet
|
||||||
|
// (`- [ ] **Phase N: …**`) IS a writable phase row — the completion
|
||||||
|
// checkbox stamp below updates it. Same grammar as that writer, widened
|
||||||
|
// one notch to `[ x]` so an ALREADY-checked bullet still counts as a
|
||||||
|
// found target: an idempotent re-run then takes the honest
|
||||||
|
// "no changes were needed" decline instead of this refusal.
|
||||||
|
const checklistTargetFound = isComplete && new RegExp(
|
||||||
|
`-\\s*\\[[ x]\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`,
|
||||||
|
'i',
|
||||||
|
).test(gateActiveRegion);
|
||||||
|
// Table-row target: set by the row-scoped cell updates below.
|
||||||
|
let tableRowFound = false;
|
||||||
|
|
||||||
// Progress table row: update Plans Complete/Status/Completed columns BY
|
// Progress table row: update Plans Complete/Status/Completed columns BY
|
||||||
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
|
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
|
||||||
@@ -1062,10 +1108,10 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
|||||||
let text = scoped;
|
let text = scoped;
|
||||||
|
|
||||||
const plansResult = updateTableCell(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
|
const plansResult = updateTableCell(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
|
||||||
if (plansResult.ok) text = plansResult.value;
|
if (plansResult.ok) { text = plansResult.value; tableRowFound = true; }
|
||||||
|
|
||||||
const statusResult = updateTableCell(text, rowMatch, 'Status', ` ${status.padEnd(11)}`);
|
const statusResult = updateTableCell(text, rowMatch, 'Status', ` ${status.padEnd(11)}`);
|
||||||
if (statusResult.ok) text = statusResult.value;
|
if (statusResult.ok) { text = statusResult.value; tableRowFound = true; }
|
||||||
|
|
||||||
// Preserve only a valid ISO date (#1161: idempotent; self-heal garbage).
|
// Preserve only a valid ISO date (#1161: idempotent; self-heal garbage).
|
||||||
// Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
|
// Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
|
||||||
@@ -1081,7 +1127,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
|||||||
}
|
}
|
||||||
return ' ';
|
return ' ';
|
||||||
});
|
});
|
||||||
if (completedResult.ok) text = completedResult.value;
|
if (completedResult.ok) { text = completedResult.value; tableRowFound = true; }
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
});
|
});
|
||||||
@@ -1125,7 +1171,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
|||||||
// continuation on the next line, since the pattern never spans past
|
// continuation on the next line, since the pattern never spans past
|
||||||
// `\n` in the first place.
|
// `\n` in the first place.
|
||||||
const planCountPattern = new RegExp(
|
const planCountPattern = new RegExp(
|
||||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`,
|
`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`,
|
||||||
'i'
|
'i'
|
||||||
);
|
);
|
||||||
const planCountText = isComplete
|
const planCountText = isComplete
|
||||||
@@ -1218,11 +1264,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
|||||||
// Pattern A: anchor to bare `Plans:` header (preferred).
|
// Pattern A: anchor to bare `Plans:` header (preferred).
|
||||||
// Pattern B: fallback to bold summary when no bare header exists.
|
// Pattern B: fallback to bold summary when no bare header exists.
|
||||||
const insertRowsPatternA = new RegExp(
|
const insertRowsPatternA = new RegExp(
|
||||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
|
`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
|
||||||
'i'
|
'i'
|
||||||
);
|
);
|
||||||
const insertRowsPatternB = new RegExp(
|
const insertRowsPatternB = new RegExp(
|
||||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
|
`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
|
||||||
'i'
|
'i'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1268,10 +1314,24 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
|||||||
// `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content`
|
// `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content`
|
||||||
// gate. Previously this wrote and reported `updated: true`
|
// gate. Previously this wrote and reported `updated: true`
|
||||||
// unconditionally, even on an idempotent re-run that changed nothing.
|
// unconditionally, even on an idempotent re-run that changed nothing.
|
||||||
if (roadmapContent !== originalContent) {
|
//
|
||||||
|
// #4247: ...but ONLY when a writable representation of THIS phase was
|
||||||
|
// found. Without the target gate, a byte delta from an unrelated
|
||||||
|
// transform (the un-scoped plan-checkbox mark) satisfied the #3957 gate
|
||||||
|
// and produced a success-shaped `updated: true` while the phase's own
|
||||||
|
// row stayed untouched — and the file-global write let the platform
|
||||||
|
// write seam's markdown normalization reflow unrelated entries. When no
|
||||||
|
// target exists the command refuses: no write at all, so ROADMAP.md is
|
||||||
|
// left byte-identical, and the caller gets a typed
|
||||||
|
// `missing_phase_details` decline instead of a false green.
|
||||||
|
const phaseRepresentationFound = tableRowFound || headingTargetFound || checklistTargetFound;
|
||||||
|
if (phaseRepresentationFound && roadmapContent !== originalContent) {
|
||||||
platformWriteSync(roadmapPath, roadmapContent);
|
platformWriteSync(roadmapPath, roadmapContent);
|
||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
|
if (!phaseRepresentationFound) {
|
||||||
|
missingPhaseDetails = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const computed = {
|
const computed = {
|
||||||
@@ -1284,6 +1344,18 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
|||||||
};
|
};
|
||||||
if (updated) {
|
if (updated) {
|
||||||
output({ updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
|
output({ updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
|
||||||
|
} else if (missingPhaseDetails) {
|
||||||
|
// #4247: honest refusal — the reason names the real condition (the
|
||||||
|
// analyzer's `missing_phase_details` vocabulary), never "already
|
||||||
|
// reflects", which was false: the ROADMAP was never able to record this
|
||||||
|
// phase's progress in the first place.
|
||||||
|
declineNoOp(
|
||||||
|
raw,
|
||||||
|
'updated',
|
||||||
|
'missing_phase_details',
|
||||||
|
`roadmap update-plan-progress skipped — ROADMAP.md has no writable entry for phase ${formatDiagnosticToken(String(phaseNum))} (no matching Progress-table row, no phase detail section, and no checklist entry this command can update). ROADMAP.md was left unchanged.`,
|
||||||
|
computed,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
declineNoOp(
|
declineNoOp(
|
||||||
raw,
|
raw,
|
||||||
|
|||||||
@@ -1269,6 +1269,364 @@ describe('#3057 B3: roadmap update-plan-progress — verification staleness-chec
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// regressions: #4247 — checklist-form ROADMAP.md must not produce a false
|
||||||
|
// `updated:true` (untouched phase row) nor blank-line corruption elsewhere
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The checklist house style the bundled roadmapper emits for the summary
|
||||||
|
* checklist (`agents/gsd-roadmapper.md` §"Summary Checklist"): long
|
||||||
|
* `- [ ] **Phase N: Title** - description` entries with column-0 continuation
|
||||||
|
* sentences (the shape issue #4247 was filed against), plus a Progress table.
|
||||||
|
*
|
||||||
|
* `tableCell` controls the Progress table's Phase cell for the target phase.
|
||||||
|
*/
|
||||||
|
function buildChecklistRoadmap4247(tableCell) {
|
||||||
|
return [
|
||||||
|
'# Roadmap: Mango Tree',
|
||||||
|
'',
|
||||||
|
'## Phases',
|
||||||
|
'',
|
||||||
|
'- [ ] **Phase 65: Orchard Layout** - goal: design the canopy grid. Progress: 4/4 plans executed, verified 2026-08-30;',
|
||||||
|
'grid survey closed the two-centimeter tolerance, terracing passed inspection, and the irrigation',
|
||||||
|
'channels were flushed before the storm; soil probes re-zeroed afterwards.',
|
||||||
|
'- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler. Progress: 1/5 plans executed;',
|
||||||
|
'68-01 calendar model in review; 68-02 crew assignment summarized; 68-03 weather windows',
|
||||||
|
'not started; 68-04 crate logistics blocked on warehouse slot; 68-05 billing hook pending.',
|
||||||
|
'- [ ] **Phase 69: Packhouse** - goal: automate the line. Progress: 0/2 executed.',
|
||||||
|
'',
|
||||||
|
'## Execution Waves',
|
||||||
|
'',
|
||||||
|
'- Wave 1: Phases 65-66 — complete.',
|
||||||
|
'- Wave 2: Phase 68 — in flight.',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'',
|
||||||
|
'| Phase | Plans Complete | Status | Completed |',
|
||||||
|
'|-------|----------------|--------|-----------|',
|
||||||
|
'| 65 | 4/4 | Complete | 2026-08-30 |',
|
||||||
|
`| ${tableCell} | 0/5 | Planned | - |`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase 68 on disk: 5 plans, 1 summary → In Progress, 1/5. */
|
||||||
|
function seedPhase68WithPlans(tmpDir, { roadmap } = {}) {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||||
|
const p68 = path.join(tmpDir, '.planning', 'phases', '68-scheduler');
|
||||||
|
fs.mkdirSync(p68, { recursive: true });
|
||||||
|
for (const n of ['01', '02', '03', '04', '05']) {
|
||||||
|
fs.writeFileSync(path.join(p68, `68-${n}-PLAN.md`), `# Plan ${n}\n`);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(p68, '68-02-SUMMARY.md'), '# Summary\n');
|
||||||
|
return p68;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#4247: roadmap update-plan-progress — checklist-form ROADMAP refuses instead of false-green', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let roadmapPath;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject('gsd-4247-roadmap-');
|
||||||
|
roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 1 — the issue's exact repro shape. FAILING FIRST.
|
||||||
|
test('checklist form with non-matching table row and a stray plan row refuses with missing_phase_details and writes nothing', () => {
|
||||||
|
const roadmap = buildChecklistRoadmap4247('Phase 68').replace(
|
||||||
|
'- [ ] **Phase 69: Packhouse**',
|
||||||
|
' - [ ] 68-01: calendar model\n - [ ] 68-02: crew assignment\n- [ ] **Phase 69: Packhouse**',
|
||||||
|
);
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, false, 'a phase with no writable roadmap representation must not claim updated');
|
||||||
|
assert.strictEqual(output.reason, 'missing_phase_details', 'refusal carries the typed parse diagnostic');
|
||||||
|
assert.strictEqual(output.plan_count, 5, 'computed counts are still reported');
|
||||||
|
assert.strictEqual(output.summary_count, 1);
|
||||||
|
|
||||||
|
// The issue's round-trip requirement: the file must be byte-identical —
|
||||||
|
// no plan-checkbox marks, no blank lines splitting other phases' sentences.
|
||||||
|
const written = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
assert.strictEqual(written, roadmap, 'ROADMAP.md must be byte-identical on refusal');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 2 — no table at all.
|
||||||
|
test('checklist form with no Progress table refuses with missing_phase_details and writes nothing', () => {
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap: T',
|
||||||
|
'',
|
||||||
|
'## Phases',
|
||||||
|
'',
|
||||||
|
'- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler.',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, false);
|
||||||
|
assert.strictEqual(output.reason, 'missing_phase_details');
|
||||||
|
assert.strictEqual(
|
||||||
|
fs.readFileSync(roadmapPath, 'utf-8'),
|
||||||
|
roadmap,
|
||||||
|
'ROADMAP.md must be byte-identical on refusal',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 3 — isolates the non-matching cell (word-prefixed) from the plan-row trigger.
|
||||||
|
test('checklist form whose table row does not match the phase-cell grammar refuses without writing', () => {
|
||||||
|
const roadmap = buildChecklistRoadmap4247('Phase 68');
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, false);
|
||||||
|
assert.strictEqual(output.reason, 'missing_phase_details');
|
||||||
|
assert.strictEqual(
|
||||||
|
fs.readFileSync(roadmapPath, 'utf-8'),
|
||||||
|
roadmap,
|
||||||
|
'ROADMAP.md must be byte-identical on refusal',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 4 — table-form MUST keep updating exactly as today (full-file byte assertion).
|
||||||
|
test('table form with a bare-number cell still updates the row byte-exactly and touches nothing else', () => {
|
||||||
|
// Normal-form fixture (template spacing, single-line entries): the write
|
||||||
|
// seam's markdown normalization is a no-op here, so the byte-exact splice
|
||||||
|
// is observable.
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap: Mango Tree',
|
||||||
|
'',
|
||||||
|
'## Phases',
|
||||||
|
'',
|
||||||
|
'- [ ] **Phase 65: Orchard Layout** - goal: design the canopy grid. Progress: 4/4 plans executed, verified 2026-08-30.',
|
||||||
|
'',
|
||||||
|
'- [ ] **Phase 68: Scheduler** - goal: ship the picking scheduler. Progress: 1/5 plans executed.',
|
||||||
|
'',
|
||||||
|
'- [ ] **Phase 69: Packhouse** - goal: automate the line. Progress: 0/2 executed.',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'',
|
||||||
|
'| Phase | Plans Complete | Status | Completed |',
|
||||||
|
'|-------|----------------|--------|-----------|',
|
||||||
|
'| 65 | 4/4 | Complete | 2026-08-30 |',
|
||||||
|
'| 68 | 0/5 | Planned | - |',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, true, 'a matching table row is a writable target');
|
||||||
|
|
||||||
|
// Byte-exact expectation: only the Phase 68 row's three cells change
|
||||||
|
// (` 1/5 ` splice, ` In Progress` padEnd(11), Completed cleared to ` `),
|
||||||
|
// every other byte — including Phase 65/69 prose and the waves section —
|
||||||
|
// is untouched (no blank-line insertion anywhere outside the row).
|
||||||
|
const expected = roadmap.replace(
|
||||||
|
'| 68 | 0/5 | Planned | - |',
|
||||||
|
'| 68 | 1/5 | In Progress| |',
|
||||||
|
);
|
||||||
|
assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 5 — 5-column milestone-grouped table, `68. [Scheduler]` cell form.
|
||||||
|
test('milestone-grouped table form with template cell still updates by column name', () => {
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap: T',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'',
|
||||||
|
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||||||
|
'|-------|-----------|----------------|--------|-----------|',
|
||||||
|
'| 67. [Waves] | v1.0 | 0/2 | Planned | - |',
|
||||||
|
'| 68. [Scheduler] | v1.0 | 0/5 | Planned | - |',
|
||||||
|
'| 69. [Packhouse] | v1.0 | 0/2 | Planned | - |',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, true);
|
||||||
|
|
||||||
|
const expected = roadmap.replace(
|
||||||
|
'| 68. [Scheduler] | v1.0 | 0/5 | Planned | - |',
|
||||||
|
'| 68. [Scheduler] | v1.0 | 1/5 | In Progress| |',
|
||||||
|
);
|
||||||
|
assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 6/7 — heading-form targets keep every existing behavior.
|
||||||
|
test('heading-form detail section still updates counts, inserts rows, and marks checkboxes', () => {
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap: T',
|
||||||
|
'',
|
||||||
|
'## Phases',
|
||||||
|
'',
|
||||||
|
'- [ ] **Phase 68: Scheduler** - goal: ship scheduler.',
|
||||||
|
'',
|
||||||
|
'### Phase 68: Scheduler',
|
||||||
|
'**Goal**: ship it',
|
||||||
|
'**Plans**: 5 plans',
|
||||||
|
'',
|
||||||
|
'Plans:',
|
||||||
|
'- [ ] 68-02: crew assignment',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'',
|
||||||
|
'| Phase | Plans Complete | Status | Completed |',
|
||||||
|
'|-------|----------------|--------|-----------|',
|
||||||
|
'| Phase 68 | 0/5 | Planned | - |',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, true, 'a heading target is writable — no refusal');
|
||||||
|
|
||||||
|
const written = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
assert.ok(written.includes('**Plans**: 1/5 plans executed'), 'count token rewritten');
|
||||||
|
assert.ok(written.includes('- [x] 68-02: crew assignment'), 'summarized plan checkbox marked');
|
||||||
|
assert.ok(written.includes('- [ ] 68-01-PLAN.md'), 'missing plan rows inserted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 9/10/11 — boundaries inside a table must not trip the refusal.
|
||||||
|
test('first and last table rows still update; adjacent rows stay untouched', () => {
|
||||||
|
for (const cell of ['68', 'Phase 68']) {
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap: T',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'',
|
||||||
|
'| Phase | Plans Complete | Status | Completed |',
|
||||||
|
'|-------|----------------|--------|-----------|',
|
||||||
|
'| 67 | 0/2 | Planned | - |',
|
||||||
|
`| ${cell === '68' ? '68' : 'Phase 68'} | 0/5 | Planned | - |`,
|
||||||
|
'| 69 | 0/2 | Planned | - |',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
const written = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
assert.ok(written.includes('| 67 | 0/2 | Planned | - |'), 'adjacent row untouched');
|
||||||
|
assert.ok(written.includes('| 69 | 0/2 | Planned | - |'), 'adjacent row untouched');
|
||||||
|
if (cell === '68') {
|
||||||
|
assert.strictEqual(output.updated, true, 'bare cell 68 between siblings updates');
|
||||||
|
assert.ok(written.includes('| 68 | 1/5 |'), 'middle row updated');
|
||||||
|
} else {
|
||||||
|
assert.strictEqual(output.updated, false, 'word-prefixed cell is not a writable target');
|
||||||
|
assert.strictEqual(written, roadmap, 'nothing written on refusal');
|
||||||
|
}
|
||||||
|
cleanup(tmpDir);
|
||||||
|
tmpDir = createTempProject('gsd-4247-roadmap-');
|
||||||
|
roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 12 — phase exists only on disk; roadmap has no representation at all.
|
||||||
|
test('phase absent from the roadmap entirely refuses with missing_phase_details', () => {
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap: T',
|
||||||
|
'',
|
||||||
|
'## Phases',
|
||||||
|
'',
|
||||||
|
'- [ ] **Phase 65: Layout** - goal design.',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'',
|
||||||
|
'| Phase | Plans Complete | Status | Completed |',
|
||||||
|
'|-------|----------------|--------|-----------|',
|
||||||
|
'| 65 | 4/4 | Complete | 2026-08-30 |',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, false);
|
||||||
|
assert.strictEqual(output.reason, 'missing_phase_details');
|
||||||
|
assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), roadmap);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 13 — the #3957 honest no-op decline must survive for target-found reruns.
|
||||||
|
test('idempotent re-run on an up-to-date table row keeps the honest no-changes decline', () => {
|
||||||
|
const roadmap = [
|
||||||
|
'# Roadmap: T',
|
||||||
|
'',
|
||||||
|
'## Progress',
|
||||||
|
'',
|
||||||
|
'| Phase | Plans Complete | Status | Completed |',
|
||||||
|
'|-------|----------------|--------|-----------|',
|
||||||
|
'| 68 | 1/5 | In Progress| |',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.updated, false);
|
||||||
|
assert.notStrictEqual(output.reason, 'missing_phase_details', 'target found — this is a genuine no-op');
|
||||||
|
assert.ok(String(output.reason).includes('no changes were needed'), 'preserves the #3957 decline vocabulary');
|
||||||
|
assert.strictEqual(fs.readFileSync(roadmapPath, 'utf-8'), roadmap);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 8 — the completion arm: the checklist entry's own checkbox IS the
|
||||||
|
// writable phase row for a complete phase.
|
||||||
|
test('checklist form completing the phase still checks the phase bullet and marks its plan rows', () => {
|
||||||
|
const roadmap = buildChecklistRoadmap4247('Phase 68').replace(
|
||||||
|
'- [ ] **Phase 69: Packhouse**',
|
||||||
|
' - [ ] 68-01: calendar model\n - [ ] 68-02: crew assignment\n- [ ] **Phase 69: Packhouse**',
|
||||||
|
);
|
||||||
|
const p68 = seedPhase68WithPlans(tmpDir, { roadmap });
|
||||||
|
for (const n of ['01', '03', '04', '05']) {
|
||||||
|
fs.writeFileSync(path.join(p68, `68-${n}-SUMMARY.md`), '# Summary\n');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(p68, '68-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||||||
|
|
||||||
|
const result = runGsdTools('roadmap update-plan-progress 68', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.complete, true);
|
||||||
|
assert.strictEqual(output.updated, true, 'the phase bullet is a writable target when completing');
|
||||||
|
|
||||||
|
const written = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
assert.match(written, /- \[x\] \*\*Phase 68: Scheduler\*\* - goal: ship the picking scheduler\. Progress: 1\/5 plans executed; \(completed \d{4}-\d{2}-\d{2}\)/, 'phase bullet checked with completion date');
|
||||||
|
// Other phases' checklist bullets are not checked or annotated.
|
||||||
|
assert.match(written, /- \[ \] \*\*Phase 65: Orchard Layout\*\*/);
|
||||||
|
assert.match(written, /- \[ \] \*\*Phase 69: Packhouse\*\*/);
|
||||||
|
assert.ok(written.includes('- [x] 68-02: crew assignment'), 'summarized plan row marked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// phase add command
|
// phase add command
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user