* fix(#2232): cap phase-token continuation segments at exactly 2 digits (all sites) A phase whose slug's first word is a ≥2-digit number (dir 14-2026-photos-performance, roadmap phase "2026 Photos & Performance" → slug 2026-photos-…) had its phase token over-collected as "14-2026" instead of "14", so every phase-locating verb (init.plan-phase, init.execute-phase, phase-plan-index, state.planned-phase, roadmap.annotate-dependencies) resolved phase_dir=null / plan_count=0 while the directory existed. This is the residual case #2043 explicitly scoped out: its ≥2-digit continuation gate (\d{2,}) distinguishes single-digit slug words but not multi-digit ones (years, counts). The structural distinguisher: getPhaseDirFromPhaseId writes sub-phase and plan continuation segments zero-padded to EXACTLY 2 digits, so a genuine continuation's digit run is exactly 2 — \d{2}(?!\d). The (?!\d) guard caps the run without anchoring what follows, so each call site keeps its own trailing grammar (letter suffixes, dotted sub-phases, boundaries). Shared-source, not hand-synced: the grammar lives once in phase-id.cts as PHASE_CONTINUATION_SEGMENT_SOURCE / isPhaseContinuationSegment (the #2121 single-owner seam), consumed by all five #2043 sites: - phase-id.cts extractPhaseToken (the reported repro) - validate.cts PHASE_TOKEN_FROM_DIR_RE + canonicalPlanStem - roadmap-parser.cts isDirInMilestone numericRe (hyphenated mode) - core-utils.cts + phase.cts extractCanonicalPlanId (paired plan component only — the LEADING phase component keeps unbounded \d{2,}; phase numbers ≥100 are legitimate) Digit-width policy, resolved per triage and locked by boundary tests at 1/2/3/4-digit continuation widths across all sites: sub-phase/plan numbers ≥100 are out of the dir-token grammar. validate.cts phaseDirNameRe's leading \d{2,} is intentionally untouched — it encodes the write-side padding of the leading dir number, not the continuation heuristic, and has no year collision. Fixes #2232 Claude-Session: https://claude.ai/code/session_017KaYUJnfzV3JVVuQnhkcjg * chore(#2232): add changeset for PR #2254 Claude-Session: https://claude.ai/code/session_017KaYUJnfzV3JVVuQnhkcjg * test(#2232): parity gate + fast-check properties for the continuation cap Addresses trek-e's review on PR #2254 (M1, M2, B1). Test-only — the fix itself was verified as a true root-cause fix, so no source changes. M1 — drift/parity enforcement for the new shared constant. scripts/lint-phase-id-drift.cjs guards PHASE_NUMBER_TOKEN_SOURCE only; its TOKEN_DRIFT_RE cannot match a bare \d{2,} re-derivation, so a future edit reintroducing a raw digit-cap at a consuming site would pass lint + CI silently. Extending the lint was rejected: \d{2,} legitimately appears at the intentionally-unbounded LEADING-token sites (validate phaseDirNameRe, core-utils/phase tokenRe), so a textual guard would need sanctions on correct code and would flag by spelling rather than by behaviour. Instead, per the repo's *-parity.test.cjs precedent, added tests/phase-continuation-parity.test.cjs: a shared digit-width corpus (1/2/3/4/5) asserting every consuming surface's notion of "is this segment absorbed" equals isPhaseContinuationSegment(). Covers all five #2043 sites: extractPhaseToken, PHASE_TOKEN_FROM_DIR_RE, canonicalPlanStem, extractCanonicalPlanId (paired component), and roadmap isDirInMilestone (hyphenated mode, on a real ROADMAP fixture). The corpus states the policy independently of the regex, so it fails on divergence rather than mirroring whatever the code does. Failing-first verified: reverting PHASE_TOKEN_FROM_DIR_RE to \d{2,} fails 3 parity tests; reverting the owner constant itself fails 11 across parity + properties + examples. M2 — fast-check properties for the changed parser (4 added to phase-id.test.cjs, following its existing inline fc precedent): - biconditional: a segment is absorbed IFF its digit run is exactly 2 - the owner agrees with observable extraction for every digit run - metamorphic: a write-side getPhaseDirFromPhaseId dir round-trips to its own normalizePhaseName id — ties the cap to the zero-padding convention it mirrors, so a change to the write-side width fails loudly - metamorphic: the round-trip holds when the phase name leads with a year (the #2232 bug itself, generatively) Digit runs are generated as digit strings (not String(int)) so leading-zero forms like "02" — the whole point of the rule — are actually exercised. B1 — GitGuardian red. The session-trailer hypothesis is disproven: the same Claude-Session trailer rides 3 commits now merged to next via #2173, whose GitGuardian check PASSED. GitGuardian's own comment names tests/phase-id.test.cjs:260 — the synthetic dir literal 'M1-14-2026-photos' tripping the generic high-entropy detector. Composed it from parts; the assertion is unchanged, only the source spelling. Refs #2232 Claude-Session: https://claude.ai/code/session_019SkiJk38YWAbmxHrGxEmuU * test(#2232): name the parity gate after the invariant, not the phase module CI caught two failures from the new parity test, both one root cause: lint-test-file-count caps each production module at 2 test files (primary + one integration, per the #3740 consolidation). The file was named phase-continuation-parity.test.cjs, and the linter clusters a test to a production module by name prefix — "phase-*" bound it to src/phase.cts, whose cluster (phase.test.cjs + phase-dependency-levels.test.cjs) was already at the cap, making 3. That tripped the lint-tests job AND the ubuntu-24 unit lane, where tests/lint-test-file-count.test.cjs is a meta-test asserting the linter exits 0 against the real repo. Renamed to continuation-grammar-parity.test.cjs, matching the convention the repo's other cross-cutting parity gates already follow: they are named after the INVARIANT, not a module — capability-precedence-parity, agent-classification-parity, and runtime-launcher-parity all have no corresponding src/*.cts, so they cluster to nothing. The gate tests a grammar shared ACROSS phase-id/validate/core-utils/roadmap-parser rather than the phase module specifically, so the invariant-name is also the semantically correct home. Not allowlisted: a novel offender belongs under the cap, not ratcheted into the exemption list. Content unchanged — same 12 assertions across the same 5 surfaces. Refs #2232 Claude-Session: https://claude.ai/code/session_019SkiJk38YWAbmxHrGxEmuU --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
403 lines
18 KiB
TypeScript
403 lines
18 KiB
TypeScript
/**
|
|
* 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. The core.cjs re-export spine was retired in epic #1267;
|
|
* callers import 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, '\\$&');
|
|
}
|
|
|
|
// project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
|
|
// leading underscores are not valid project codes per .planning/config.json.
|
|
const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
|
|
const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
|
|
const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
|
|
const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
|
|
|
|
// #1729: phase headers may carry a parenthetical tag between the number and the
|
|
// colon, e.g. `### Phase 26 (Cluster B): Title`. This optional, non-capturing
|
|
// fragment is injected at every phase-header regex call site (immediately after
|
|
// the phase-number token, before the colon/space delimiter) so the resolver
|
|
// tolerates the tag — mirroring how `[...]` is already tolerated before `Phase`.
|
|
// `[^)\n]*` keeps the match single-line (headers are one line) to avoid
|
|
// over-consuming across a malformed multi-line document. Injected at the call
|
|
// site (not baked into phaseMarkdownRegexSource) so it applies uniformly to
|
|
// both the numeric and project-code-exact escaped sources, and so the decimal
|
|
// sub-phase patterns can place it after the `.N` segment.
|
|
//
|
|
// Enumeration/parse call sites that read phase headers from a regex *literal*
|
|
// (rather than a `new RegExp` built from an interpolated phase number) cannot
|
|
// reference this constant; they inline its literal-regex mirror instead —
|
|
// `(?:\s*\([^)\n]{0,200}\))?` — kept character-for-character equivalent to this
|
|
// source. Both forms must change together; see the #1729 regression test.
|
|
const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
|
|
|
// #2128: the canonical phase-NUMBER-TOKEN grammar — a phase number with an
|
|
// optional single-letter variant suffix and optional dotted sub-phases
|
|
// (1, 01, 12A, 12.1, 3.2.1). This is the ENUMERATION/scan counterpart to
|
|
// phaseMarkdownRegexSource: use phaseMarkdownRegexSource(n) to build a source
|
|
// for ONE KNOWN number; reference this constant when a call site must match ANY
|
|
// phase and capture its token. Enumeration/parse sites inline this into a
|
|
// `new RegExp(...)` instead of re-deriving the grammar as a literal, so every
|
|
// phase-token producer shares one owner. The anti-divergence guard
|
|
// (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
|
|
// introduced outside this module without a `// phase-id-owner:` justification.
|
|
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
|
|
|
// #2232: the canonical CONTINUATION-segment grammar — a dash-separated segment
|
|
// that extends a phase token (a zero-padded sub-phase or plan number, e.g. the
|
|
// "01" in "02-01-setup"). getPhaseDirFromPhaseId writes these zero-padded to
|
|
// exactly 2 digits, so the digit RUN of a genuine continuation is exactly 2:
|
|
// #2043's `\d{2,}` (2-or-more) over-collected a slug word that merely leads
|
|
// with ≥2 digits (a year: "14-2026-photos-…" yielded token "14-2026", so every
|
|
// phase-locating verb reported the phase as missing). The `(?!\d)` guard caps
|
|
// the run at 2 without anchoring what may follow, so call sites keep their own
|
|
// trailing grammar (letter suffixes, dotted sub-phases, segment boundaries).
|
|
// POLICY (locked by boundary tests): sub-phase/plan numbers ≥100 are out of the
|
|
// dir-token grammar — the LEADING phase number stays unbounded (`\d+`), only
|
|
// continuation segments are width-capped. Shared from here so the five #2043
|
|
// call sites cannot drift independently (see scripts/lint-phase-id-drift.cjs).
|
|
const PHASE_CONTINUATION_SEGMENT_SOURCE = '\\d{2}(?!\\d)';
|
|
const PHASE_CONTINUATION_SEGMENT_PREFIX_RE = new RegExp(`^${PHASE_CONTINUATION_SEGMENT_SOURCE}`);
|
|
function isPhaseContinuationSegment(seg: string): boolean {
|
|
return PHASE_CONTINUATION_SEGMENT_PREFIX_RE.test(seg);
|
|
}
|
|
|
|
function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string {
|
|
const input = String(value);
|
|
const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
|
|
return input.replace(re, '');
|
|
}
|
|
|
|
function hasProjectCodePrefix(value: unknown): boolean {
|
|
return PROJECT_CODE_PREFIX_STRIP_RE_I.test(String(value));
|
|
}
|
|
|
|
function normalizePhaseName(phase: unknown): string {
|
|
const str = String(phase);
|
|
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
|
const stripped = stripProjectCodePrefix(str, false);
|
|
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
|
|
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
if (milestoneMatch) {
|
|
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 stripped = stripProjectCodePrefix(phaseId);
|
|
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 stripped = stripProjectCodePrefix(phaseId);
|
|
const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
|
|
if (!m) return null;
|
|
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
|
|
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 = stripProjectCodePrefix(phaseNum);
|
|
|
|
// 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 (!hasProjectCodePrefix(raw)) return null;
|
|
return escapeRegex(raw);
|
|
}
|
|
|
|
function comparePhaseNum(a: unknown, b: unknown): number {
|
|
// Strip optional project_code prefix before comparing
|
|
const sa = stripProjectCodePrefix(a);
|
|
const sb = stripProjectCodePrefix(b);
|
|
|
|
const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
|
|
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(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
|
let prefix = '';
|
|
let rest = dirName;
|
|
if (codePrefixMatch) {
|
|
prefix = codePrefixMatch[1] + '-';
|
|
rest = codePrefixMatch[2];
|
|
}
|
|
|
|
const segments = rest.split('-');
|
|
const tokenSegments: string[] = [];
|
|
// #2043: distinguish a real (zero-padded) phase/sub-phase segment from a
|
|
// single-digit slug word. A pure-numeric leading segment ("46") only
|
|
// continues with exactly-2-digit segments (#2232: a ≥3-digit run is a slug
|
|
// word such as a year — "14-2026-photos-…" yields "14", not "14-2026"), so
|
|
// "46-6-rs-…" yields "46" (the "6" is the
|
|
// slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here
|
|
// with "M1-" already stripped as a project-code prefix (see
|
|
// PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same
|
|
// pure-numeric rule applies (M1-46-6-rs → "M1-46"). The firstLetterPrefixed
|
|
// carve-out covers letter+digit leading segments that survive prefix stripping
|
|
// because of punctuation (e.g. "P0.3-2"), whose single-digit continuation is
|
|
// intentionally preserved (unchanged from prior behaviour).
|
|
let firstLetterPrefixed = false;
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const seg = segments[i];
|
|
if (i === 0) {
|
|
if (/^\d/.test(seg)) {
|
|
tokenSegments.push(seg);
|
|
} else if (/^[A-Za-z]{1,3}\d/.test(seg)) {
|
|
tokenSegments.push(seg);
|
|
firstLetterPrefixed = true;
|
|
} else {
|
|
break;
|
|
}
|
|
} else if (isPhaseContinuationSegment(seg) || (firstLetterPrefixed && /^\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 = stripProjectCodePrefix(dirName);
|
|
if (stripped !== dirName) {
|
|
const strippedToken = extractPhaseToken(stripped);
|
|
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ─── #2121 canonical surface (ADR-2121) ──────────────────────────────────────
|
|
|
|
/**
|
|
* Parse a phase identifier from a STATE.md `Phase:` prose field VALUE — the text
|
|
* after the `Phase:` label (e.g. `"3 of 4 (Delta)"`, `"3A — Delta (executing)"`,
|
|
* or `"Milestone v0.5 complete"`).
|
|
*
|
|
* The token is anchored to the START of the value (after an optional literal
|
|
* `Phase ` label and an optional project-code prefix) so a phase is only
|
|
* returned when the value actually begins with one. This is the #2111 fix: the
|
|
* prior unanchored `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mined the first numeral
|
|
* anywhere, so `"Milestone v0.5 complete"` collapsed to `"5"` (the minor-version
|
|
* digit) and `"v1.0"` to `"0"` (a reserved sentinel). Here both yield
|
|
* `{ phase: null }` because they do not begin with a phase token. The name
|
|
* extraction (parenthetical or em-dash tail, minus status words) is unchanged.
|
|
*/
|
|
function parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null } {
|
|
if (!value) return { phase: null, name: null };
|
|
// Coerce defensively so a non-string caller cannot throw on this canonical
|
|
// surface (mirrors the sibling #2121 functions' String(...) handling).
|
|
const str = String(value);
|
|
const phaseMatch = str.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i);
|
|
// The name-extraction quantifiers are length-bounded so a crafted long
|
|
// unterminated run (many `(` or `—`) in an untrusted STATE.md field value
|
|
// cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase
|
|
// name is far shorter than the cap.
|
|
const parenName = str.match(/\(([^)]{1,200})\)/);
|
|
const dashName = str.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/);
|
|
const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
|
|
const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
|
|
? rawName.trim()
|
|
: null;
|
|
return {
|
|
phase: phaseMatch ? phaseMatch[1] : null,
|
|
name,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Config-AWARE project-code prefix strip. Unlike the config-blind
|
|
* `stripProjectCodePrefix` (which strips ANY `<CODE>-` shape), this strips the
|
|
* leading `<CODE>-` ONLY when `<CODE>` case-insensitively equals the configured
|
|
* `projectCode`. A foreign prefix (`MEM-01` when the configured code is `LKML`)
|
|
* or an absent/empty `projectCode` is preserved verbatim — this is the #2104
|
|
* fix: a foreign-prefixed id must not collapse to a bare numeric phase and
|
|
* collide with a real one.
|
|
*/
|
|
function stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string {
|
|
const input = String(value);
|
|
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
|
|
if (!configured) return input;
|
|
const m = input.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
|
if (!m) return input;
|
|
if (m[1].toUpperCase() !== configured.toUpperCase()) return input;
|
|
return m[2];
|
|
}
|
|
|
|
/**
|
|
* True when `phase` carries a project-code prefix that is NOT the configured
|
|
* `projectCode` (or when no `projectCode` is configured). The canonical
|
|
* predicate the init-command foreign-prefix guard (#2056 / PR #2105) delegates
|
|
* to, so every call site shares one foreign-prefix rule.
|
|
*/
|
|
function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean {
|
|
const m = String(phase).match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
|
if (!m) return false;
|
|
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
|
|
return !configured || m[1].toUpperCase() !== configured.toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Canonical ROADMAP heading lookup-source list (moved here from
|
|
* roadmap-parser.cts so phase-id.cts is the single owner of the ordering).
|
|
* Sources are tried in a fixed, deduplicated order: exact (only when the query
|
|
* itself is project-code-prefixed) → bare numeric / padding-tolerant →
|
|
* prefix-tolerant fallback. The bare numeric source precedes the prefix-tolerant
|
|
* form so a canonical heading (`### Phase 117:`) is preferred over a drifted
|
|
* prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP.
|
|
*/
|
|
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
|
|
const sources: string[] = [];
|
|
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
|
|
if (exactSource) sources.push(exactSource);
|
|
|
|
const numericSource = phaseMarkdownRegexSource(phaseNum);
|
|
sources.push(numericSource);
|
|
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
|
|
|
|
return [...new Set(sources)];
|
|
}
|
|
|
|
export = {
|
|
escapeRegex,
|
|
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
|
OPTIONAL_PHASE_TAG_SOURCE,
|
|
PHASE_NUMBER_TOKEN_SOURCE,
|
|
PHASE_CONTINUATION_SEGMENT_SOURCE,
|
|
isPhaseContinuationSegment,
|
|
stripProjectCodePrefix,
|
|
normalizePhaseName,
|
|
getMilestoneFromPhaseId,
|
|
getPhaseDirFromPhaseId,
|
|
phaseMarkdownRegexSource,
|
|
phaseMarkdownRegexSourceExact,
|
|
comparePhaseNum,
|
|
extractPhaseToken,
|
|
phaseTokenMatches,
|
|
parsePhaseFromProse,
|
|
stripConfiguredProjectCodePrefix,
|
|
isForeignPrefixedPhaseQuery,
|
|
roadmapPhaseLookupSources,
|
|
};
|