* test(#2199): cover bullet/em-dash ROADMAP phase resolution + milestone count Adds the bullet-only ROADMAP fixture the suite lacked: an all-bullet em-dash ROADMAP resolves each phase (no Phase null), colon/en-dash/hyphen bullet separators all resolve, mixed heading + bullet forms coexist, and the milestone phase-count counts bullet-form phases instead of collapsing to zero. * fix(#2199): accept bullet/em-dash phase entries in roadmap lookup + milestone filter Roadmap phase lookup (findRoadmapPhaseInContent) matched only ATX headings against a colon-required pattern, so a bullet/checkbox entry like `- [ ] **Phase N — name**` — which the bundled roadmapper emits in bullet-house- style ROADMAPs — resolved found:false and `Phase null` was written into STATE.md. The milestone phase-filter built its phase set from headings only, so a bullet- only ROADMAP collapsed to a zero-count pass-all filter and progress denominators broke. - Add a shared bullet-phase-line pattern (separator: em-dash/en-dash/hyphen/colon). - findRoadmapPhaseInContent: on a heading-match miss, fall back to the bullet line for the requested phase; return found:true with the captured name. - getMilestonePhaseFilter: also scan bullet lines into the milestone phase set. Closes #2199 * docs(#2199): add changeset fragment * fix(#2199): bullet phase lookup as last resort + consolidate test (review) Two corrections to the initial fix: 1. Regression — the bullet fallback inside findRoadmapPhaseInContent was too eager: it returned a bullet match from the scoped (current-milestone) content before the caller tried the full-content heading path, so a phase whose Requirements live in a Phase Details heading (after the active-milestone section) got a bullet-line section with no Requirements → phase_req_ids null (broke 3 init tests). Restructure: findRoadmapPhaseInContent is heading-only again; a separate findRoadmapBulletPhaseInContent runs in getRoadmapPhaseInternal ONLY after scoped + full heading lookup fails, so a heading with a Requirements section always wins. 2. lint-test-file-count — the standalone fix-2199-roadmap-bullet-phase.test.cjs collided with the over-cap 'roadmap' module (FAIL_NOVEL_FILES). Consolidate the regression into the existing tests/roadmap-parser.test.cjs (its natural home, under the 2-file cap). * test(#2199): assert heading-in-full beats bullet-in-scoped (review L3) The exact first-attempt regression: a phase has a bullet in the active-milestone scope but its heading (carrying Requirements) lives in a Phase Details section outside that scope. Pin that the heading section wins over the bullet line so req_ids resolve and the eager-bullet bug cannot return. * docs(#2199): backfill PR number in changeset fragment
This commit is contained in:
@@ -1964,3 +1964,114 @@ describe('feat-3594: roadmap parser does not crash on ANY corpus fixture', () =>
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #2199: bullet/em-dash ROADMAP phase resolution ───────────────────────────
|
||||
// Self-contained block: phase lookup + milestone filter must accept bullet/
|
||||
// checkbox entries with an em-dash/en-dash/hyphen/colon separator, not just the
|
||||
// ATX-heading + colon form. Previously such an entry resolved found:false and
|
||||
// `Phase null` was written into STATE.md; a bullet-only ROADMAP collapsed the
|
||||
// milestone filter to a zero-count pass-all.
|
||||
{
|
||||
const { describe: d2, test: t2, beforeEach: be2, afterEach: ae2 } = require('node:test');
|
||||
const a2 = require('node:assert/strict');
|
||||
const fs2 = require('node:fs');
|
||||
const path2 = require('node:path');
|
||||
const { createTempProject: ctp2, cleanup: cu2 } = require('./helpers.cjs');
|
||||
const rp2 = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
||||
const writeRoadmap2 = (d, c) => fs2.writeFileSync(path2.join(d, '.planning', 'ROADMAP.md'), c);
|
||||
|
||||
d2('#2199 roadmap bullet/em-dash phase resolution', () => {
|
||||
let tmpDir;
|
||||
be2(() => { tmpDir = ctp2('fix-2199-'); });
|
||||
ae2(() => { cu2(tmpDir); });
|
||||
|
||||
t2('an all-bullet em-dash ROADMAP resolves each phase (no Phase null)', () => {
|
||||
writeRoadmap2(tmpDir, [
|
||||
'# Roadmap', '', '## v1.0 Active', '',
|
||||
'- [ ] **Phase 1 — Authentication**: login flow',
|
||||
'- [ ] **Phase 2 — Authorization**: RBAC',
|
||||
'- [x] **Phase 3 — Audit Logging**: events',
|
||||
'',
|
||||
].join('\n'));
|
||||
const p1 = rp2.getRoadmapPhaseInternal(tmpDir, '1');
|
||||
a2.ok(p1 && p1.found, 'Phase 1 must resolve on a bullet ROADMAP');
|
||||
a2.strictEqual(p1.phase_name, 'Authentication');
|
||||
const p2 = rp2.getRoadmapPhaseInternal(tmpDir, '2');
|
||||
a2.ok(p2 && p2.found);
|
||||
a2.strictEqual(p2.phase_name, 'Authorization');
|
||||
const p3 = rp2.getRoadmapPhaseInternal(tmpDir, '3');
|
||||
a2.ok(p3 && p3.found, 'a checked [x] bullet must also resolve');
|
||||
a2.strictEqual(p3.phase_name, 'Audit Logging');
|
||||
const absent = rp2.getRoadmapPhaseInternal(tmpDir, '99');
|
||||
a2.ok(!absent || !absent.found, 'an absent phase must not resolve');
|
||||
});
|
||||
|
||||
t2('bullet entries with colon / en-dash / hyphen separators all resolve', () => {
|
||||
writeRoadmap2(tmpDir, [
|
||||
'# Roadmap', '', '## v1.0 Active', '',
|
||||
'- [ ] **Phase 1: Colon Sep**: one',
|
||||
'- [ ] **Phase 2 – En Dash**: two',
|
||||
'- [ ] **Phase 3 - Hyphen Sep**: three',
|
||||
'',
|
||||
].join('\n'));
|
||||
a2.strictEqual(rp2.getRoadmapPhaseInternal(tmpDir, '1').phase_name, 'Colon Sep');
|
||||
a2.strictEqual(rp2.getRoadmapPhaseInternal(tmpDir, '2').phase_name, 'En Dash');
|
||||
a2.strictEqual(rp2.getRoadmapPhaseInternal(tmpDir, '3').phase_name, 'Hyphen Sep');
|
||||
});
|
||||
|
||||
t2('mixed heading + bullet forms both resolve', () => {
|
||||
writeRoadmap2(tmpDir, [
|
||||
'# Roadmap', '', '## v1.0 Active', '',
|
||||
'### Phase 1: Heading Form',
|
||||
'body',
|
||||
'- [ ] **Phase 2 — Bullet Form**: two',
|
||||
'',
|
||||
].join('\n'));
|
||||
const p1 = rp2.getRoadmapPhaseInternal(tmpDir, '1');
|
||||
a2.ok(p1 && p1.found, 'heading form still resolves (no regression)');
|
||||
a2.ok(/Heading Form/.test(p1.phase_name));
|
||||
const p2 = rp2.getRoadmapPhaseInternal(tmpDir, '2');
|
||||
a2.ok(p2 && p2.found, 'bullet form resolves alongside heading form');
|
||||
a2.strictEqual(p2.phase_name, 'Bullet Form');
|
||||
});
|
||||
|
||||
t2('milestone phase-count counts bullet-form phases (not zero)', () => {
|
||||
writeRoadmap2(tmpDir, [
|
||||
'# Roadmap', '', '## v1.0 Active', '',
|
||||
'- [ ] **Phase 1 — One**: a',
|
||||
'- [ ] **Phase 2 — Two**: b',
|
||||
'- [ ] **Phase 3 — Three**: c',
|
||||
'',
|
||||
].join('\n'));
|
||||
const filter = rp2.getMilestonePhaseFilter(tmpDir);
|
||||
a2.strictEqual(filter.phaseCount, 3,
|
||||
'a bullet-only ROADMAP must populate the milestone phase set (was a zero-count pass-all before #2199)');
|
||||
a2.ok(filter('1'), 'phase 1 dir is in the milestone set');
|
||||
a2.ok(filter('2'), 'phase 2 dir is in the milestone set');
|
||||
a2.ok(!filter('99'), 'a non-listed phase is excluded');
|
||||
});
|
||||
|
||||
t2('#2199 heading in Phase Details (full content) beats a bullet in the active scope', () => {
|
||||
// The exact first-attempt regression: a bullet for the phase exists in the
|
||||
// active-milestone scope, but the heading (carrying Requirements) lives in a
|
||||
// Phase Details section outside that scope (only in fullContent). The heading
|
||||
// MUST win — otherwise the bullet's single-line section yields null req_ids.
|
||||
writeRoadmap2(tmpDir, [
|
||||
'# Roadmap', '',
|
||||
'## Milestones', '',
|
||||
'- 🚧 **v1.0 Active** - Phases 10-11', '',
|
||||
'## v1.0 Active', '',
|
||||
'- [ ] **Phase 11 — Second Active Phase**',
|
||||
'',
|
||||
'## Phase Details', '',
|
||||
'### Phase 11: Second Active Phase',
|
||||
'**Requirements**: REQ-02, REQ-03',
|
||||
'',
|
||||
].join('\n'));
|
||||
const p11 = rp2.getRoadmapPhaseInternal(tmpDir, '11');
|
||||
a2.ok(p11 && p11.found, 'phase 11 resolves');
|
||||
a2.ok(/REQ-02/.test(p11.section),
|
||||
'the heading section (with Requirements) must win over the scoped bullet line');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user