diff --git a/.changeset/clever-quails-snooze.md b/.changeset/clever-quails-snooze.md new file mode 100644 index 000000000..ca43a8956 --- /dev/null +++ b/.changeset/clever-quails-snooze.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1456 +--- +Phase-aware commands now resolve project-code-prefixed ROADMAP headings such as MANIFOLD-117, while the roadmapper is instructed to keep project_code out of phase headings. diff --git a/agents/gsd-roadmapper.md b/agents/gsd-roadmapper.md index 02195ef4b..4971cceb7 100644 --- a/agents/gsd-roadmapper.md +++ b/agents/gsd-roadmapper.md @@ -226,6 +226,10 @@ current milestone number and a two-digit phase index within that milestone active milestone context (default: `1` for new projects). This ensures downstream tools that parse `### Phase N-NN:` headers for milestone-scoped workflows receive correctly prefixed IDs. +`project_code` is only a phase-directory prefix. Never include `project_code` in ROADMAP phase +checklist entries or detail headers. For example, even when `project_code: "PROJ"` is configured, +write `Phase 7` for `sequential` and `Phase 1-07` for `milestone-prefixed`, not `Phase PROJ-7`. + ## Granularity Calibration Read granularity from config.json. Granularity controls compression tolerance. @@ -328,6 +332,7 @@ After roadmap creation, REQUIREMENTS.md gets updated with phase mappings: ### 1. Summary Checklist (under `## Phases`) Use the form matching `phase_id_convention` from config. +Do not include `project_code` in checklist phase IDs. **Sequential (default — when absent or `"sequential"`):** @@ -348,6 +353,7 @@ Use the form matching `phase_id_convention` from config. ### 2. Detail Sections (under `## Phase Details`) Use the header form matching `phase_id_convention` from config. +Do not include `project_code` in detail header phase IDs. **Sequential (default):** diff --git a/src/phase-id.cts b/src/phase-id.cts index bee1cc6c1..79bca6a0a 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -16,10 +16,27 @@ function escapeRegex(value: unknown): string { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +// project_code values start with an uppercase letter (e.g. PROJ, APP_CODE); +// leading underscores are not valid project codes per .planning/config.json. +const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/; +const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i; +const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i; +const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?'; + +function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string { + const input = String(value); + const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE; + return input.replace(re, ''); +} + +function hasProjectCodePrefix(value: unknown): boolean { + return PROJECT_CODE_PREFIX_STRIP_RE_I.test(String(value)); +} + function normalizePhaseName(phase: unknown): string { const str = String(phase); // Strip optional project_code prefix (e.g., 'CK-01' → '01') - const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, ''); + const stripped = stripProjectCodePrefix(str, false); // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition). const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); if (milestoneMatch) { @@ -42,8 +59,7 @@ function normalizePhaseName(phase: unknown): string { } function getMilestoneFromPhaseId(phaseId: unknown): string | null { - const str = String(phaseId); - const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const stripped = stripProjectCodePrefix(phaseId); const m = stripped.match(/^0*(\d+)-\d/); if (!m) return null; const major = parseInt(m[1], 10); @@ -52,8 +68,7 @@ function getMilestoneFromPhaseId(phaseId: unknown): string | null { } function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | undefined, projectCode: string | null | undefined): string | null { - const str = String(phaseId); - const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const stripped = stripProjectCodePrefix(phaseId); const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/); if (!m) return null; const milestone = String(parseInt(m[1], 10)).padStart(2, '0'); @@ -72,7 +87,7 @@ function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | und * prose regardless of zero-padding on either side. */ function phaseMarkdownRegexSource(phaseNum: unknown): string { - const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const stripped = stripProjectCodePrefix(phaseNum); // Milestone-prefixed IDs: M-NN or M-N-N (deep). const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i); @@ -104,14 +119,14 @@ function phaseMarkdownRegexSource(phaseNum: unknown): string { */ function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null { const raw = String(phaseNum); - if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null; + if (!hasProjectCodePrefix(raw)) return null; return escapeRegex(raw); } function comparePhaseNum(a: unknown, b: unknown): number { // Strip optional project_code prefix before comparing - const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); - const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const sa = stripProjectCodePrefix(a); + const sb = stripProjectCodePrefix(b); const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); @@ -162,7 +177,7 @@ function comparePhaseNum(a: unknown, b: unknown): number { * Extract the phase token from a directory name. */ function extractPhaseToken(dirName: string): string { - const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i); + const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I); let prefix = ''; let rest = dirName; if (codePrefixMatch) { @@ -194,7 +209,7 @@ function extractPhaseToken(dirName: string): string { function phaseTokenMatches(dirName: string, normalized: string): boolean { const token = extractPhaseToken(dirName); if (token.toUpperCase() === normalized.toUpperCase()) return true; - const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const stripped = stripProjectCodePrefix(dirName); if (stripped !== dirName) { const strippedToken = extractPhaseToken(stripped); if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true; @@ -204,6 +219,8 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean { export = { escapeRegex, + OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, + stripProjectCodePrefix, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, diff --git a/src/phase.cts b/src/phase.cts index 857be90d5..50eb51334 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -29,7 +29,14 @@ import coreUtilsMod = require('./core-utils.cjs'); const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod; // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches } = phaseIdMod; +const { + escapeRegex, + normalizePhaseName, + phaseMarkdownRegexSource, + comparePhaseNum, + phaseTokenMatches, + OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, +} = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod; @@ -194,7 +201,7 @@ function cmdPhaseNextDecimal(cwd: string, basePhase: string, raw: boolean): void const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); baseExists = dirs.some((d) => phaseTokenMatches(d, normalized)); - const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`); + const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`); for (const dir of dirs) { const match = dir.match(dirPattern); if (match) decimalSet.add(parseInt(match[1], 10)); @@ -360,7 +367,7 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void { if (!match) continue; const dirMatch = - match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) || + match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; @@ -908,7 +915,7 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); const decimalPattern = new RegExp( - `^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`, + `^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`, ); for (const dir of dirs) { const dm = dir.match(decimalPattern); diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 76e96b3ba..862920be3 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -19,7 +19,13 @@ import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdModule = require('./phase-id.cjs'); -const { escapeRegex, phaseMarkdownRegexSource } = phaseIdModule; +const { + escapeRegex, + phaseMarkdownRegexSource, + phaseMarkdownRegexSourceExact, + stripProjectCodePrefix, + OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, +} = phaseIdModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir } = planningWorkspace; @@ -202,9 +208,9 @@ interface RoadmapPhaseResult { section: string; } -function findRoadmapPhaseInContent(content: string, phaseNum: unknown): RoadmapPhaseResult | null { +function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null { const phasePattern = new RegExp( - `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, + `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i' ); const headerMatch = content.match(phasePattern); @@ -229,6 +235,23 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown): RoadmapP }; } +function roadmapPhaseLookupSources(phaseNum: unknown): string[] { + const sources: string[] = []; + const exactSource = phaseMarkdownRegexSourceExact(phaseNum); + if (exactSource) sources.push(exactSource); + + const numericSource = phaseMarkdownRegexSource(phaseNum); + // Source order matters: the bare numeric source is tried before the + // prefix-tolerant form so that a canonical bare heading ("Phase 117:") is + // preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when + // both exist in the same ROADMAP. The prefix-tolerant form is the fallback + // that handles the drifted-only case. + sources.push(numericSource); + sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`); + + return [...new Set(sources)]; +} + function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null { if (!phaseNum) return null; const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); @@ -238,10 +261,17 @@ function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseRe const roadmapRaw = platformReadSync(roadmapPath); if (roadmapRaw === null) throw new Error('missing'); const content = extractCurrentMilestone(roadmapRaw, cwd); - const scopedResult = findRoadmapPhaseInContent(content, phaseNum); - if (scopedResult) return scopedResult; + const fullContent = stripShippedMilestones(roadmapRaw); - return findRoadmapPhaseInContent(stripShippedMilestones(roadmapRaw), phaseNum); + for (const source of roadmapPhaseLookupSources(phaseNum)) { + const scopedResult = findRoadmapPhaseInContent(content, phaseNum, source); + if (scopedResult) return scopedResult; + + const fullResult = findRoadmapPhaseInContent(fullContent, phaseNum, source); + if (fullResult) return fullResult; + } + + return null; } catch { return null; } @@ -438,7 +468,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true; const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/); if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true; - const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const stripped = stripProjectCodePrefix(dirName); if (stripped !== dirName) { const sm = stripped.match(numericRe); if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true; diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index 6f07b0ad4..ba5969f79 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -12,7 +12,10 @@ import path from 'node:path'; import { execSync } from 'node:child_process'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('./phase-id.cjs'); const { planningDir } = planningWorkspace; +const { stripProjectCodePrefix } = phaseIdMod; // ─── Regex helpers ──────────────────────────────────────────────────────────── @@ -165,7 +168,7 @@ function assignSubIndices(phaseEntries: ParsedPhaseEntry[]): Map { assert.strictEqual(output.has_plans, false); }); + test('fallback resolves drifted project-code-prefixed roadmap heading by bare number (#1455)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase MANIFOLD-117: Prefixed Heading\n**Goal:** Build prefixed phase\n**Plans:** TBD\n' + ); + + const result = runGsdTools('init phase-op 117', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true); + assert.strictEqual(output.phase_dir, null); + assert.strictEqual(output.phase_number, '117'); + assert.strictEqual(output.phase_name, 'Prefixed Heading'); + assert.strictEqual(output.phase_slug, 'prefixed-heading'); + }); + + test('fallback resolves drifted project-code-prefixed roadmap heading by prefixed ID (#1455)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase MANIFOLD-117: Prefixed Heading\n**Goal:** Build prefixed phase\n**Plans:** TBD\n' + ); + + const result = runGsdTools('init phase-op MANIFOLD-117', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true); + assert.strictEqual(output.phase_dir, null); + assert.strictEqual(output.phase_number, 'MANIFOLD-117'); + assert.strictEqual(output.phase_name, 'Prefixed Heading'); + assert.strictEqual(output.phase_slug, 'prefixed-heading'); + }); + test('prefers current milestone roadmap entry over archived phase with same number', () => { const archiveDir = path.join( tmpDir, diff --git a/tests/phase-id.test.cjs b/tests/phase-id.test.cjs index 615aaa1eb..957723c8e 100644 --- a/tests/phase-id.test.cjs +++ b/tests/phase-id.test.cjs @@ -85,6 +85,16 @@ describe('normalizePhaseName', () => { assert.strictEqual(phaseId.normalizePhaseName('CK-01'), '01'); assert.strictEqual(phaseId.normalizePhaseName('PROJ-3'), '03'); assert.strictEqual(phaseId.normalizePhaseName('AB-12'), '12'); + assert.strictEqual(phaseId.normalizePhaseName('MANIFOLD-7'), '07'); + assert.strictEqual(phaseId.normalizePhaseName('APP1-7'), '07'); + assert.strictEqual(phaseId.normalizePhaseName('APP_1-7'), '07'); + }); + + test('does not strip leading-underscore pseudo-prefix (#1455)', () => { + // Valid project_code values must start with [A-Z]; leading underscores + // (_FOO-7, _-7) are not valid codes and must not be stripped. + assert.strictEqual(phaseId.normalizePhaseName('_FOO-7'), '_FOO-7'); + assert.strictEqual(phaseId.normalizePhaseName('_-7'), '_-7'); }); test('handles letter suffix (preserves original case per #1962)', () => { @@ -104,10 +114,10 @@ describe('normalizePhaseName', () => { }); test('custom phase IDs: project_code prefix is stripped, then numeric part is normalized', () => { - // The regex /^[A-Z]{1,6}-(?=\d)/ matches 'PROJ-' and strips it, leaving '42' - // which is then normalized to '42' (no leading zero needed for 2+ digits) + // The project-code prefix is stripped, leaving a numeric token that normalizes to '42' (no leading zero needed for 2+ digits). assert.strictEqual(phaseId.normalizePhaseName('PROJ-42'), '42'); assert.strictEqual(phaseId.normalizePhaseName('AUTH-101'), '101'); + assert.strictEqual(phaseId.normalizePhaseName('MANIFOLD-117'), '117'); }); test('custom phase IDs with non-numeric remainder pass through as-is', () => { @@ -158,6 +168,9 @@ describe('comparePhaseNum', () => { test('strips project_code prefix before comparing', () => { assert.strictEqual(phaseId.comparePhaseNum('CK-01', '01'), 0); assert.ok(phaseId.comparePhaseNum('CK-01', 'CK-02') < 0); + assert.strictEqual(phaseId.comparePhaseNum('MANIFOLD-117', '117'), 0); + assert.strictEqual(phaseId.comparePhaseNum('APP1-117', '117'), 0); + assert.strictEqual(phaseId.comparePhaseNum('APP_1-117', '117'), 0); }); test('handles non-parseable phase IDs via localeCompare fallback', () => { @@ -183,6 +196,9 @@ describe('extractPhaseToken', () => { test('extracts token with project_code prefix', () => { assert.strictEqual(phaseId.extractPhaseToken('CK-01-some-phase'), 'CK-01'); assert.strictEqual(phaseId.extractPhaseToken('PROJ-12-feature'), 'PROJ-12'); + assert.strictEqual(phaseId.extractPhaseToken('MANIFOLD-117-feature'), 'MANIFOLD-117'); + assert.strictEqual(phaseId.extractPhaseToken('APP1-117-feature'), 'APP1-117'); + assert.strictEqual(phaseId.extractPhaseToken('APP_1-117-feature'), 'APP_1-117'); }); test('extracts glued letter-prefix phase tokens (#1324)', () => { @@ -215,6 +231,9 @@ describe('phaseTokenMatches', () => { test('matches with project_code prefix stripped', () => { assert.ok(phaseId.phaseTokenMatches('CK-01-phase', '01')); assert.ok(phaseId.phaseTokenMatches('PROJ-12-feature', '12')); + assert.ok(phaseId.phaseTokenMatches('MANIFOLD-117-feature', '117')); + assert.ok(phaseId.phaseTokenMatches('APP1-117-feature', '117')); + assert.ok(phaseId.phaseTokenMatches('APP_1-117-feature', '117')); }); test('matches glued letter-prefix phase dirs (#1324)', () => { @@ -281,6 +300,7 @@ describe('phaseMarkdownRegexSource', () => { const withPrefix = phaseId.phaseMarkdownRegexSource('CK-01'); const withoutPrefix = phaseId.phaseMarkdownRegexSource('01'); assert.strictEqual(withPrefix, withoutPrefix); + assert.strictEqual(phaseId.phaseMarkdownRegexSource('MANIFOLD-117'), phaseId.phaseMarkdownRegexSource('117')); }); test('falls back to escaped literal for unparseable input', () => { @@ -307,6 +327,9 @@ describe('phaseMarkdownRegexSourceExact', () => { assert.strictEqual(result, 'PROJ-42'); // The result is a valid regex source assert.doesNotThrow(() => new RegExp(result)); + assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('MANIFOLD-117'), 'MANIFOLD-117'); + assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP1-117'), 'APP1-117'); + assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP_1-117'), 'APP_1-117'); }); test('returns null for non-prefixed IDs', () => { @@ -350,6 +373,9 @@ describe('getMilestoneFromPhaseId', () => { test('strips project_code prefix before parsing', () => { assert.strictEqual(phaseId.getMilestoneFromPhaseId('CK-2-01'), 'v2.0'); + assert.strictEqual(phaseId.getMilestoneFromPhaseId('MANIFOLD-2-01'), 'v2.0'); + assert.strictEqual(phaseId.getMilestoneFromPhaseId('APP1-2-01'), 'v2.0'); + assert.strictEqual(phaseId.getMilestoneFromPhaseId('APP_1-2-01'), 'v2.0'); }); test('coerces non-string values', () => { @@ -384,6 +410,9 @@ describe('getPhaseDirFromPhaseId', () => { test('strips project_code from phaseId before parsing', () => { const result = phaseId.getPhaseDirFromPhaseId('CK-1-2', null, null); assert.strictEqual(result, '01-02'); + assert.strictEqual(phaseId.getPhaseDirFromPhaseId('MANIFOLD-1-2', null, null), '01-02'); + assert.strictEqual(phaseId.getPhaseDirFromPhaseId('APP1-1-2', null, null), '01-02'); + assert.strictEqual(phaseId.getPhaseDirFromPhaseId('APP_1-1-2', null, null), '01-02'); }); test('handles deep decomposition IDs (M-N-N)', () => { diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index 53518884f..b423447b1 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -250,6 +250,52 @@ describe('roadmap-parser: getRoadmapPhaseInternal', () => { assert.strictEqual(result.goal, 'Set up infrastructure'); }); + test('finds drifted project-code-prefixed headings by bare number (#1455)', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Current', + '### Phase MANIFOLD-117: Prefixed Heading', + '**Goal:** Recover from roadmapper heading drift', + ].join('\n')); + + const result = getRoadmapPhaseInternal(tmpDir, '117'); + assert.ok(result !== null, 'bare number lookup should tolerate a prefixed heading'); + assert.strictEqual(result.found, true); + assert.strictEqual(result.phase_number, '117'); + assert.strictEqual(result.phase_name, 'Prefixed Heading'); + assert.strictEqual(result.goal, 'Recover from roadmapper heading drift'); + }); + + test('finds drifted project-code-prefixed headings by prefixed query (#1455)', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Current', + '### Phase MANIFOLD-117: Prefixed Heading', + '**Goal:** Exact prefixed lookup works on init resolver', + ].join('\n')); + + const result = getRoadmapPhaseInternal(tmpDir, 'MANIFOLD-117'); + assert.ok(result !== null, 'prefixed lookup should resolve the matching prefixed heading'); + assert.strictEqual(result.found, true); + assert.strictEqual(result.phase_number, 'MANIFOLD-117'); + assert.strictEqual(result.phase_name, 'Prefixed Heading'); + assert.strictEqual(result.goal, 'Exact prefixed lookup works on init resolver'); + }); + + test('prefers canonical bare heading before prefixed drift fallback (#1455)', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Current', + '### Phase MANIFOLD-117: Prefixed Heading', + '**Goal:** Drift fallback', + '', + '### Phase 117: Bare Heading', + '**Goal:** Canonical bare', + ].join('\n')); + + const result = getRoadmapPhaseInternal(tmpDir, '117'); + assert.ok(result !== null, 'bare lookup should resolve'); + assert.strictEqual(result.phase_name, 'Bare Heading'); + assert.strictEqual(result.goal, 'Canonical bare'); + }); + test('returns null for missing phase number', () => { writeRoadmap(tmpDir, '### Phase 1: Foo\n**Goal:** bar\n'); const result = getRoadmapPhaseInternal(tmpDir, '99'); diff --git a/tests/roadmapper-granularity.test.cjs b/tests/roadmapper-granularity.test.cjs index fd47548f8..c7a067522 100644 --- a/tests/roadmapper-granularity.test.cjs +++ b/tests/roadmapper-granularity.test.cjs @@ -114,4 +114,19 @@ describe('gsd-roadmapper phase_id_convention support (#1205)', () => { 'phase_identification block must document that sequential is the default/fallback' ); }); + + test('phase headings and checklists must not include project_code (#1455)', () => { + const phaseIdentification = extractBlock(content, 'phase_identification'); + const outputFormats = extractBlock(content, 'output_formats'); + const combined = `${phaseIdentification}\n${outputFormats}`; + + assert.ok( + combined.includes('project_code'), + 'roadmapper instructions must explicitly mention project_code' + ); + assert.ok( + /project_code[\s\S]{0,120}Never include|Do not include `project_code`/.test(combined), + 'roadmapper must state that project_code is not part of phase headings/checklists' + ); + }); });