From 2988a21c462e1254aede204d28c3dac053e46dcc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 8 Jun 2026 11:12:08 -0400 Subject: [PATCH] refactor(#865): extract pure phase-id helpers from core.cts into phase-id.cts (#868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-857 rollout phase 2a — the first cut of the core.cts decomposition. Move the 9 pure phase-id parsing/matching helpers (escapeRegex, normalizePhaseName, comparePhaseNum, extractPhaseToken, phaseTokenMatches, phaseMarkdownRegexSource/Exact, getMilestoneFromPhaseId, getPhaseDirFromPhaseId) out of core.cts into a new leaf module src/phase-id.cts. core.cts re-exports them (behavior-preserving); its internal callers resolve the destructured bindings. Cycle-safe by design: phase-id depends on nothing in core, so re-export creates no circular require (the property that made phase-1 io.cts clean). This is the leaf-first ordering — it unblocks the roadmap-parser extraction (2b), which imports phaseMarkdownRegexSource. New-CLI-module checklist: .gitignore, eslint.config.mjs ignores, INVENTORY.md count 91->92 + row, INVENTORY-MANIFEST.json, ARCHITECTURE.md row, CONTEXT.md "Phase Id Module" glossary entry. Adds tests/phase-id.test.cjs (63 behavioral tests incl. shim-identity + adversarial inputs). Gates: lint, code-review, security-review, codex adversarial-review (all 0 findings), and gsd-test-both (14814 pass on Mac + Linux Docker, 0 fail). Closes #865 Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + CONTEXT.md | 3 + docs/ARCHITECTURE.md | 3 +- docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + src/core.cts | 199 +--------------- src/phase-id.cts | 217 ++++++++++++++++++ tests/phase-id.test.cjs | 429 +++++++++++++++++++++++++++++++++++ 9 files changed, 664 insertions(+), 193 deletions(-) create mode 100644 src/phase-id.cts create mode 100644 tests/phase-id.test.cjs diff --git a/.gitignore b/.gitignore index 3d0c8ac23..43e98f2cb 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,7 @@ build/ /gsd-core/bin/lib/command-routing-hub.cjs /gsd-core/bin/lib/core.cjs /gsd-core/bin/lib/io.cjs +/gsd-core/bin/lib/phase-id.cjs /gsd-core/bin/lib/drift.cjs /gsd-core/bin/lib/cjs-command-router-adapter.cjs /gsd-core/bin/lib/phase-command-router.cjs diff --git a/CONTEXT.md b/CONTEXT.md index c040e97f9..994670b42 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,9 @@ Module owning `milestone complete` (archive roadmap/requirements/phases, build M ### Dispatch Pipeline Module Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. The SDK-era pipeline collapsed onto the Command Routing Hub per ADR-0174; current dispatch seam: `gsd-core/bin/lib/command-routing-hub.cjs` (see Command Routing Hub below). +### Phase Id Module +Module owning the pure phase-id parsing and matching helpers: phase-name normalization, phase-token extraction/matching, milestone- and phase-dir id parsing, and phase-markdown regex builders (`escapeRegex`, `normalizePhaseName`, `comparePhaseNum`, `extractPhaseToken`, `phaseTokenMatches`, `phaseMarkdownRegexSource`/`phaseMarkdownRegexSourceExact`, `getMilestoneFromPhaseId`, `getPhaseDirFromPhaseId`). Pure string/regex — no I/O, no config, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2a (#865) as the cycle-free leaf that unblocks the roadmap-parser and phase-locator extractions; `core.cjs` re-exports the helpers for back-compat. Source of truth: `gsd-core/bin/lib/phase-id.cjs` (generated from `src/phase-id.cts`). + ### Phase Lifecycle Module Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3aa176c8..05bdbd192 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -342,8 +342,9 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | Module | Responsibility | | ---------------------- | --------------------------------------------------------------------------------------------------- | -| `core.cjs` | Shared utilities; compatibility re-exports for planning and I/O (`io.cjs`) helpers | +| `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers | | `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover | +| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, regex builders (extracted from `core.cjs`, ADR-857) | | `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) | | `state.cjs` | STATE.md parsing, updating, progression, metrics | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 4dde591fe..634a0fc41 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -310,6 +310,7 @@ "package-identity.cjs", "package-legitimacy.cjs", "phase-command-router.cjs", + "phase-id.cjs", "phase-lifecycle.cjs", "phase.cjs", "phases-command-router.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 69f15b57b..d99ced286 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (91 shipped) +## CLI Modules (92 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -421,6 +421,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `package-identity.cjs` | Generated single source for GSD's published-package coordinates (npm name, bin name, repo slug, changelog URL, manual-install command), derived from package.json; read by the update worker, `check-latest-version`, and installer (#498) | | `package-legitimacy.cjs` | Registry-API package legitimacy verdicts (OK/SUS/SLOP) from npm/PyPI/crates, slopcheck optional | | `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` | +| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, milestone/phase-dir id parsing, phase-markdown regex builders (extracted from `core.cjs`, ADR-857) | | `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` | diff --git a/eslint.config.mjs b/eslint.config.mjs index c461b21c8..757642109 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -90,6 +90,7 @@ export default tseslint.config( 'gsd-core/bin/lib/command-routing-hub.cjs', 'gsd-core/bin/lib/core.cjs', 'gsd-core/bin/lib/io.cjs', + 'gsd-core/bin/lib/phase-id.cjs', 'gsd-core/bin/lib/drift.cjs', 'gsd-core/bin/lib/cjs-command-router-adapter.cjs', 'gsd-core/bin/lib/phase-command-router.cjs', diff --git a/src/core.cts b/src/core.cts index dcfad3e29..23b986e93 100644 --- a/src/core.cts +++ b/src/core.cts @@ -14,6 +14,9 @@ import { execGit, platformWriteSync, platformReadSync } from './shell-command-pr import ioModule = require('./io.cjs'); const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule; // eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdModule = require('./phase-id.cjs'); +const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, phaseTokenMatches } = phaseIdModule; +// eslint-disable-next-line @typescript-eslint/no-require-imports import modelProfiles = require('./model-profiles.cjs'); const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles; import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, PROVIDER_PRESETS, KNOWN_PROVIDERS } from './model-catalog.cjs'; @@ -518,197 +521,11 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] { // ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ─── -// ─── Phase utilities ────────────────────────────────────────────────────────── - -function escapeRegex(value: unknown): string { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -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)/, ''); - // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition). - const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); - if (milestoneMatch) { - const major = milestoneMatch[1].padStart(2, '0'); - 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) { - const padded = match[1].padStart(2, '0'); - // Preserve original case of letter suffix (#1962). - const letter = match[2] || ''; - const decimal = match[3] || ''; - return padded + letter + decimal; - } - // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is - return str; -} - -function getMilestoneFromPhaseId(phaseId: unknown): string | null { - 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: 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 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. - */ -function phaseMarkdownRegexSource(phaseNum: unknown): string { - const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); - - // Milestone-prefixed IDs: M-NN or M-N-N (deep). - const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i); - if (milestoneSegments && milestoneSegments[2]) { - const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0'; - const subParts = milestoneSegments[2].slice(1).split('-'); - 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); - - const integer = match[1].replace(/^0+/, '') || '0'; - const letter = match[2] ? escapeRegex(match[2]) : ''; - const decimal = match[3] ? escapeRegex(match[3]) : ''; - return `0*${escapeRegex(integer)}${letter}${decimal}`; -} - -/** - * #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`, - * return the exact-escaped form. - */ -function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null { - const raw = String(phaseNum); - if (!/^[A-Z]{1,6}-(?=\d)/i.test(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 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; - } - const sufA = milestoneA[3] || ''; - const sufB = milestoneB[3] || ''; - if (sufA !== sufB) return sufA < sufB ? -1 : 1; - return 0; - } - - 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 (!pa || !pb) return String(a).localeCompare(String(b)); - const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10); - if (intDiff !== 0) return intDiff; - const la = (pa[2] || '').toUpperCase(); - const lb = (pb[2] || '').toUpperCase(); - if (la !== lb) { - if (!la) return -1; - if (!lb) return 1; - return la < lb ? -1 : 1; - } - const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; - const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; - const maxLen = Math.max(aDecParts.length, bDecParts.length); - if (aDecParts.length === 0 && bDecParts.length > 0) return -1; - if (bDecParts.length === 0 && aDecParts.length > 0) return 1; - for (let i = 0; i < maxLen; i++) { - const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0; - const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0; - if (av !== bv) return av - bv; - } - return 0; -} - -/** - * Extract the phase token from a directory name. - */ -function extractPhaseToken(dirName: string): string { - const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i); - let prefix = ''; - let rest = dirName; - if (codePrefixMatch) { - prefix = codePrefixMatch[1] + '-'; - rest = codePrefixMatch[2]; - } - - const segments = rest.split('-'); - const tokenSegments: string[] = []; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - if (/^\d/.test(seg)) { - tokenSegments.push(seg); - } else { - break; - } - } - - if (tokenSegments.length === 0) { - return dirName; - } - - return prefix + tokenSegments.join('-'); -} - -/** - * Check if a directory name's phase token matches the normalized phase exactly. - */ -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, ''); - if (stripped !== dirName) { - const strippedToken = extractPhaseToken(stripped); - if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true; - } - return false; -} +// ─── Phase utilities (pure helpers re-exported from phase-id.cjs) ───────────── +// escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, +// phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, +// extractPhaseToken, phaseTokenMatches +// — all imported via `phaseIdModule` above; internal callers use the destructured bindings. function extractCanonicalPlanId(filename: string): string { const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, ''); diff --git a/src/phase-id.cts b/src/phase-id.cts new file mode 100644 index 000000000..360bb3ecc --- /dev/null +++ b/src/phase-id.cts @@ -0,0 +1,217 @@ +/** + * Pure phase-id parsing/matching helpers — normalize, token match, + * milestone/phase-dir id parsing, phase-markdown regex builders. + * + * Extracted from core.cts (ADR-857 rollout phase 2a / issue #865). + * The hand-written bodies are preserved byte-for-behaviour; only the module + * boundary moved. core.cts re-exports every symbol here under its own + * `export =` object so existing consumers are unaffected. + * + * New imports should pull phase-id helpers from phase-id.cjs directly. + * + * Dependencies: none (pure string/regex, no Node built-ins required). + */ + +// ─── Phase-id helpers ───────────────────────────────────────────────────────── + +function escapeRegex(value: unknown): string { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +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)/, ''); + // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition). + const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i); + if (milestoneMatch) { + const major = milestoneMatch[1].padStart(2, '0'); + 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) { + const padded = match[1].padStart(2, '0'); + // Preserve original case of letter suffix (#1962). + const letter = match[2] || ''; + const decimal = match[3] || ''; + return padded + letter + decimal; + } + // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is + return str; +} + +function getMilestoneFromPhaseId(phaseId: unknown): string | null { + 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: 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 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. + */ +function phaseMarkdownRegexSource(phaseNum: unknown): string { + const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + + // Milestone-prefixed IDs: M-NN or M-N-N (deep). + const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i); + if (milestoneSegments && milestoneSegments[2]) { + const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0'; + const subParts = milestoneSegments[2].slice(1).split('-'); + 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); + + const integer = match[1].replace(/^0+/, '') || '0'; + const letter = match[2] ? escapeRegex(match[2]) : ''; + const decimal = match[3] ? escapeRegex(match[3]) : ''; + return `0*${escapeRegex(integer)}${letter}${decimal}`; +} + +/** + * #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`, + * return the exact-escaped form. + */ +function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null { + const raw = String(phaseNum); + if (!/^[A-Z]{1,6}-(?=\d)/i.test(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 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; + } + const sufA = milestoneA[3] || ''; + const sufB = milestoneB[3] || ''; + if (sufA !== sufB) return sufA < sufB ? -1 : 1; + return 0; + } + + 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 (!pa || !pb) return String(a).localeCompare(String(b)); + const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10); + if (intDiff !== 0) return intDiff; + const la = (pa[2] || '').toUpperCase(); + const lb = (pb[2] || '').toUpperCase(); + if (la !== lb) { + if (!la) return -1; + if (!lb) return 1; + return la < lb ? -1 : 1; + } + const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; + const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; + const maxLen = Math.max(aDecParts.length, bDecParts.length); + if (aDecParts.length === 0 && bDecParts.length > 0) return -1; + if (bDecParts.length === 0 && aDecParts.length > 0) return 1; + for (let i = 0; i < maxLen; i++) { + const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0; + const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0; + if (av !== bv) return av - bv; + } + return 0; +} + +/** + * Extract the phase token from a directory name. + */ +function extractPhaseToken(dirName: string): string { + const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i); + let prefix = ''; + let rest = dirName; + if (codePrefixMatch) { + prefix = codePrefixMatch[1] + '-'; + rest = codePrefixMatch[2]; + } + + const segments = rest.split('-'); + const tokenSegments: string[] = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (/^\d/.test(seg)) { + tokenSegments.push(seg); + } else { + break; + } + } + + if (tokenSegments.length === 0) { + return dirName; + } + + return prefix + tokenSegments.join('-'); +} + +/** + * Check if a directory name's phase token matches the normalized phase exactly. + */ +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, ''); + if (stripped !== dirName) { + const strippedToken = extractPhaseToken(stripped); + if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true; + } + return false; +} + +export = { + escapeRegex, + normalizePhaseName, + getMilestoneFromPhaseId, + getPhaseDirFromPhaseId, + phaseMarkdownRegexSource, + phaseMarkdownRegexSourceExact, + comparePhaseNum, + extractPhaseToken, + phaseTokenMatches, +}; diff --git a/tests/phase-id.test.cjs b/tests/phase-id.test.cjs new file mode 100644 index 000000000..f16b95cee --- /dev/null +++ b/tests/phase-id.test.cjs @@ -0,0 +1,429 @@ +/** + * Tests for src/phase-id.cts (compiled to gsd-core/bin/lib/phase-id.cjs). + * + * Verifies behavioural contracts of the extracted pure phase-id helpers: + * - escapeRegex + * - normalizePhaseName + * - comparePhaseNum + * - extractPhaseToken + * - phaseTokenMatches + * - phaseMarkdownRegexSource + * - phaseMarkdownRegexSourceExact + * - getMilestoneFromPhaseId + * - getPhaseDirFromPhaseId + * - core.cjs re-export shims resolve to the exact same functions (single instance) + * + * ADR-857 rollout phase 2a / issue #865. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); +const core = require('../gsd-core/bin/lib/core.cjs'); + +// ─── escapeRegex ───────────────────────────────────────────────────────────── + +describe('escapeRegex', () => { + test('escapes all regex special characters', () => { + assert.strictEqual(phaseId.escapeRegex('.'), '\\.'); + assert.strictEqual(phaseId.escapeRegex('*'), '\\*'); + assert.strictEqual(phaseId.escapeRegex('+'), '\\+'); + assert.strictEqual(phaseId.escapeRegex('?'), '\\?'); + assert.strictEqual(phaseId.escapeRegex('^'), '\\^'); + assert.strictEqual(phaseId.escapeRegex('$'), '\\$'); + assert.strictEqual(phaseId.escapeRegex('{'), '\\{'); + assert.strictEqual(phaseId.escapeRegex('}'), '\\}'); + assert.strictEqual(phaseId.escapeRegex('('), '\\('); + assert.strictEqual(phaseId.escapeRegex(')'), '\\)'); + assert.strictEqual(phaseId.escapeRegex('|'), '\\|'); + assert.strictEqual(phaseId.escapeRegex('['), '\\['); + assert.strictEqual(phaseId.escapeRegex(']'), '\\]'); + assert.strictEqual(phaseId.escapeRegex('\\'), '\\\\'); + }); + + test('leaves alphanumeric and hyphen characters unescaped', () => { + assert.strictEqual(phaseId.escapeRegex('abc'), 'abc'); + assert.strictEqual(phaseId.escapeRegex('01-02'), '01-02'); + assert.strictEqual(phaseId.escapeRegex('v1.0'), 'v1\\.0'); + }); + + test('coerces non-string values via String()', () => { + assert.strictEqual(phaseId.escapeRegex(42), '42'); + assert.strictEqual(phaseId.escapeRegex(null), 'null'); + assert.strictEqual(phaseId.escapeRegex(undefined), 'undefined'); + }); + + test('adversarial: path-traversal-like inputs are treated as literals', () => { + const result = phaseId.escapeRegex('../../../etc/passwd'); + // The dots get escaped; slashes and alphanumeric pass through unchanged + assert.strictEqual(result, '\\.\\./\\.\\./\\.\\./etc/passwd'); + // The result forms a valid regex (no throws) + assert.doesNotThrow(() => new RegExp(result)); + }); + + test('unicode passthrough', () => { + assert.strictEqual(phaseId.escapeRegex('Phase Name'), 'Phase Name'); + assert.strictEqual(phaseId.escapeRegex('中文'), '中文'); + }); +}); + +// ─── normalizePhaseName ─────────────────────────────────────────────────────── + +describe('normalizePhaseName', () => { + test('zero-pads single-digit phase', () => { + assert.strictEqual(phaseId.normalizePhaseName('1'), '01'); + assert.strictEqual(phaseId.normalizePhaseName('3'), '03'); + }); + + test('leaves two-digit phase unchanged', () => { + assert.strictEqual(phaseId.normalizePhaseName('12'), '12'); + }); + + test('strips project_code prefix before normalizing', () => { + assert.strictEqual(phaseId.normalizePhaseName('CK-01'), '01'); + assert.strictEqual(phaseId.normalizePhaseName('PROJ-3'), '03'); + assert.strictEqual(phaseId.normalizePhaseName('AB-12'), '12'); + }); + + test('handles letter suffix (preserves original case per #1962)', () => { + assert.strictEqual(phaseId.normalizePhaseName('12A'), '12A'); + assert.strictEqual(phaseId.normalizePhaseName('3b'), '03b'); + }); + + test('handles decimal phase IDs', () => { + assert.strictEqual(phaseId.normalizePhaseName('12.1'), '12.1'); + assert.strictEqual(phaseId.normalizePhaseName('3.10'), '03.10'); + }); + + test('handles milestone-prefixed IDs (M-NN form)', () => { + assert.strictEqual(phaseId.normalizePhaseName('1-1'), '01-01'); + assert.strictEqual(phaseId.normalizePhaseName('2-3'), '02-03'); + assert.strictEqual(phaseId.normalizePhaseName('1-2-3'), '01-02-03'); + }); + + 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) + assert.strictEqual(phaseId.normalizePhaseName('PROJ-42'), '42'); + assert.strictEqual(phaseId.normalizePhaseName('AUTH-101'), '101'); + }); + + test('custom phase IDs with non-numeric remainder pass through as-is', () => { + // No project_code pattern, no numeric match → return str as-is + assert.strictEqual(phaseId.normalizePhaseName('my-phase'), 'my-phase'); + }); + + test('coerces non-string values', () => { + assert.strictEqual(phaseId.normalizePhaseName(5), '05'); + }); +}); + +// ─── comparePhaseNum ────────────────────────────────────────────────────────── + +describe('comparePhaseNum', () => { + test('sorts numeric phases in ascending order', () => { + const phases = ['03', '01', '10', '02']; + const sorted = [...phases].sort(phaseId.comparePhaseNum); + assert.deepStrictEqual(sorted, ['01', '02', '03', '10']); + }); + + test('compares single-digit vs two-digit correctly', () => { + assert.ok(phaseId.comparePhaseNum('1', '02') < 0); + assert.ok(phaseId.comparePhaseNum('02', '1') > 0); + assert.strictEqual(phaseId.comparePhaseNum('1', '01'), 0); + }); + + test('handles decimal phases', () => { + assert.ok(phaseId.comparePhaseNum('1', '1.1') < 0); + assert.ok(phaseId.comparePhaseNum('1.1', '1.2') < 0); + assert.ok(phaseId.comparePhaseNum('1.10', '1.9') > 0); + assert.strictEqual(phaseId.comparePhaseNum('1.1', '01.1'), 0); + }); + + test('handles letter suffix ordering (no letter < A < B)', () => { + assert.ok(phaseId.comparePhaseNum('01', '01A') < 0); + assert.ok(phaseId.comparePhaseNum('01A', '01B') < 0); + assert.ok(phaseId.comparePhaseNum('01B', '01') > 0); + }); + + test('handles milestone-prefixed IDs', () => { + assert.ok(phaseId.comparePhaseNum('1-1', '1-2') < 0); + assert.ok(phaseId.comparePhaseNum('2-1', '1-10') > 0); + assert.ok(phaseId.comparePhaseNum('1-2-3', '1-2-4') < 0); + assert.strictEqual(phaseId.comparePhaseNum('01-01', '1-1'), 0); + }); + + test('strips project_code prefix before comparing', () => { + assert.strictEqual(phaseId.comparePhaseNum('CK-01', '01'), 0); + assert.ok(phaseId.comparePhaseNum('CK-01', 'CK-02') < 0); + }); + + test('handles non-parseable phase IDs via localeCompare fallback', () => { + // Should not throw on non-numeric IDs + const result = phaseId.comparePhaseNum('alpha', 'beta'); + assert.strictEqual(typeof result, 'number'); + }); +}); + +// ─── extractPhaseToken ──────────────────────────────────────────────────────── + +describe('extractPhaseToken', () => { + test('extracts simple numeric token from directory name', () => { + assert.strictEqual(phaseId.extractPhaseToken('01-some-phase-name'), '01'); + assert.strictEqual(phaseId.extractPhaseToken('12A-feature'), '12A'); + }); + + test('extracts milestone-prefixed numeric token', () => { + assert.strictEqual(phaseId.extractPhaseToken('01-02-some-name'), '01-02'); + assert.strictEqual(phaseId.extractPhaseToken('02-03-04-deep'), '02-03-04'); + }); + + 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'); + }); + + test('returns the full dirName when no numeric token found', () => { + assert.strictEqual(phaseId.extractPhaseToken('no-numeric'), 'no-numeric'); + assert.strictEqual(phaseId.extractPhaseToken('alpha'), 'alpha'); + }); + + test('stops at first non-numeric-starting segment', () => { + assert.strictEqual(phaseId.extractPhaseToken('01-02-name-03'), '01-02'); + }); +}); + +// ─── phaseTokenMatches ──────────────────────────────────────────────────────── + +describe('phaseTokenMatches', () => { + test('matches exact token (case-insensitive)', () => { + assert.ok(phaseId.phaseTokenMatches('01-some-phase', '01')); + assert.ok(phaseId.phaseTokenMatches('12A-feature', '12A')); + assert.ok(phaseId.phaseTokenMatches('12A-feature', '12a')); + }); + + test('matches with project_code prefix stripped', () => { + assert.ok(phaseId.phaseTokenMatches('CK-01-phase', '01')); + assert.ok(phaseId.phaseTokenMatches('PROJ-12-feature', '12')); + }); + + test('does not match when token differs', () => { + assert.ok(!phaseId.phaseTokenMatches('01-some-phase', '02')); + assert.ok(!phaseId.phaseTokenMatches('12A-feature', '12B')); + }); + + test('matches milestone-prefixed token', () => { + assert.ok(phaseId.phaseTokenMatches('01-02-feature', '01-02')); + assert.ok(!phaseId.phaseTokenMatches('01-02-feature', '01-03')); + }); +}); + +// ─── phaseMarkdownRegexSource ───────────────────────────────────────────────── + +describe('phaseMarkdownRegexSource', () => { + test('produces a regex source that matches zero-padded variants', () => { + const src = phaseId.phaseMarkdownRegexSource('1'); + const re = new RegExp(src); + assert.ok(re.test('1')); + assert.ok(re.test('01')); + assert.ok(re.test('001')); + }); + + test('produces source matching a two-digit phase', () => { + const src = phaseId.phaseMarkdownRegexSource('12'); + const re = new RegExp(src); + assert.ok(re.test('12')); + assert.ok(re.test('012')); + assert.ok(!re.test('13')); + }); + + test('handles letter suffix', () => { + const src = phaseId.phaseMarkdownRegexSource('12A'); + const re = new RegExp(src, 'i'); + assert.ok(re.test('12A')); + assert.ok(re.test('012A')); + }); + + test('handles decimal phases', () => { + const src = phaseId.phaseMarkdownRegexSource('3.1'); + const re = new RegExp(src); + assert.ok(re.test('3.1')); + assert.ok(re.test('03.1')); + assert.ok(!re.test('3.2')); + }); + + test('handles milestone-prefixed phase IDs', () => { + const src = phaseId.phaseMarkdownRegexSource('1-2'); + const re = new RegExp(src); + assert.ok(re.test('1-2')); + assert.ok(re.test('01-02')); + assert.ok(re.test('01-2')); + assert.ok(!re.test('1-3')); + }); + + test('strips project_code prefix before building regex', () => { + const withPrefix = phaseId.phaseMarkdownRegexSource('CK-01'); + const withoutPrefix = phaseId.phaseMarkdownRegexSource('01'); + assert.strictEqual(withPrefix, withoutPrefix); + }); + + test('falls back to escaped literal for unparseable input', () => { + const src = phaseId.phaseMarkdownRegexSource('v1.0'); + assert.strictEqual(typeof src, 'string'); + assert.ok(src.length > 0); + }); + + test('adversarial: phase num containing regex metacharacters is escaped', () => { + // e.g. some exotic value that shouldn't break regexp construction + const src = phaseId.phaseMarkdownRegexSource('3.1'); + // The literal dot in "3.1" should be escaped so it only matches a real dot + const re = new RegExp(src); + assert.ok(!re.test('3X1'), 'unescaped dot would match any char — must be escaped'); + }); +}); + +// ─── phaseMarkdownRegexSourceExact ──────────────────────────────────────────── + +describe('phaseMarkdownRegexSourceExact', () => { + test('returns escaped form for project-code-prefixed IDs', () => { + const result = phaseId.phaseMarkdownRegexSourceExact('PROJ-42'); + // hyphen is not a regex special char so it passes through unescaped + assert.strictEqual(result, 'PROJ-42'); + // The result is a valid regex source + assert.doesNotThrow(() => new RegExp(result)); + }); + + test('returns null for non-prefixed IDs', () => { + assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('01'), null); + assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('12A'), null); + assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('1-2'), null); + }); + + test('null coercion: returns null for null/undefined', () => { + assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact(null), null); + assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact(undefined), null); + }); + + test('resulting regex matches the exact prefixed ID', () => { + const src = phaseId.phaseMarkdownRegexSourceExact('AUTH-101'); + assert.ok(src !== null); + const re = new RegExp(src); + assert.ok(re.test('AUTH-101')); + assert.ok(!re.test('AUTH-102')); + }); +}); + +// ─── getMilestoneFromPhaseId ────────────────────────────────────────────────── + +describe('getMilestoneFromPhaseId', () => { + test('returns vN.0 for a milestone-prefixed phase id', () => { + assert.strictEqual(phaseId.getMilestoneFromPhaseId('1-01'), 'v1.0'); + assert.strictEqual(phaseId.getMilestoneFromPhaseId('02-03'), 'v2.0'); + assert.strictEqual(phaseId.getMilestoneFromPhaseId('10-5'), 'v10.0'); + }); + + test('returns null for non-milestone-prefixed IDs', () => { + assert.strictEqual(phaseId.getMilestoneFromPhaseId('01'), null); + assert.strictEqual(phaseId.getMilestoneFromPhaseId('12A'), null); + }); + + test('returns null for special sentinel milestones 0 and 999', () => { + assert.strictEqual(phaseId.getMilestoneFromPhaseId('0-1'), null); + assert.strictEqual(phaseId.getMilestoneFromPhaseId('999-1'), null); + }); + + test('strips project_code prefix before parsing', () => { + assert.strictEqual(phaseId.getMilestoneFromPhaseId('CK-2-01'), 'v2.0'); + }); + + test('coerces non-string values', () => { + // numeric doesn't match the milestone pattern — returns null + assert.strictEqual(phaseId.getMilestoneFromPhaseId(42), null); + }); +}); + +// ─── getPhaseDirFromPhaseId ─────────────────────────────────────────────────── + +describe('getPhaseDirFromPhaseId', () => { + test('returns null for non-milestone-format IDs', () => { + assert.strictEqual(phaseId.getPhaseDirFromPhaseId('01', null, null), null); + assert.strictEqual(phaseId.getPhaseDirFromPhaseId('12A', null, null), null); + }); + + test('constructs dir name from milestone-prefixed phase id (no name, no code)', () => { + const result = phaseId.getPhaseDirFromPhaseId('1-2', null, null); + assert.strictEqual(result, '01-02'); + }); + + test('includes phaseName slug', () => { + const result = phaseId.getPhaseDirFromPhaseId('1-2', 'My Feature', null); + assert.strictEqual(result, '01-02-my-feature'); + }); + + test('prepends projectCode when provided', () => { + const result = phaseId.getPhaseDirFromPhaseId('1-2', 'Auth', 'CK'); + assert.strictEqual(result, 'CK-01-02-auth'); + }); + + test('strips project_code from phaseId before parsing', () => { + const result = phaseId.getPhaseDirFromPhaseId('CK-1-2', null, null); + assert.strictEqual(result, '01-02'); + }); + + test('handles deep decomposition IDs (M-N-N)', () => { + // m[2] is "02-03" for input "1-2-3" — split and pad each sub-part + const result = phaseId.getPhaseDirFromPhaseId('1-2-3', null, null); + assert.strictEqual(result, '01-02-03'); + }); + + test('slug strips leading/trailing hyphens from phaseName', () => { + const result = phaseId.getPhaseDirFromPhaseId('1-1', ' --some--name-- ', null); + // normalize: replace non-alnum runs with hyphen, strip edges + assert.ok(result !== null); + assert.ok(!result.startsWith('-')); + assert.ok(!result.endsWith('-')); + }); +}); + +// ─── core.cjs re-export shim identity assertions ────────────────────────────── + +describe('core.cjs re-export shim identity (single instance)', () => { + test('core.escapeRegex === phaseId.escapeRegex', () => { + assert.strictEqual(core.escapeRegex, phaseId.escapeRegex); + }); + + test('core.normalizePhaseName === phaseId.normalizePhaseName', () => { + assert.strictEqual(core.normalizePhaseName, phaseId.normalizePhaseName); + }); + + test('core.comparePhaseNum === phaseId.comparePhaseNum', () => { + assert.strictEqual(core.comparePhaseNum, phaseId.comparePhaseNum); + }); + + test('core.extractPhaseToken === phaseId.extractPhaseToken', () => { + assert.strictEqual(core.extractPhaseToken, phaseId.extractPhaseToken); + }); + + test('core.phaseTokenMatches === phaseId.phaseTokenMatches', () => { + assert.strictEqual(core.phaseTokenMatches, phaseId.phaseTokenMatches); + }); + + test('core.phaseMarkdownRegexSource === phaseId.phaseMarkdownRegexSource', () => { + assert.strictEqual(core.phaseMarkdownRegexSource, phaseId.phaseMarkdownRegexSource); + }); + + test('core.phaseMarkdownRegexSourceExact === phaseId.phaseMarkdownRegexSourceExact', () => { + assert.strictEqual(core.phaseMarkdownRegexSourceExact, phaseId.phaseMarkdownRegexSourceExact); + }); + + test('core.getMilestoneFromPhaseId === phaseId.getMilestoneFromPhaseId', () => { + assert.strictEqual(core.getMilestoneFromPhaseId, phaseId.getMilestoneFromPhaseId); + }); + + test('core.getPhaseDirFromPhaseId === phaseId.getPhaseDirFromPhaseId', () => { + assert.strictEqual(core.getPhaseDirFromPhaseId, phaseId.getPhaseDirFromPhaseId); + }); +});