* 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>
|
||||
```
|
||||
|
||||
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`)
|
||||
|
||||
`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
|
||||
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, () => {
|
||||
// #3957 (B9.4): captured BEFORE any transform runs, so the write/report
|
||||
// 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');
|
||||
let roadmapContent = originalContent;
|
||||
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
|
||||
// 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;
|
||||
|
||||
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)}`);
|
||||
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).
|
||||
// Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
|
||||
@@ -1081,7 +1127,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
||||
}
|
||||
return ' ';
|
||||
});
|
||||
if (completedResult.ok) text = completedResult.value;
|
||||
if (completedResult.ok) { text = completedResult.value; tableRowFound = true; }
|
||||
|
||||
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
|
||||
// `\n` in the first place.
|
||||
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'
|
||||
);
|
||||
const planCountText = isComplete
|
||||
@@ -1218,11 +1264,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
||||
// Pattern A: anchor to bare `Plans:` header (preferred).
|
||||
// Pattern B: fallback to bold summary when no bare header exists.
|
||||
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'
|
||||
);
|
||||
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'
|
||||
);
|
||||
|
||||
@@ -1268,10 +1314,24 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
||||
// `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content`
|
||||
// gate. Previously this wrote and reported `updated: true`
|
||||
// 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);
|
||||
updated = true;
|
||||
}
|
||||
if (!phaseRepresentationFound) {
|
||||
missingPhaseDetails = true;
|
||||
}
|
||||
});
|
||||
|
||||
const computed = {
|
||||
@@ -1284,6 +1344,18 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
||||
};
|
||||
if (updated) {
|
||||
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 {
|
||||
declineNoOp(
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user