From 0a12b06381c1cf3a85c25237d4a770adcf4b4ca8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 31 May 2026 23:15:55 -0400 Subject: [PATCH] feat(#39): milestone-prefixed phase IDs (M-NN convention) + migration tool + validation (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#39): milestone-prefixed phase IDs (M-NN convention) + migration tool + validation - Add getMilestoneFromPhaseId() / getPhaseDirFromPhaseId() helpers to core.cjs - Fix isDirInMilestone to match M-NN-style dirs (02-01-setup) against M-NN ROADMAP headings - Extend heading regex to tolerate [bracket-token] scope prefix on phase headings - Add W021 validation rule for milestone prefix mismatch - Add gsd-tools roadmap validate + roadmap upgrade --convention milestone-prefixed - Add phase_id_convention config field (null default, backwards-compatible) Co-Authored-By: Claude Sonnet 4.6 * fix(#39): address 4 Codex review findings in milestone-prefixed phase ID implementation - getMilestoneFromPhaseId: tighten regex to require a digit after the hyphen (rejects '1-' and '1-abc') - isDirInMilestone: use convention-aware regex — only capture M-NN segments when ROADMAP itself uses hyphenated phase IDs, preventing legacy dirs like '01-02-setup' from being misread as phase '1-02' - checkW021: add UNPREFIXED_PHASE_RE path so unprefixed headings (### Phase 1:) also fire W021 when convention is milestone-prefixed - roadmap-upgrade: remove isMigratedDirName dir-name check (false-positive for legacy dirs); config + ROADMAP heading checks at lines 194 and 212 are sufficient Co-Authored-By: Claude Sonnet 4.6 * chore: update changeset pr reference to #565 * fix(#39): restore phaseDirNameRe 2-digit minimum; add roadmap-upgrade to inventory - validate.cjs: \d{1,} → \d{2,} to keep single-digit prefix rejection per W005 contract - docs/INVENTORY.md: 79 → 80, add roadmap-upgrade.cjs row - docs/INVENTORY-MANIFEST.json: regenerated (roadmap-upgrade.cjs entry) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/steady-pandas-purr.md | 5 + CHANGELOG.md | 11 + docs/COMMANDS.md | 34 ++ docs/CONFIGURATION.md | 1 + docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- get-shit-done/bin/gsd-tools.cjs | 2 + get-shit-done/bin/lib/command-aliases.cjs | 16 + get-shit-done/bin/lib/commands.cjs | 15 +- get-shit-done/bin/lib/core.cjs | 211 ++++++- .../bin/lib/roadmap-command-router.cjs | 134 +++++ get-shit-done/bin/lib/roadmap-upgrade.cjs | 569 ++++++++++++++++++ get-shit-done/bin/lib/roadmap.cjs | 11 +- get-shit-done/bin/lib/validate.cjs | 60 +- get-shit-done/bin/lib/verify.cjs | 65 +- .../bin/shared/config-defaults.manifest.json | 1 + .../bin/shared/config-schema.manifest.json | 1 + tests/backwards-compat-phase-id.test.cjs | 187 ++++++ tests/milestone-helper.test.cjs | 90 +++ tests/milestone-prefixed-convention.test.cjs | 235 ++++++++ tests/roadmap-command-router.test.cjs | 2 +- 21 files changed, 1593 insertions(+), 63 deletions(-) create mode 100644 .changeset/steady-pandas-purr.md create mode 100644 get-shit-done/bin/lib/roadmap-upgrade.cjs create mode 100644 tests/backwards-compat-phase-id.test.cjs create mode 100644 tests/milestone-helper.test.cjs create mode 100644 tests/milestone-prefixed-convention.test.cjs diff --git a/.changeset/steady-pandas-purr.md b/.changeset/steady-pandas-purr.md new file mode 100644 index 000000000..1a940923b --- /dev/null +++ b/.changeset/steady-pandas-purr.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 565 +--- +**Milestone-prefixed phase ID convention (`Phase M-NN`) with migration tool and validation** — introduces globally unique phase IDs within a project, resolving cross-session reference ambiguity behind bugs #3537/#3287/#3297/#3298. Adds `getMilestoneFromPhaseId()` / `getPhaseDirFromPhaseId()` helpers to `core.cjs`, fixes `isDirInMilestone` to correctly match `GSD-02-01-setup` style dirs, extends heading regex to tolerate `[bracket-token]` scope prefixes, adds W021 validation rule for milestone-prefix mismatch, adds `gsd-tools roadmap validate` and `roadmap upgrade --convention milestone-prefixed` commands, and introduces the `phase_id_convention` config field (`null` default, fully backwards-compatible). Closes #39. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b27a6495..87af8e3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- Milestone-prefixed phase ID convention (M-NN) for globally unique phase IDs within a project (#39) +- `getMilestoneFromPhaseId()` and `getPhaseDirFromPhaseId()` helpers in core.cjs (#39) +- W021 validation rule: fires when a phase ID's integer prefix mismatches its enclosing milestone section (#39) +- `gsd-tools roadmap validate` subcommand for convention compliance checking (#39) +- `gsd-tools roadmap upgrade --convention milestone-prefixed` migration tool (dry-run by default, `--apply` to mutate) (#39) +- `phase_id_convention` config field (`null` | `'milestone-prefixed'` | `'free-form'`), defaults to `null` (legacy free-form, no breaking change) (#39) + ### Fixed +- `isDirInMilestone` now correctly matches M-NN-style phase directories against milestone-prefixed ROADMAP headings (#39) +- `searchPhaseInContent` heading regex now tolerates `[bracket-token]` scope prefix (e.g., `### [GSD] Phase 2-01:`) (#39) - **README version guidance now uses npm/package metadata as the source of truth** — README, localized READMEs, and the docs index no longer present archived release-note or canary-stream numbers as the current GSD Core package version. (#545) ## [1.2.0](https://www.npmjs.com/package/@opengsd/gsd-core/v/1.2.0) - 2026-05-31 diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 69bdf45c4..e8402a1a3 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1369,6 +1369,40 @@ Threads are lightweight cross-session knowledge stores for work that spans multi --- +## Roadmap Management Commands + +### `roadmap validate` + +Validate ROADMAP.md for structural integrity, including milestone-prefix consistency. + +**Prerequisites:** `.planning/ROADMAP.md` exists +**Produces:** Validation report; exits non-zero on any error or warning + +```bash +node gsd-tools.cjs roadmap validate +``` + +--- + +### `roadmap upgrade --convention milestone-prefixed` + +Migrate legacy `Phase N` IDs to the milestone-prefixed `Phase M-NN` convention. + +| Flag | Required | Description | +|------|----------|-------------| +| `--convention milestone-prefixed` | Yes | Target convention to migrate to | +| `--apply` | No | Write changes to disk (default: dry-run only) | + +**Prerequisites:** `.planning/ROADMAP.md` exists +**Produces:** Dry-run diff (default) or in-place ROADMAP.md rewrite (`--apply`) + +```bash +node gsd-tools.cjs roadmap upgrade --convention milestone-prefixed # dry-run +node gsd-tools.cjs roadmap upgrade --convention milestone-prefixed --apply # apply +``` + +--- + ## State Management Commands ### `state validate` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 699e497ab..2745ac932 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -145,6 +145,7 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new | `dynamic_routing.escalate_on_failure` | boolean | `true`, `false` | `true` | When `false`, escalation is disabled even if `enabled: true` — every attempt uses the default tier. Added in v1.40 | | `dynamic_routing.max_escalations` | integer | `0`, `1`, `2`, … | `1` | Hard cap on retries per agent invocation. Beyond the cap the resolver returns the cap-tier model. Added in v1.40 | | `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 | +| `phase_id_convention` | enum | `"milestone-prefixed"`, `null` | `null` | Phase ID naming convention. `null` = legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` = globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`). Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. | | `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32 | | `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-opus-4-7[1m]`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-config --advanced`. | | `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 0a1c4bd64..d3f3b3fcb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-05-31", + "generated": "2026-06-01", "families": { "agents": [ "gsd-advisor-researcher", @@ -311,6 +311,7 @@ "prompt-budget.cjs", "review-reviewer-selection.cjs", "roadmap-command-router.cjs", + "roadmap-upgrade.cjs", "roadmap.cjs", "runtime-artifact-layout.cjs", "runtime-homes.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 36e97f8aa..a8ff3c9d5 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -362,7 +362,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (79 shipped) +## CLI Modules (80 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -419,6 +419,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) | | `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence | | `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` | +| `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | | `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) | | `runtime-name-policy.cjs` | Runtime name normalization policy — canonical token sanitization for runtime identifiers used in path construction and display | diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index f6c9302af..0c6db92a9 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -46,6 +46,8 @@ * roadmap analyze Full roadmap parse with disk status * roadmap update-plan-progress Update progress table row from disk (PLAN vs SUMMARY counts) * roadmap annotate-dependencies Add wave dependency notes + cross-cutting constraints to ROADMAP.md + * roadmap validate Validate phase ID convention compliance + * roadmap upgrade [--apply] --convention milestone-prefixed Migrate phase IDs to M-NN convention * * Requirements Operations: * requirements mark-complete Mark requirement IDs as complete in REQUIREMENTS.md diff --git a/get-shit-done/bin/lib/command-aliases.cjs b/get-shit-done/bin/lib/command-aliases.cjs index 61852efa7..2985fdc49 100644 --- a/get-shit-done/bin/lib/command-aliases.cjs +++ b/get-shit-done/bin/lib/command-aliases.cjs @@ -540,6 +540,22 @@ const ROADMAP_COMMAND_ALIASES = [ ], "subcommand": "annotate-dependencies", "mutation": true + }, + { + "canonical": "roadmap.validate", + "aliases": [ + "roadmap validate" + ], + "subcommand": "validate", + "mutation": false + }, + { + "canonical": "roadmap.upgrade", + "aliases": [ + "roadmap upgrade" + ], + "subcommand": "upgrade", + "mutation": true } ]; diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index fa1a8c3e1..678f9a90e 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const { execGit, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs'); -const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs'); +const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal, extractPhaseToken } = require('./core.cjs'); const { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } = require('./model-catalog.cjs'); const { planningDir, planningPaths } = require('./planning-workspace.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); @@ -994,7 +994,9 @@ function cmdStats(cwd, format, raw) { const roadmapRaw = platformReadSync(roadmapPath); if (roadmapRaw === null) throw new Error('roadmap missing'); const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd); - const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings. + // Also tolerates optional [bracket-token] scope prefix on phase headings. + const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:\s*([^\n]+)/gi; let match; while ((match = headingPattern.exec(roadmapContent)) !== null) { const key = normalizePhaseName(match[1]); @@ -1017,9 +1019,12 @@ function cmdStats(cwd, format, raw) { .sort((a, b) => comparePhaseNum(a, b)); for (const dir of dirs) { - const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); - const phaseNum = dm ? dm[1] : dir; - const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : ''; + // Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names. + const phaseToken = extractPhaseToken(dir); + const phaseNum = phaseToken || dir; + // phaseName is everything after the token (strip leading '-') + const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, ''); + const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : ''; const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 53efd9843..819af8ec5 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -668,6 +668,18 @@ function normalizePhaseName(phase) { const str = String(phase); // Strip optional project_code prefix (e.g., 'CK-01' → '01') const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, ''); + // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition). + // Examples: '2-01', '02-01', '2-4-1', '02-04-01'. + // Must be tested BEFORE the plain numeric path so '2-01' → '02-01', not '02'. + // Pattern: at least two dash-separated all-digit segments (letter/decimal suffix on last). + const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); + if (milestoneMatch) { + const major = milestoneMatch[1].padStart(2, '0'); + // Each sub-segment gets zero-padded to at least 2 digits. + const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0')); + const suffix = milestoneMatch[3] || ''; + return `${major}-${subSegments.join('-')}${suffix}`; + } // Standard numeric phases: 1, 01, 12A, 12.1 const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (match) { @@ -683,6 +695,32 @@ function normalizePhaseName(phase) { return str; } +function getMilestoneFromPhaseId(phaseId) { + const str = String(phaseId); + const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const m = stripped.match(/^0*(\d+)-\d/); + if (!m) return null; + const major = parseInt(m[1], 10); + if (major === 0 || major === 999) return null; + return `v${major}.0`; +} + +function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) { + const str = String(phaseId); + const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/); + if (!m) return null; + const milestone = String(parseInt(m[1], 10)).padStart(2, '0'); + const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0')); + const sub = subParts.join('-'); + const slug = phaseName + ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + : ''; + const parts = [milestone, sub, slug].filter(Boolean); + const base = parts.join('-'); + return projectCode ? `${projectCode}-${base}` : base; +} + /** * Render a regex source fragment matching a phase number against ROADMAP/STATE * prose regardless of zero-padding on either side. Skills pass the resolved @@ -699,6 +737,24 @@ function normalizePhaseName(phase) { */ function phaseMarkdownRegexSource(phaseNum) { const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + + // Milestone-prefixed IDs: M-NN or M-N-N (deep). Each numeric segment is padding-tolerant. + // Pattern: one or more dash-separated all-digit groups (last may have letter/decimal suffix). + const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i); + if (milestoneSegments && milestoneSegments[2]) { + // Has at least one dash-separated segment — treat as milestone-prefixed + const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0'; + const subParts = milestoneSegments[2].slice(1).split('-'); // drop leading '-' + const subFragments = subParts.map(s => { + const unpadded = s.replace(/^0+/, '') || '0'; + return `0*${escapeRegex(unpadded)}`; + }); + const suffix = milestoneSegments[3] || ''; + const suffixFragment = suffix ? escapeRegex(suffix) : ''; + return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`; + } + + // Plain numeric phase: 1, 01, 12A, 12.1 const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i); if (!match) return escapeRegex(phaseNum); @@ -729,8 +785,35 @@ function phaseMarkdownRegexSourceExact(phaseNum) { function comparePhaseNum(a, b) { // Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name') - const sa = String(a).replace(/^[A-Z]{1,6}-/, ''); - const sb = String(b).replace(/^[A-Z]{1,6}-/, ''); + const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + + // Milestone-prefixed IDs: one or more dash-separated all-digit segments. + // e.g. '02-10', '2-01', '02-04-01'. Compare segment by segment numerically. + // A string matches this form when it starts with digits and has at least one '-digit' group. + const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); + const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); + + if (milestoneA && milestoneB) { + const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))]; + const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))]; + const maxSegs = Math.max(segsA.length, segsB.length); + for (let i = 0; i < maxSegs; i++) { + const av = segsA[i] !== undefined ? segsA[i] : 0; + const bv = segsB[i] !== undefined ? segsB[i] : 0; + if (av !== bv) return av - bv; + } + // Segments equal — compare any trailing letter/decimal suffix + const sufA = milestoneA[3] || ''; + const sufB = milestoneB[3] || ''; + if (sufA !== sufB) return sufA < sufB ? -1 : 1; + return 0; + } + + // If one is milestone-prefixed and the other is not, milestone-prefixed sorts first + // (they come from different conventions; preserve caller's intent by string comparison). + if (milestoneA || milestoneB) return String(a).localeCompare(String(b)); + const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); // If either is non-numeric (custom ID), fall back to string comparison @@ -761,20 +844,59 @@ function comparePhaseNum(a, b) { /** * Extract the phase token from a directory name. - * Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'. - * Returns the token portion (e.g. '01', '1009A', '999.6', 'PROJ-42') or the full name if no separator. + * A token is the leading all-numeric (or project-code-prefixed) run of dash-separated + * segments, up to but not including the first segment that starts with a letter after the + * optional code prefix. The last numeric segment may carry a letter suffix (e.g. 12A) or + * decimal suffix (e.g. 999.6). + * + * Examples: + * '01-name' → '01' + * '02-01-setup' → '02-01' (milestone-prefixed 2-segment) + * '02-04-01-deep' → '02-04-01' (deep 3-segment) + * 'CK-01-name' → 'CK-01' (project-code-prefixed) + * 'GSD-02-01-setup' → 'GSD-02-01' (code-prefixed milestone) + * 'GSD-02-04-01-deep' → 'GSD-02-04-01' + * '1009A-name' → '1009A' + * '999.6-name' → '999.6' + * 'PROJ-42-name' → 'PROJ-42' (custom ID) */ function extractPhaseToken(dirName) { - // Try project-code-prefixed numeric: CK-01-name → CK-01, CK-01A.2-name → CK-01A.2 - const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i); - if (codePrefixed) return codePrefixed[1]; - // Try plain numeric: 01-name, 1009A-name, 999.6-name - const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i); - if (numeric) return numeric[1]; - // Custom IDs: PROJ-42-name → everything before the last segment that looks like a name - const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i); - if (custom) return custom[1]; - return dirName; + // Optional project-code prefix: 1–6 uppercase letters followed by a digit segment. + const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i); + let prefix = ''; + let rest = dirName; + if (codePrefixMatch) { + // Distinguish code prefix (e.g. GSD-, CK-) from purely numeric-looking start. + // The prefix must be all-uppercase-letter (already guaranteed by [A-Z]{1,6}) and + // the first char after '-' must be a digit so we don't swallow PROJ-42-name prematurely. + prefix = codePrefixMatch[1] + '-'; + rest = codePrefixMatch[2]; + } + + // Greedily consume all leading all-digit segments (possibly with A-Z letter or .N suffix on the last). + // Stop when a segment starts with a letter (that is not a continuation of the last digit segment). + const segments = rest.split('-'); + const tokenSegments = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (/^\d/.test(seg)) { + // Numeric segment (possibly trailing letter or .N suffix on last) — always include + tokenSegments.push(seg); + } else { + // First letter-start segment after digits → name portion starts here + break; + } + } + + if (tokenSegments.length === 0) { + // No leading numeric segment — could be a custom ID like PROJ-42 + // If we stripped a code prefix, return the full original (prefix stripped the code but rest is numeric handled above) + // For purely letter-start directory with no code prefix (shouldn't normally happen), return as-is + return dirName; + } + + // The last numeric segment may have a letter suffix (1009A) or decimal (.6) already included. + return prefix + tokenSegments.join('-'); } /** @@ -811,13 +933,13 @@ function searchPhaseInDir(baseDir, relBase, normalized) { const match = dirs.find(d => phaseTokenMatches(d, normalized)); if (!match) return null; - // Extract phase number and name — supports numeric (01-name), project-code-prefixed (CK-01-name), and custom (PROJ-42-name) - const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) - || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) - || match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i) - || [null, match, null]; - const phaseNumber = dirMatch ? dirMatch[1] : normalized; - const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; + // Extract phase number and name using extractPhaseToken for correctness with all ID forms + // including deep milestone-prefixed (02-04-01-deep → 02-04-01 / deep) and code-prefixed. + const phaseToken = extractPhaseToken(match); + const phaseNumber = phaseToken || normalized; + // phase_name is everything after the token (strip leading '-') + const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, ''); + const phaseName = afterToken || null; const phaseDir = path.join(baseDir, match); const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir); const plans = unsortedPlans.sort(); @@ -1140,8 +1262,9 @@ function getRoadmapPhaseInternal(cwd, phaseNum) { // #3537: route through canonical padding-tolerant fragment. The prior // hand-rolled `isNumeric` branch only stripped padding on integer-only // ids and missed decimal padding (`02.7` against `Phase 2.7:` headings). + // Also tolerate optional [bracket-token] scope prefix on phase headings. const phasePattern = new RegExp( - `#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, + `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i' ); const headerMatch = content.match(phasePattern); @@ -1150,7 +1273,8 @@ function getRoadmapPhaseInternal(cwd, phaseNum) { const phaseName = headerMatch[1].trim(); const headerIndex = headerMatch.index; const restOfContent = content.slice(headerIndex); - const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i); + // Boundary: next phase heading — also matches bracket-prefixed form. + const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i); const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length; const section = content.slice(headerIndex, sectionEnd).trim(); @@ -1926,6 +2050,18 @@ function getMilestonePhaseFilter(cwd, versionOverride) { if (roadmapContent === null) throw new Error('missing'); let roadmap = extractCurrentMilestone(roadmapContent, cwd); + // Emit a deprecation warning for "free-form" roadmaps: those that have + // Phase headings but no versioned milestone sections (## vX.Y / ## Roadmap vX.Y). + // This is non-fatal — the roadmap continues to work via legacy behaviour. + const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent); + const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent); + if (!hasVersionedMilestonesGlobal && hasPhaseHeadings) { + console.warn( + '[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' + + 'Set phase_id_convention in config.json to suppress this warning.' + ); + } + if (versionOverride) { const escapedVersion = escapeRegex(versionOverride); // Exclude phase headings (e.g. "### Phase 1: v1.3 migration") that mention @@ -2002,8 +2138,9 @@ function getMilestonePhaseFilter(cwd, versionOverride) { } } - // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:) - const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; + // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:). + // Also tolerate optional [bracket-token] scope prefix (e.g., ### [GSD] Phase 2-01:). + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; let m; while ((m = phasePattern.exec(roadmap)) !== null) { milestonePhaseNums.add(m[1]); @@ -2018,13 +2155,25 @@ function getMilestonePhaseFilter(cwd, versionOverride) { } const normalized = new Set( - [...milestonePhaseNums].map(n => (n.replace(/^0+(?=\d)/, '') || '0').toLowerCase()) + [...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase()) ); + function normalizePhaseIdSegments(id) { + return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-'); + } + + // Only capture hyphenated M-NN segments when the ROADMAP itself uses that convention. + // Legacy ROADMAPs with phase IDs like '1' must use the simple first-segment regex or + // a legacy dir like '01-02-setup' (phase 1, slug '02-setup') would match as '1-02'. + const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-')); + const numericRe = roadmapUsesHyphenedIds + ? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/ + : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/; + function isDirInMilestone(dirName) { // Try numeric match first - const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); - if (m && normalized.has(m[1].toLowerCase())) return true; + const m = dirName.match(numericRe); + if (m && normalized.has(normalizePhaseIdSegments(m[1]).toLowerCase())) return true; // Try custom ID match (e.g. PROJ-42-description → PROJ-42) const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/); if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true; @@ -2037,8 +2186,8 @@ function getMilestonePhaseFilter(cwd, versionOverride) { // milestone is keyed on the bare numeric form. const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); if (stripped !== dirName) { - const sm = stripped.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); - if (sm && normalized.has(sm[1].toLowerCase())) return true; + const sm = stripped.match(numericRe); + if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true; } return false; } @@ -2127,6 +2276,8 @@ module.exports = { isGitIgnored, escapeRegex, normalizePhaseName, + getMilestoneFromPhaseId, + getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, diff --git a/get-shit-done/bin/lib/roadmap-command-router.cjs b/get-shit-done/bin/lib/roadmap-command-router.cjs index 338b18b69..ac8f53ed0 100644 --- a/get-shit-done/bin/lib/roadmap-command-router.cjs +++ b/get-shit-done/bin/lib/roadmap-command-router.cjs @@ -1,7 +1,91 @@ 'use strict'; +const fs = require('fs'); +const path = require('path'); const { ROADMAP_SUBCOMMANDS } = require('./command-aliases.cjs'); const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const roadmapUpgrade = require('./roadmap-upgrade.cjs'); +const { planningDir } = require('./planning-workspace.cjs'); +const { loadConfig } = require('./core.cjs'); + +/** + * Check each phase entry in a milestone-prefixed ROADMAP.md for W021 violations. + * + * W021: a phase whose ID integer prefix does not match its enclosing milestone's + * major version number. + * + * Sentinel milestones (0 = pre-milestone, 999 = backlog) are exempt. + * + * @param {string} content - ROADMAP.md content + * @returns {Array<{code:'W021', message:string}>} + */ +function checkW021(content) { + const warnings = []; + + // Sentinel milestone integers exempt from W021 + const SENTINELS = new Set([0, 999]); + const MIGRATION_CMD = 'gsd-tools roadmap upgrade --convention milestone-prefixed'; + + // Milestone section heading: ## [GSD] v2.0 — Label OR ## v2.0: Label OR ## Roadmap v2.0 + // OR ## ✅ v2.0 OR ## 🚧 v2.0 (emoji-prefixed variants used by roadmap templates) + // Capture the major integer. + const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu; + + // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form) + const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*\s*:/i; + // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index) + const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)\s*:/i; + + let currentMilestoneMajor = null; + const lines = content.split('\n'); + + for (const line of lines) { + const milestoneMatch = line.match(MILESTONE_RE); + if (milestoneMatch) { + currentMilestoneMajor = parseInt(milestoneMatch[1], 10); + continue; + } + + const phaseMatch = line.match(PHASE_RE); + if (phaseMatch) { + const phaseMajor = parseInt(phaseMatch[1], 10); + if (SENTINELS.has(phaseMajor)) continue; // exempt + + if (currentMilestoneMajor !== null && phaseMajor !== currentMilestoneMajor) { + const phaseId = `${phaseMatch[1]}-${phaseMatch[2]}`; + warnings.push({ + code: 'W021', + message: + `Phase ID prefix mismatch: phase "${phaseId}" is listed under v${currentMilestoneMajor}.x ` + + `but its prefix (${phaseMajor}) does not match. ` + + `Run \`${MIGRATION_CMD}\` to fix.`, + }); + } + continue; + } + + // When the convention is active, an unprefixed heading (### Phase 1:) is itself a W021 + // violation — it is missing the required M-NN prefix entirely. + const unprefixedMatch = line.match(UNPREFIXED_PHASE_RE); + if (unprefixedMatch && currentMilestoneMajor !== null) { + const rawId = unprefixedMatch[1]; + // Skip if it matched PHASE_RE already (it didn't reach here in that case) + // Also skip if it looks like a bare integer whose prefix matches the section + // — those pass; only non-matching or non-prefixed forms fire W021. + const numericMajor = parseInt(rawId, 10); + if (!SENTINELS.has(numericMajor)) { + warnings.push({ + code: 'W021', + message: + `Phase ID "${rawId}" is not in M-NN form (milestone-prefixed convention is active). ` + + `Run \`${MIGRATION_CMD}\` to migrate.`, + }); + } + } + } + + return warnings; +} /** * Manifest-backed roadmap subcommand router. @@ -19,6 +103,56 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) { analyze: () => roadmap.cmdRoadmapAnalyze(cwd, raw), 'update-plan-progress': () => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw), 'annotate-dependencies': () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw), + 'validate': () => { + const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); + let roadmapContent = ''; + try { + roadmapContent = fs.readFileSync(roadmapPath, 'utf8'); + } catch { + // ROADMAP.md missing — return empty warnings + } + + // W021 only fires when phase_id_convention is explicitly 'milestone-prefixed'. + // Authoritative source: .planning/config.json (set by the upgrade command). + // Fallback: ROADMAP.md frontmatter (for projects that set the field there directly). + let convention; + try { + const cfg = loadConfig(cwd); + convention = cfg.phase_id_convention; + } catch { + convention = undefined; + } + if (convention === undefined || convention === null) { + // Fallback: read from ROADMAP.md frontmatter + const fmMatch = roadmapContent.match(/^---\r?\n([\s\S]+?)\r?\n---/); + if (fmMatch) { + const kvMatch = fmMatch[1].match(/^phase_id_convention:\s*(.*)$/m); + if (kvMatch) { + const val = kvMatch[1].trim(); + if (val !== 'null' && val !== '') { + convention = val.replace(/^["']|["']$/g, ''); + } + } + } + } + const warnings = (convention === 'milestone-prefixed') + ? checkW021(roadmapContent) + : []; + + const result = { warnings }; + if (raw) process.stdout.write(JSON.stringify(result)); + else process.stdout.write(JSON.stringify(result, null, 2)); + }, + 'upgrade': () => { + const dryRun = !args.includes('--apply'); + const convention = args.find((a, i) => args[i-1] === '--convention') || 'milestone-prefixed'; + if (convention !== 'milestone-prefixed') { + process.stderr.write('Only --convention milestone-prefixed is supported\n'); + process.exit(1); + } + const plan = roadmapUpgrade.computeMigrationPlan(cwd); + roadmapUpgrade.applyMigration(cwd, plan, { dryRun }); + }, }, }); } diff --git a/get-shit-done/bin/lib/roadmap-upgrade.cjs b/get-shit-done/bin/lib/roadmap-upgrade.cjs new file mode 100644 index 000000000..be3e01eb2 --- /dev/null +++ b/get-shit-done/bin/lib/roadmap-upgrade.cjs @@ -0,0 +1,569 @@ +'use strict'; + +/** + * Roadmap Upgrade — Migration tool for converting legacy 'Phase N' phase IDs + * to milestone-prefixed 'Phase M-NN' form. + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { planningDir } = require('./planning-workspace.cjs'); + +// ─── Regex helpers ──────────────────────────────────────────────────────────── + +// Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:) +// Captures: (hashes)(spaces)(phase-number)(rest-of-line) +const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i; + +// Matches already-migrated phase headings: ### Phase M-NN: Name +const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i; + +// Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc. +// The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group. +const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu; + +// Matches checklist phase references: - [ ] **Phase N:** or - [x] **Phase N:** (also decimal) +const CHECKLIST_PHASE_RE = /^(\s*-\s*\[[ x]\]\s*\*{0,2})Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + +// ─── Pure computation helpers ───────────────────────────────────────────────── + +/** + * Parse the ROADMAP.md content and build a list of phase entries with their + * enclosing milestone major version. + * + * Returns an array of: + * { lineIndex, headingLine, milestoneInt, legacyPhaseNum, phaseName } + */ +function parseRoadmapPhases(lines) { + const results = []; + let currentMilestoneInt = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + const milestoneMatch = line.match(MILESTONE_HEADING_RE); + if (milestoneMatch) { + currentMilestoneInt = parseInt(milestoneMatch[1], 10); + continue; + } + + if (MIGRATED_PHASE_HEADING_RE.test(line)) { + // Already-migrated heading found — caller will detect this + results.push({ lineIndex: i, headingLine: line, alreadyMigrated: true }); + continue; + } + + const phaseMatch = line.match(LEGACY_PHASE_HEADING_RE); + if (phaseMatch) { + results.push({ + lineIndex: i, + headingLine: line, + milestoneInt: currentMilestoneInt, + legacyPhaseNum: phaseMatch[2], + phaseName: phaseMatch[3].trim(), + hashes: phaseMatch[1], + alreadyMigrated: false, + }); + } + } + + return results; +} + +/** + * Assign sub-indices within each milestone, building a per-entry mapping. + * + * Input: array from parseRoadmapPhases (non-migrated entries only). + * Returns: Map + * + * Keyed by `lineIndex` (the unique position of the heading line in ROADMAP.md) + * so that identical legacy phase numbers in different milestones (e.g., two + * `Phase 1` headings in v1.0 and v2.0) each get their own correct M-NN ID + * instead of the later milestone's mapping overwriting the earlier one. + * + * Sub-indices are 1-based and sequential within each milestone. + */ +function assignSubIndices(phaseEntries) { + const milestoneCounters = new Map(); // milestoneInt → counter + const mapping = new Map(); // lineIndex → { newId, milestoneInt, subIndex } + + for (const entry of phaseEntries) { + if (entry.alreadyMigrated) continue; + const m = entry.milestoneInt; + if (m === null || m === undefined) continue; + + const counter = (milestoneCounters.get(m) || 0) + 1; + milestoneCounters.set(m, counter); + + const subIndex = String(counter).padStart(2, '0'); + const newId = `${m}-${subIndex}`; + + mapping.set(entry.lineIndex, { newId, milestoneInt: m, subIndex: counter, legacyPhaseNum: entry.legacyPhaseNum }); + } + + return mapping; +} + +/** + * Read a phase directory name and return its numeric token (stripping project_code prefix). + * e.g. "GSD-01-setup" → "01", "01-setup" → "01", "02-implement" → "02", "02.1-hotfix" → "02.1" + */ +function extractPhaseNumFromDir(dirName) { + // Strip optional project_code prefix: "GSD-01-setup" → "01-setup" + const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + // Matches: digits + optional letter + optional decimal suffix, followed by '-' or end. + // e.g. "02.1-hotfix" → "02.1", "01-setup" → "01" + const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i); + return m ? m[1] : null; +} + + +/** + * Build the new directory name from old name and new phase ID. + * old: "01-setup" newId: "1-02" projectCode: "GSD" → "GSD-01-02-setup" + * old: "01-setup" newId: "1-02" projectCode: null → "01-02-setup" + * old: "GSD-01-setup" newId: "1-02" projectCode: "GSD" → "GSD-01-02-setup" + */ +function buildNewDirName(oldDirName, newId, projectCode) { + // Strip existing project_code prefix + const stripped = oldDirName.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + + // Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1) + const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i); + const slug = slugMatch ? slugMatch[1] : stripped; + + // Build M-NN prefix (zero-pad both parts) + const [milestoneStr, subStr] = newId.split('-'); + const milestoneInt = parseInt(milestoneStr, 10); + const subIndex = subStr; + const paddedMilestone = String(milestoneInt).padStart(2, '0'); + const newBase = slug ? `${paddedMilestone}-${subIndex}-${slug}` : `${paddedMilestone}-${subIndex}`; + + return projectCode ? `${projectCode}-${newBase}` : newBase; +} + +/** + * Read project_code from config.json if present. + */ +function readProjectCode(configPath) { + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw); + return parsed.project_code || null; + } catch { + return null; + } +} + +// ─── computeMigrationPlan ───────────────────────────────────────────────────── + +/** + * Compute a migration plan without touching the filesystem. + * + * @param {string} cwd + * @param {object} [options] + * @returns {{ + * alreadyMigrated: boolean, + * phases: Array<{oldId, newId, oldDir, newDir}>, + * roadmapEdits: Array<{lineIndex, from, to}>, + * crossRefEdits: Array<{file, from, to}>, + * }} + */ +function computeMigrationPlan(cwd, options = {}) { + const pDir = planningDir(cwd); + const roadmapPath = path.join(pDir, 'ROADMAP.md'); + const configPath = path.join(pDir, 'config.json'); + const phasesDir = path.join(pDir, 'phases'); + + // ── Check config for existing convention ───────────────────────────────── + let configData = {}; + try { + configData = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { /* config may not exist */ } + + if (configData.phase_id_convention === 'milestone-prefixed') { + return { alreadyMigrated: true, phases: [], roadmapEdits: [], crossRefEdits: [] }; + } + + const projectCode = configData.project_code || null; + + // ── Read ROADMAP.md ─────────────────────────────────────────────────────── + let roadmapContent = ''; + try { + roadmapContent = fs.readFileSync(roadmapPath, 'utf8'); + } catch { + throw new Error(`ROADMAP.md not found at ${roadmapPath}`); + } + + const lines = roadmapContent.split('\n'); + const parsedPhases = parseRoadmapPhases(lines); + + // Check for any already-migrated headings + const hasAnyMigrated = parsedPhases.some(e => e.alreadyMigrated); + if (hasAnyMigrated) { + return { alreadyMigrated: true, phases: [], roadmapEdits: [], crossRefEdits: [] }; + } + + const legacyPhases = parsedPhases.filter(e => !e.alreadyMigrated); + const idMapping = assignSubIndices(legacyPhases); + + // Secondary lookup: (milestoneInt, normalizedLegacyNum) → newId + // Used for directory renames and checklist rewrites where line position is unknown. + // For simplicity, each milestone gets its own Map from legacy num → newId. + const milestoneIdMap = new Map(); // milestoneInt → Map + for (const [, entry] of idMapping) { + if (!milestoneIdMap.has(entry.milestoneInt)) { + milestoneIdMap.set(entry.milestoneInt, new Map()); + } + const mMap = milestoneIdMap.get(entry.milestoneInt); + const legacyNum = entry.legacyPhaseNum; + // Register integer forms (covers plain numeric and letter-suffix IDs) + const intPart = parseInt(legacyNum, 10); + const paddedLegacy = String(intPart).padStart(2, '0'); + const unpaddedLegacy = String(intPart); + mMap.set(paddedLegacy, entry.newId); + mMap.set(unpaddedLegacy, entry.newId); + // Also register the original form and padded-integer+decimal form + // so decimal IDs like "2.1" / "02.1" round-trip correctly. + mMap.set(legacyNum, entry.newId); + const dotIdx = legacyNum.indexOf('.'); + if (dotIdx !== -1) { + const decimalSuffix = legacyNum.slice(dotIdx); // e.g. ".1" + mMap.set(paddedLegacy + decimalSuffix, entry.newId); + mMap.set(unpaddedLegacy + decimalSuffix, entry.newId); + } + } + + // ── Read existing phase directories ─────────────────────────────────────── + let existingDirs = []; + try { + existingDirs = fs.readdirSync(phasesDir).filter(d => { + try { + return fs.statSync(path.join(phasesDir, d)).isDirectory(); + } catch { return false; } + }); + } catch { /* phases dir may not exist */ } + + // ── Build phase rename pairs ─────────────────────────────────────────────── + // Flat ordered list of (legacyPhaseNum, newId) in ROADMAP order, for dir matching. + const orderedMappings = [...idMapping.values()].map(e => ({ + legacyPhaseNum: e.legacyPhaseNum, + newId: e.newId, + milestoneInt: e.milestoneInt, + _used: false, + })); + + // Note: if the same legacy phase number appears in multiple milestones (the exact legacy + // ambiguity this tool is designed to resolve), directories are matched in ROADMAP document + // order — the first ROADMAP occurrence of a given number claims the first matching disk dir. + // This is the only unambiguous assignment strategy for flat dirs that carry no milestone + // context. The dry-run output shows the complete rename plan so users can review before + // applying with --apply. + + const phases = []; + for (const dirName of existingDirs) { + const phaseNum = extractPhaseNumFromDir(dirName); + if (!phaseNum) continue; + + const intPart = parseInt(phaseNum, 10); + const paddedPhaseNum = String(intPart).padStart(2, '0'); + const unpaddedPhaseNum = String(intPart); + // For decimal IDs like "02.1", also try "2.1" + const dotIdx = phaseNum.indexOf('.'); + const decimalUnpadded = dotIdx !== -1 ? unpaddedPhaseNum + phaseNum.slice(dotIdx) : null; + + // Find the first unused mapping whose legacy number matches (exact, padded, unpadded, or decimal) + const found = orderedMappings.find(m => !m._used && ( + m.legacyPhaseNum === phaseNum || + m.legacyPhaseNum === paddedPhaseNum || + m.legacyPhaseNum === unpaddedPhaseNum || + (decimalUnpadded && m.legacyPhaseNum === decimalUnpadded) + )); + if (!found) continue; + found._used = true; + + const newDirName = buildNewDirName(dirName, found.newId, projectCode); + if (newDirName !== dirName) { + phases.push({ + oldId: phaseNum, + newId: found.newId, + oldDir: dirName, + newDir: newDirName, + }); + } + } + + // ── Build ROADMAP.md line edits ──────────────────────────────────────────── + const roadmapEdits = []; + + for (const entry of legacyPhases) { + // Use lineIndex as the canonical key (not legacyPhaseNum, which may collide across milestones) + const mapping = idMapping.get(entry.lineIndex); + if (!mapping) continue; + + // Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name" + const oldLine = lines[entry.lineIndex]; + const newLine = oldLine.replace( + /^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i, + `$1${mapping.newId}$2` + ); + if (newLine !== oldLine) { + roadmapEdits.push({ lineIndex: entry.lineIndex, from: oldLine, to: newLine }); + } + } + + // Rewrite checklist lines in ROADMAP.md — use milestone context to resolve collisions. + let currentChecklistMilestone = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Track enclosing milestone section for context-aware lookup + const milestoneHeadingMatch = line.match(MILESTONE_HEADING_RE); + if (milestoneHeadingMatch) { + currentChecklistMilestone = parseInt(milestoneHeadingMatch[1], 10); + } + + // Already in roadmapEdits? skip + if (roadmapEdits.some(e => e.lineIndex === i)) continue; + + // Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal) + const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i); + if (checklistMatch) { + const legacyNum = checklistMatch[2]; + const cIntPart = parseInt(legacyNum, 10); + const paddedLegacy = String(cIntPart).padStart(2, '0'); + const unpaddedLegacy = String(cIntPart); + const cDotIdx = legacyNum.indexOf('.'); + const paddedLegacyDecimal = cDotIdx !== -1 ? paddedLegacy + legacyNum.slice(cDotIdx) : null; + + // Prefer milestone-context lookup (avoids collision across milestones) + let newId; + if (currentChecklistMilestone !== null && milestoneIdMap.has(currentChecklistMilestone)) { + const mMap = milestoneIdMap.get(currentChecklistMilestone); + newId = mMap.get(legacyNum) || mMap.get(paddedLegacy) || mMap.get(unpaddedLegacy); + if (!newId && paddedLegacyDecimal) newId = mMap.get(paddedLegacyDecimal); + } + if (!newId) { + // Fallback: use ordered flat list (no milestone collision in this roadmap) + const found = orderedMappings.find(m => + m.legacyPhaseNum === legacyNum || + m.legacyPhaseNum === paddedLegacy || + m.legacyPhaseNum === unpaddedLegacy || + (paddedLegacyDecimal && m.legacyPhaseNum === paddedLegacyDecimal) + ); + if (found) newId = found.newId; + } + + if (newId) { + const newLine = line.replace( + /^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i, + `$1${newId}$2` + ); + if (newLine !== line) { + roadmapEdits.push({ lineIndex: i, from: line, to: newLine }); + } + } + } + } + + // ── Build cross-ref edits for STATE.md and PROJECT.md ──────────────────── + const crossRefEdits = []; + const crossRefFiles = ['STATE.md', 'PROJECT.md']; + + for (const fileName of crossRefFiles) { + const filePath = path.join(pDir, fileName); + if (!fs.existsSync(filePath)) continue; + + const fileContent = fs.readFileSync(filePath, 'utf8'); + + // Iterate using orderedMappings (ROADMAP order) — idMapping is now keyed by lineIndex. + for (const m of orderedMappings) { + const legacyNum = m.legacyPhaseNum; + const xIntPart = parseInt(legacyNum, 10); + const paddedNum = String(xIntPart).padStart(2, '0'); + const unpaddedNum = String(xIntPart); + // Decimal suffix (e.g. ".1" from "2.1") — preserve in cross-ref patterns + const xDotIdx = legacyNum.indexOf('.'); + const decimalSuffix = xDotIdx !== -1 ? legacyNum.slice(xDotIdx) : ''; + + // Rewrite project_code-prefixed references: "GSD-01-" → "GSD-01-02-" + if (projectCode) { + const [milestoneStr, subStr] = m.newId.split('-'); + const paddedMilestone = String(parseInt(milestoneStr, 10)).padStart(2, '0'); + const prefixedNew = `${projectCode}-${paddedMilestone}-${subStr}-`; + // Try both padded and original forms as old prefix + for (const oldNum of new Set([paddedNum + decimalSuffix, unpaddedNum + decimalSuffix, paddedNum, unpaddedNum])) { + const prefixedOld = `${projectCode}-${oldNum}-`; + if (fileContent.includes(prefixedOld)) { + crossRefEdits.push({ file: fileName, from: prefixedOld, to: prefixedNew }); + } + } + } + + // Rewrite prose references: "Phase 1:" → "Phase 1-01:", "Phase 2.1:" → "Phase 1-02:" + const proseOldPatterns = new Set([ + `Phase ${unpaddedNum}${decimalSuffix}:`, + `Phase ${paddedNum}${decimalSuffix}:`, + `Phase ${legacyNum}:`, + ]); + for (const proseOld of proseOldPatterns) { + if (fileContent.includes(proseOld)) { + const proseNew = `Phase ${m.newId}:`; + crossRefEdits.push({ file: fileName, from: proseOld, to: proseNew }); + } + } + } + } + + return { + alreadyMigrated: false, + phases, + roadmapEdits, + crossRefEdits, + }; +} + +// ─── applyMigration ─────────────────────────────────────────────────────────── + +/** + * Apply the migration plan computed by computeMigrationPlan(). + * + * @param {string} cwd + * @param {{ alreadyMigrated, phases, roadmapEdits, crossRefEdits }} plan + * @param {object} [options] + * @param {boolean} [options.dryRun=true] - Print plan and exit without mutating. + * @returns {{ + * applied?: boolean, + * alreadyMigrated?: boolean, + * renamedDirs?: string[], + * editedFiles?: string[], + * }} + */ +function applyMigration(cwd, plan, options = {}) { + const dryRun = options.dryRun !== false; // default true + + if (plan.alreadyMigrated) { + return { alreadyMigrated: true }; + } + + if (dryRun) { + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); + return { dryRun: true }; + } + + // ── Real run: verify clean working tree ─────────────────────────────────── + let gitStatus; + try { + gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8' }); + } catch (err) { + throw new Error(`git status failed: ${err.message}`); + } + if (gitStatus.trim().length > 0) { + throw new Error('Working tree is dirty. Commit or stash changes before migrating.'); + } + + // Capture HEAD sha for rollback + let headSha; + try { + headSha = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim(); + } catch (err) { + throw new Error(`git rev-parse HEAD failed: ${err.message}`); + } + + const pDir = planningDir(cwd); + const phasesDir = path.join(pDir, 'phases'); + const roadmapPath = path.join(pDir, 'ROADMAP.md'); + const configPath = path.join(pDir, 'config.json'); + + const renamedDirs = []; + const editedFiles = []; + + try { + // 1. Rename phase directories + for (const phaseEntry of plan.phases) { + const oldPath = path.join(phasesDir, phaseEntry.oldDir); + const newPath = path.join(phasesDir, phaseEntry.newDir); + if (fs.existsSync(oldPath)) { + fs.renameSync(oldPath, newPath); + renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`); + } + } + + // 2. Rewrite ROADMAP.md phase headings + if (plan.roadmapEdits.length > 0) { + const roadmapContent = fs.readFileSync(roadmapPath, 'utf8'); + const lines = roadmapContent.split('\n'); + + // Sort edits by lineIndex to apply in order + const sortedEdits = [...plan.roadmapEdits].sort((a, b) => a.lineIndex - b.lineIndex); + for (const edit of sortedEdits) { + if (lines[edit.lineIndex] === edit.from) { + lines[edit.lineIndex] = edit.to; + } + } + + fs.writeFileSync(roadmapPath, lines.join('\n'), 'utf8'); + editedFiles.push('ROADMAP.md'); + } + + // 3. Rewrite cross-refs in STATE.md and PROJECT.md + const crossRefsByFile = new Map(); + for (const edit of plan.crossRefEdits) { + if (!crossRefsByFile.has(edit.file)) { + crossRefsByFile.set(edit.file, []); + } + crossRefsByFile.get(edit.file).push(edit); + } + + for (const [fileName, edits] of crossRefsByFile) { + const filePath = path.join(pDir, fileName); + if (!fs.existsSync(filePath)) continue; + + let content = fs.readFileSync(filePath, 'utf8'); + let changed = false; + + for (const edit of edits) { + if (content.includes(edit.from)) { + // Replace all occurrences + content = content.split(edit.from).join(edit.to); + changed = true; + } + } + + if (changed) { + fs.writeFileSync(filePath, content, 'utf8'); + editedFiles.push(fileName); + } + } + + // 4. Update config.json: set phase_id_convention to 'milestone-prefixed' + let configData = {}; + try { + configData = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { /* config may not exist yet */ } + + configData.phase_id_convention = 'milestone-prefixed'; + fs.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8'); + editedFiles.push('config.json'); + + } catch (err) { + // Rollback via git reset --hard + git clean + try { + execSync(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe' }); + execSync('git clean -fd .planning/phases/', { cwd, stdio: 'pipe' }); + } catch (rollbackErr) { + // Swallow rollback errors — surface original error + } + throw new Error(`Migration failed (rolled back to ${headSha}): ${err.message}`); + } + + return { applied: true, renamedDirs, editedFiles }; +} + +// ─── Exports ────────────────────────────────────────────────────────────────── + +module.exports = { + computeMigrationPlan, + applyMigration, +}; diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 00bbeb9f7..e9d0dce8c 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -63,7 +63,7 @@ function countPhasePlansAndSummaries(phaseDir) { function searchPhaseInContent(content, escapedPhase, phaseNum) { // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name const phasePattern = new RegExp( - `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, + `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i' ); const headerMatch = content.match(phasePattern); @@ -92,9 +92,10 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) { const phaseName = headerMatch[1].trim(); const headerIndex = headerMatch.index; - // Find the end of this section (next ## or ### phase header, or end of file) + // Find the end of this section (next ## or ### phase header, or end of file). + // Also matches bracket-prefixed headings like ### [GSD] Phase 2-01:. const restOfContent = content.slice(headerIndex); - const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w][\w.-]*/i); + const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w][\w.-]*/i); const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length; @@ -203,7 +204,7 @@ function cmdRoadmapAnalyze(cwd, raw) { const phasesDir = planningPaths(cwd).phases; // Extract all phase headings: ## Phase N: Name or ### Phase N: Name - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi; const phases = []; let match; @@ -225,7 +226,7 @@ function cmdRoadmapAnalyze(cwd, raw) { const restOfContent = content.slice(sectionStart); // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are // recognised as section boundaries. - const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i); + const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i); const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length; const section = content.slice(sectionStart, sectionEnd); diff --git a/get-shit-done/bin/lib/validate.cjs b/get-shit-done/bin/lib/validate.cjs index 54828ef8c..3ac822413 100644 --- a/get-shit-done/bin/lib/validate.cjs +++ b/get-shit-done/bin/lib/validate.cjs @@ -29,8 +29,13 @@ */ // ── Issue #26: regex constants (W005, W006-archived) ──────────────────────── -const phaseDirNameRe = /^\d{2,}(?:\.\d+)*-[\w-]+$/; -const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i; +// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup), +// deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup). +const phaseDirNameRe = /^(?:[A-Z]{1,6}-)?\d{2,}(?:-\d+)*(?:\.\d+)*-[\w-]+$/i; +// Extracts the full phase token from a directory name, including milestone-prefixed +// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup". +// Greedily captures all leading all-digit segments before the first letter-start segment. +const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+(?:-\d+)*[A-Z]?(?:\.\d+)*)(?:-[a-z]|$)/i; const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; // ── Issue #26: I001 canonicalization ──────────────────────────────────────── @@ -41,26 +46,46 @@ function canonicalPlanStem(stem) { // ── Issue #6: phase variant helpers (W006/W007) ────────────────────────────── function phaseVariants(phase) { + const variants = new Set([phase]); + const dotIdx = phase.indexOf('.'); + const head = dotIdx === -1 ? phase : phase.slice(0, dotIdx); + const tail = dotIdx === -1 ? '' : phase.slice(dotIdx); - const variants = new Set([phase]); - const dotIdx = phase.indexOf('.'); - const head = dotIdx === -1 ? phase : phase.slice(0, dotIdx); - const tail = dotIdx === -1 ? '' : phase.slice(dotIdx); - const headMatch = head.match(/^(\d+)([A-Z]?)$/i); - if (!headMatch) - return variants; - const numericHead = headMatch[1]; - const letterSuffix = headMatch[2] || ''; - variants.add(`${String(parseInt(numericHead, 10))}${letterSuffix}${tail}`); - variants.add(`${numericHead.padStart(2, '0')}${letterSuffix}${tail}`); - return variants; - + // Milestone-prefixed IDs: M-NN or M-N-N. Add padding-normalized variant. + // e.g. "2-01" → also "02-01"; "02-01" → also "2-01" + const milestoneHeadMatch = head.match(/^(\d+)((?:-\d+)+)([A-Z]?)$/i); + if (milestoneHeadMatch) { + const major = milestoneHeadMatch[1]; + const subSegs = milestoneHeadMatch[2]; // e.g. "-01" or "-04-01" + const letter = milestoneHeadMatch[3] || ''; + const paddedMajor = major.padStart(2, '0'); + const unpaddedMajor = String(parseInt(major, 10)); + // Pad/unpad sub-segments individually + const paddedSubs = subSegs.slice(1).split('-').map(s => s.padStart(2, '0')).join('-'); + const unpaddedSubs = subSegs.slice(1).split('-').map(s => String(parseInt(s, 10))).join('-'); + variants.add(`${paddedMajor}-${paddedSubs}${letter}${tail}`); + variants.add(`${unpaddedMajor}-${unpaddedSubs}${letter}${tail}`); + variants.add(`${unpaddedMajor}-${paddedSubs}${letter}${tail}`); + variants.add(`${paddedMajor}-${unpaddedSubs}${letter}${tail}`); + return variants; + } + + // Plain numeric/decimal IDs: "1", "01", "12A", "12.1" + const headMatch = head.match(/^(\d+)([A-Z]?)$/i); + if (!headMatch) return variants; + const numericHead = headMatch[1]; + const letterSuffix = headMatch[2] || ''; + variants.add(`${String(parseInt(numericHead, 10))}${letterSuffix}${tail}`); + variants.add(`${numericHead.padStart(2, '0')}${letterSuffix}${tail}`); + return variants; } function buildRoadmapPhaseVariants(roadmapContent) { const roadmapPhases = new Set(); const roadmapPhaseVariants = new Set(); - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:), + // and bracket-prefixed (### [GSD] Phase 2-01:) headings. + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi; let m; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); @@ -71,7 +96,8 @@ function buildRoadmapPhaseVariants(roadmapContent) { function buildNotStartedPhaseVariants(roadmapContent) { const notStartedPhases = new Set(); - const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s*]/gi; + // Also matches milestone-prefixed and bracket-prefixed checklist items. + const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)[:\s*]/gi; let um; while ((um = uncheckedPattern.exec(roadmapContent)) !== null) { for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant); diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 5d5f0f925..023930c03 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -507,6 +507,34 @@ function collectDiskPhases(planBase) { return diskPhases; } +// W021: phase ID integer prefix doesn't match its enclosing milestone section +// Only fires when phase_id_convention is 'milestone-prefixed' (opt-in). +// Returns array of mismatch objects: { phaseId, foundInMilestone, expectedMilestone } +function checkMilestonePrefixMismatches(roadmapContent, { getMilestoneFromPhaseId }) { + const mismatches = []; + // Find all milestone sections (## vN.N or ## [code] vN.N) + const sections = []; + const sectionRx = /^#{1,3}\s+(?:\[[^\]]+\]\s*)?.*v(\d+\.\d+)/gim; + let m; + while ((m = sectionRx.exec(roadmapContent)) !== null) { + if (sections.length > 0) sections[sections.length - 1].end = m.index; + sections.push({ version: `v${m[1]}`, start: m.index, end: roadmapContent.length }); + } + for (const section of sections) { + const content = roadmapContent.slice(section.start, section.end); + const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; + let pm; + while ((pm = phaseRx.exec(content)) !== null) { + const phaseId = pm[1]; + const expectedMilestone = getMilestoneFromPhaseId(phaseId); + if (expectedMilestone !== null && expectedMilestone !== section.version) { + mismatches.push({ phaseId, foundInMilestone: section.version, expectedMilestone }); + } + } + } + return mismatches; +} + function cmdValidateConsistency(cwd, raw) { const planBase = planningDir(cwd); const roadmapPath = path.join(planBase, 'ROADMAP.md'); @@ -527,7 +555,9 @@ function cmdValidateConsistency(cwd, raw) { // stripped). Used for the "in ROADMAP but not on disk" check — we only require // disk dirs for the active milestone's phases. const roadmapPhases = new Set(); - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), and + // milestone-prefixed (Phase 2-01:) headings, including bracket-prefixed form. + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi; let m; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); @@ -539,7 +569,7 @@ function cmdValidateConsistency(cwd, raw) { // though it is absent from the active-milestone scope. Without this, narrowing // the scope (#501) would flag every shipped phase dir as a spurious orphan. const fullRoadmapPhases = new Set(); - const fullPhasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + const fullPhasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi; let fm; while ((fm = fullPhasePattern.exec(roadmapContentRaw)) !== null) { fullRoadmapPhases.add(fm[1]); @@ -558,8 +588,12 @@ function cmdValidateConsistency(cwd, raw) { // Check: phases on disk but not in ROADMAP (compared against the FULL roadmap // so shipped-milestone phase dirs are not flagged as orphans — #501) for (const p of diskPhases) { + // For plain numeric IDs, also try the unpadded form (e.g. "02" → "2"). + // For milestone-prefixed IDs (e.g. "02-01"), use normalizePhaseName to + // canonicalize padding before comparing with the ROADMAP entries. + const normalized = normalizePhaseName(p); const unpadded = String(parseInt(p, 10)); - if (!fullRoadmapPhases.has(p) && !fullRoadmapPhases.has(unpadded)) { + if (!fullRoadmapPhases.has(p) && !fullRoadmapPhases.has(normalized) && !fullRoadmapPhases.has(unpadded)) { warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`); } } @@ -1059,6 +1093,31 @@ function cmdValidateHealth(cwd, options, raw) { } } catch { /* git worktree not available or not a git repo — skip silently */ } + // ─── Check 11b: Phase ID / milestone-section mismatch (W021) ───────────── + // Only active when phase_id_convention === 'milestone-prefixed' in config.json. + try { + const phaseConvention = (() => { + if (!fs.existsSync(configPath)) return null; + try { + const configRaw = fs.readFileSync(configPath, 'utf-8'); + const configParsed = JSON.parse(configRaw); + return configParsed.phase_id_convention || null; + } catch { return null; } + })(); + if (phaseConvention === 'milestone-prefixed') { + if (fs.existsSync(roadmapPath)) { + const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + const { getMilestoneFromPhaseId } = require('./core.cjs'); + const mismatches = checkMilestonePrefixMismatches(roadmapContent, { getMilestoneFromPhaseId }); + for (const m of mismatches) { + addIssue('warning', 'W021', + `Phase ${m.phaseId}: integer prefix implies ${m.expectedMilestone} but listed under ${m.foundInMilestone}`, + 'Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default)'); + } + } + } + } catch { /* W021 check is advisory — skip on error */ } + // ─── Check 12: MILESTONES.md / archive snapshot drift (#2446) ───────────── const milestonesPath = path.join(planBase, 'MILESTONES.md'); const milestonesArchiveDir = path.join(planBase, 'milestones'); diff --git a/get-shit-done/bin/shared/config-defaults.manifest.json b/get-shit-done/bin/shared/config-defaults.manifest.json index 3939fed8e..b843bae57 100644 --- a/get-shit-done/bin/shared/config-defaults.manifest.json +++ b/get-shit-done/bin/shared/config-defaults.manifest.json @@ -11,6 +11,7 @@ "context_window": 200000, "phase_naming": "sequential", "project_code": null, + "phase_id_convention": null, "mode": "interactive", "claude_md_path": "./CLAUDE.md", "git": { diff --git a/get-shit-done/bin/shared/config-schema.manifest.json b/get-shit-done/bin/shared/config-schema.manifest.json index bac2c81b2..7ed43ab41 100644 --- a/get-shit-done/bin/shared/config-schema.manifest.json +++ b/get-shit-done/bin/shared/config-schema.manifest.json @@ -83,6 +83,7 @@ "features.global_learnings", "learnings.max_inject", "project_code", + "phase_id_convention", "phase_naming", "manager.flags.discuss", "manager.flags.plan", diff --git a/tests/backwards-compat-phase-id.test.cjs b/tests/backwards-compat-phase-id.test.cjs new file mode 100644 index 000000000..416e347de --- /dev/null +++ b/tests/backwards-compat-phase-id.test.cjs @@ -0,0 +1,187 @@ +/** + * Backwards-compatibility tests for legacy phase ID conventions. + * + * Covers: + * 1. Legacy 'Phase N' ROADMAP entries still work when phase_id_convention + * is null (the default — no config key set). + * 2. Deprecated warning fires for free-form roadmaps (non-fatal). + * 3. No automatic migration happens when a free-form roadmap is loaded. + * 4. isDirInMilestone still works for old-style dirs ('02-setup') against + * ROADMAP entries 'Phase 2:'. + * 5. isDirInMilestone works for new-style dirs ('GSD-02-01-setup') against + * ROADMAP entries 'Phase 2-01:'. + * 6. Heading regex matches both '### Phase 2-01: Setup' and + * '### [GSD] Phase 2-01: Setup'. + * + * Tests 1-3 exercise new behavior and will FAIL until implemented. + * Tests 4-6 exercise existing/new behavior and should pass once wired. + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempProject, cleanup, runGsdTools, captureConsole } = require('./helpers.cjs'); +const { getMilestonePhaseFilter } = require('../get-shit-done/bin/lib/core.cjs'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function writeRoadmap(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); +} + +function writeConfig(tmpDir, obj) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify(obj) + ); +} + +// ─── suite ─────────────────────────────────────────────────────────────────── + +describe('backwards-compat: legacy Phase N roadmap entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // ── test 1: legacy entries work with null phase_id_convention ────────────── + + test('Phase N ROADMAP entries work when phase_id_convention is null (default)', () => { + // No phase_id_convention key → default (null) must still honour Phase N headings. + writeRoadmap(tmpDir, [ + '## Roadmap v1.0: Current', + '', + '### Phase 1: Setup', + '**Goal:** initial setup', + '', + '### Phase 2: Build', + '**Goal:** build the thing', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter('01-setup'), true, 'old-style dir must match Phase 1'); + assert.strictEqual(filter('02-build'), true, 'old-style dir must match Phase 2'); + assert.strictEqual(filter('03-deploy'), false, 'unlisted phase must not match'); + }); + + // ── test 2: deprecated warning fires for free-form roadmaps ─────────────── + + test('deprecated warning fires (non-fatal) when roadmap has no versioned milestone headings', () => { + // A "free-form" roadmap: phase headings but no ## vX.Y milestone section. + writeRoadmap(tmpDir, [ + '### Phase 1: Setup', + '**Goal:** setup', + '', + '### Phase 2: Build', + '**Goal:** build', + ].join('\n')); + + const { stderr } = captureConsole(() => { + getMilestonePhaseFilter(tmpDir); + }); + + // Warning must fire but must not throw — non-fatal. + assert.match( + stderr, + /deprecated|free.form|phase_id_convention/i, + 'a deprecation warning must be emitted for free-form roadmaps' + ); + }); + + // ── test 3: no automatic migration ──────────────────────────────────────── + + test('loading a free-form roadmap does not rewrite ROADMAP.md on disk', () => { + const roadmapContent = [ + '### Phase 1: Setup', + '**Goal:** setup', + ].join('\n'); + + writeRoadmap(tmpDir, roadmapContent); + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + const before = fs.readFileSync(roadmapPath, 'utf-8'); + + // Trigger a load — must not silently migrate the file. + getMilestonePhaseFilter(tmpDir); + + const after = fs.readFileSync(roadmapPath, 'utf-8'); + assert.equal(after, before, 'ROADMAP.md must not be rewritten during load'); + }); + + // ── test 4: old-style dirs ('02-setup') match 'Phase 2:' ───────────────── + + test('isDirInMilestone: old-style dir "02-setup" matches ROADMAP "Phase 2:"', () => { + writeRoadmap(tmpDir, [ + '## Roadmap v1.0: Current', + '', + '### Phase 2: Setup', + '**Goal:** setup', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter('02-setup'), true, '"02-setup" must match "Phase 2:"'); + assert.strictEqual(filter('2-setup'), true, '"2-setup" must also match "Phase 2:"'); + assert.strictEqual(filter('03-other'), false, 'unlisted dir must not match'); + }); + + // ── test 5: new-style dirs ('GSD-02-01-setup') match 'Phase 2-01:' ─────── + + test('isDirInMilestone: new-style dir "GSD-02-01-setup" matches ROADMAP "Phase 2-01:"', () => { + writeRoadmap(tmpDir, [ + '## Roadmap v1.0: Current', + '', + '### Phase 2-01: Setup', + '**Goal:** setup', + ].join('\n')); + writeConfig(tmpDir, { project_code: 'GSD' }); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual( + filter('GSD-02-01-setup'), + true, + '"GSD-02-01-setup" must match "Phase 2-01:"' + ); + assert.strictEqual( + filter('02-01-setup'), + true, + '"02-01-setup" must match "Phase 2-01:" without project prefix' + ); + }); + + // ── test 6: heading regex matches both plain and [GSD]-prefixed headings ── + + test('phase heading regex matches "### Phase 2-01: Setup" and "### [GSD] Phase 2-01: Setup"', () => { + const plain = '### Phase 2-01: Setup'; + const bracketed = '### [GSD] Phase 2-01: Setup'; + + // Both heading variants must be captured by the phasePattern used internally. + // We exercise this via getMilestonePhaseFilter with a roadmap containing each form. + + const plainRoadmap = ['## Roadmap v1.0: Current', '', plain, '**Goal:** g'].join('\n'); + const bracketedRoadmap = ['## Roadmap v1.0: Current', '', bracketed, '**Goal:** g'].join('\n'); + + writeRoadmap(tmpDir, plainRoadmap); + const filterPlain = getMilestonePhaseFilter(tmpDir); + assert.strictEqual( + filterPlain('02-01-setup'), + true, + 'plain heading "### Phase 2-01:" must be matched' + ); + + writeRoadmap(tmpDir, bracketedRoadmap); + const filterBracketed = getMilestonePhaseFilter(tmpDir); + assert.strictEqual( + filterBracketed('02-01-setup'), + true, + '"### [GSD] Phase 2-01:" must also be matched by the heading regex' + ); + }); +}); diff --git a/tests/milestone-helper.test.cjs b/tests/milestone-helper.test.cjs new file mode 100644 index 000000000..6e160a7f9 --- /dev/null +++ b/tests/milestone-helper.test.cjs @@ -0,0 +1,90 @@ +// Tests for getMilestoneFromPhaseId and getPhaseDirFromPhaseId helpers (issue #39). + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + getMilestoneFromPhaseId, + getPhaseDirFromPhaseId, +} = require('../get-shit-done/bin/lib/core.cjs'); + +// ─── getMilestoneFromPhaseId ──────────────────────────────────────────────── + +describe('getMilestoneFromPhaseId', () => { + test('maps milestone integer 1 to v1.0', () => { + assert.strictEqual(getMilestoneFromPhaseId('1-01'), 'v1.0'); + }); + + test('uses only the top-level integer: 2-4-1 → v2.0', () => { + assert.strictEqual(getMilestoneFromPhaseId('2-4-1'), 'v2.0'); + }); + + test('handles double-digit milestone: 10-01 → v10.0', () => { + assert.strictEqual(getMilestoneFromPhaseId('10-01'), 'v10.0'); + }); + + test('returns null for sentinel 999 (backlog)', () => { + assert.strictEqual(getMilestoneFromPhaseId('999-1'), null); + }); + + test('returns null for sentinel 0 (pre-milestone spike)', () => { + assert.strictEqual(getMilestoneFromPhaseId('0-1'), null); + }); + + test('returns null when there is no hyphen separator', () => { + assert.strictEqual(getMilestoneFromPhaseId('1'), null); + }); + + test('strips project_code prefix: CK-2-01 → v2.0', () => { + assert.strictEqual(getMilestoneFromPhaseId('CK-2-01'), 'v2.0'); + }); + + test('strips longer project_code prefix: GSD-10-01 → v10.0', () => { + assert.strictEqual(getMilestoneFromPhaseId('GSD-10-01'), 'v10.0'); + }); + + test('returns null for fully non-numeric input', () => { + assert.strictEqual(getMilestoneFromPhaseId('invalid'), null); + }); +}); + +// ─── getPhaseDirFromPhaseId ───────────────────────────────────────────────── + +describe('getPhaseDirFromPhaseId', () => { + test('produces zero-padded dir with project code', () => { + assert.strictEqual( + getPhaseDirFromPhaseId('2-01', 'Setup Database', 'GSD'), + 'GSD-02-01-setup-database', + ); + }); + + test('omits project code when not provided', () => { + assert.strictEqual( + getPhaseDirFromPhaseId('2-01', 'Setup Database'), + '02-01-setup-database', + ); + }); + + test('handles double-digit milestone with project code', () => { + assert.strictEqual( + getPhaseDirFromPhaseId('10-01', 'Build Feature', 'CK'), + 'CK-10-01-build-feature', + ); + }); + + test('produces zero-padded dir without project code: 1-01 → 01-01-setup', () => { + assert.strictEqual( + getPhaseDirFromPhaseId('1-01', 'Setup'), + '01-01-setup', + ); + }); + + test('returns null for phase IDs without the M-NN hyphen form', () => { + assert.strictEqual( + getPhaseDirFromPhaseId('nohyphen', 'Some Title', 'GSD'), + null, + ); + }); +}); diff --git a/tests/milestone-prefixed-convention.test.cjs b/tests/milestone-prefixed-convention.test.cjs new file mode 100644 index 000000000..f54e42a1a --- /dev/null +++ b/tests/milestone-prefixed-convention.test.cjs @@ -0,0 +1,235 @@ +'use strict'; + +/** + * W021 validation rule — milestone-prefixed phase ID convention. + * + * W021 fires when a phase ID's integer prefix doesn't match its enclosing + * milestone section (e.g. phase '1-01' listed under ## v2.0 is a mismatch). + * + * Also covers: `gsd-tools roadmap validate` subcommand shape. + * + * These features do NOT exist yet — this file is written TDD-first. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// --------------------------------------------------------------------------- +// Fixture builder +// --------------------------------------------------------------------------- + +/** + * Build a ROADMAP.md with milestone-prefixed sections at + * `tmpDir/.planning/ROADMAP.md`. + * + * @param {string} tmpDir - Temp project root returned by createTempProject(). + * @param {Array<{version: string, label: string, phases: Array<{id: string, name: string}>}>} milestones + * Each milestone maps to a `## [GSD] vX.Y — Label` section; each phase maps + * to a `### Phase : ` heading inside that section. + * @param {object} [opts] + * @param {string|null} [opts.conventionValue] - Value for the `phase_id_convention` + * front-matter field. Pass `null` to emit the key with a null/absent value. + * Omit (undefined) to use the default ('milestone-prefixed'). + */ +function buildRoadmap(tmpDir, milestones, opts = {}) { + const { conventionValue } = opts; + + let conventionLine; + if (conventionValue === null) { + conventionLine = 'phase_id_convention: null'; + } else if (conventionValue === undefined) { + conventionLine = 'phase_id_convention: milestone-prefixed'; + } else { + conventionLine = `phase_id_convention: ${conventionValue}`; + } + + const frontmatter = `---\n${conventionLine}\n---\n\n`; + + const sections = milestones + .map(({ version, label, phases }) => { + const phaseBlocks = phases + .map(({ id, name }) => `### Phase ${id}: ${name}\n**Goal:** Placeholder goal\n`) + .join('\n'); + return `## [GSD] ${version} — ${label}\n\n${phaseBlocks}`; + }) + .join('\n\n'); + + const content = `${frontmatter}# Roadmap\n\n${sections}\n`; + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('W021 — milestone-prefixed phase ID convention', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // ── 1. Mismatch fires W021 ──────────────────────────────────────────────── + + test('W021 fires when phase 1-01 is listed under ## v2.0 (mismatch)', () => { + buildRoadmap(tmpDir, [ + { + version: 'v2.0', + label: 'Expansion', + phases: [{ id: '1-01', name: 'Setup' }], + }, + ]); + + const result = runGsdTools(['roadmap', 'validate'], tmpDir); + assert.ok(result.success, `roadmap validate should exit 0 even with warnings: ${result.error}`); + + const out = JSON.parse(result.output); + assert.ok(Array.isArray(out.warnings), 'output.warnings should be an array'); + + const w021 = out.warnings.filter(w => w.code === 'W021'); + assert.ok(w021.length > 0, 'at least one W021 warning expected for prefix mismatch'); + + const warning = w021[0]; + assert.ok(warning.message, 'W021 entry should have a message field'); + }); + + // ── 2. Match does NOT fire W021 ─────────────────────────────────────────── + + test('W021 does NOT fire when phase 2-01 is under ## v2.0 (match)', () => { + buildRoadmap(tmpDir, [ + { + version: 'v2.0', + label: 'Expansion', + phases: [{ id: '2-01', name: 'New thing' }], + }, + ]); + + const result = runGsdTools(['roadmap', 'validate'], tmpDir); + assert.ok(result.success, `roadmap validate failed: ${result.error}`); + + const out = JSON.parse(result.output); + assert.ok(Array.isArray(out.warnings), 'output.warnings should be an array'); + + const w021 = out.warnings.filter(w => w.code === 'W021'); + assert.strictEqual(w021.length, 0, 'no W021 warnings expected when prefix matches milestone'); + }); + + // ── 3. Sentinel ranges are exempt ──────────────────────────────────────── + + test('W021 does NOT fire for sentinel range: phase 999-01 (backlog)', () => { + buildRoadmap(tmpDir, [ + { + version: 'v1.0', + label: 'Foundation', + phases: [{ id: '999-01', name: 'Backlog item' }], + }, + ]); + + const result = runGsdTools(['roadmap', 'validate'], tmpDir); + assert.ok(result.success, `roadmap validate failed: ${result.error}`); + + const out = JSON.parse(result.output); + const w021 = (out.warnings || []).filter(w => w.code === 'W021'); + assert.strictEqual(w021.length, 0, 'backlog sentinel (999-xx) should be exempt from W021'); + }); + + test('W021 does NOT fire for sentinel range: phase 0-01 (pre-milestone)', () => { + buildRoadmap(tmpDir, [ + { + version: 'v1.0', + label: 'Foundation', + phases: [{ id: '0-01', name: 'Pre-milestone work' }], + }, + ]); + + const result = runGsdTools(['roadmap', 'validate'], tmpDir); + assert.ok(result.success, `roadmap validate failed: ${result.error}`); + + const out = JSON.parse(result.output); + const w021 = (out.warnings || []).filter(w => w.code === 'W021'); + assert.strictEqual(w021.length, 0, 'pre-milestone sentinel (0-xx) should be exempt from W021'); + }); + + // ── 4. null convention disables W021 ───────────────────────────────────── + + test('W021 does NOT fire when phase_id_convention is null (free-form roadmap)', () => { + buildRoadmap( + tmpDir, + [ + { + version: 'v2.0', + label: 'Expansion', + // Deliberately mismatched prefix to confirm the rule is disabled + phases: [{ id: '1-01', name: 'Setup' }], + }, + ], + { conventionValue: null } + ); + + const result = runGsdTools(['roadmap', 'validate'], tmpDir); + assert.ok(result.success, `roadmap validate failed: ${result.error}`); + + const out = JSON.parse(result.output); + const w021 = (out.warnings || []).filter(w => w.code === 'W021'); + assert.strictEqual(w021.length, 0, 'W021 must not fire when convention is null'); + }); + + // ── 5. `roadmap validate` returns JSON with warnings array ─────────────── + + test("'gsd-tools roadmap validate' subcommand returns JSON with warnings array", () => { + buildRoadmap(tmpDir, [ + { + version: 'v1.0', + label: 'Foundation', + phases: [{ id: '1-01', name: 'Setup' }], + }, + ]); + + const result = runGsdTools(['roadmap', 'validate'], tmpDir); + assert.ok(result.success, `roadmap validate should succeed: ${result.error}`); + + let out; + try { + out = JSON.parse(result.output); + } catch { + assert.fail(`roadmap validate output is not valid JSON: ${result.output}`); + } + + assert.ok(typeof out === 'object' && out !== null, 'output should be a JSON object'); + assert.ok(Array.isArray(out.warnings), 'output should have a warnings array'); + }); + + // ── 6. W021 message includes migration command ──────────────────────────── + + test('W021 warning text includes the migration command', () => { + buildRoadmap(tmpDir, [ + { + version: 'v2.0', + label: 'Expansion', + phases: [{ id: '1-01', name: 'Mismatched phase' }], + }, + ]); + + const result = runGsdTools(['roadmap', 'validate'], tmpDir); + assert.ok(result.success, `roadmap validate failed: ${result.error}`); + + const out = JSON.parse(result.output); + const w021 = (out.warnings || []).filter(w => w.code === 'W021'); + assert.ok(w021.length > 0, 'W021 warning expected'); + + const migrationCmd = 'gsd-tools roadmap upgrade --convention milestone-prefixed'; + const hasMigration = w021.some(w => typeof w.message === 'string' && w.message.includes(migrationCmd)); + assert.ok( + hasMigration, + `W021 warning message should include "${migrationCmd}". Got: ${JSON.stringify(w021.map(w => w.message))}` + ); + }); +}); diff --git a/tests/roadmap-command-router.test.cjs b/tests/roadmap-command-router.test.cjs index 3f09d146e..faf9aa49b 100644 --- a/tests/roadmap-command-router.test.cjs +++ b/tests/roadmap-command-router.test.cjs @@ -82,6 +82,6 @@ describe('roadmap-command-router', () => { }, }); - assert.equal(message, 'Unknown roadmap subcommand. Available: analyze, get-phase, update-plan-progress, annotate-dependencies'); + assert.equal(message, 'Unknown roadmap subcommand. Available: analyze, get-phase, update-plan-progress, annotate-dependencies, validate, upgrade'); }); });