* fix(#1729): resolve phase headers with a pre-colon parenthetical tag A phase header may carry a parenthetical tag between the number and the colon, e.g. `### Phase 26 (Cluster B): Title`. Every phase-header regex built `Phase\s+<num>` immediately against the colon delimiter, so the tagged phase was invisible: the resolver returned found:false and, just as bad, the capture-all enumeration/parse paths (roadmap analyze, milestone listing + milestone-scope filter, verify, init/import, state total_phases, validate, the command router, preamble stripping, and the phase-remove renumbering rewrite) silently dropped, miscounted, or failed to renumber it — wrong phase_count, progress_percent, next_phase, or corrupt numbering after a removal. The fix tolerates the tag at the header seam. Parameterized resolver sites compose the exported OPTIONAL_PHASE_TAG_SOURCE fragment; literal enumeration sites inline its character-for-character mirror `(?:\s*\([^)\n]*\))?`, placed immediately before the colon so it cannot alter an existing match (optional, single-line, one paren pair, no capture-group shift). In the renumber-on-removal rewrite the tag is folded into the re-emitted suffix capture so it survives verbatim. Both forms are documented to change together and a drift-guard test asserts their behavioral equivalence over a header corpus. Deliberately excluded: roadmap-upgrade.cts (legacy one-time migration), where tolerating the tag would silently drop it on header rewrite — that needs its own data-preserving treatment. Known boundaries left for follow-up: checklist/bullet-style phase entries (`- [ ] Phase N (tag):`) and a malformed space-before-colon variant, both pre-existing. Validated empirically against the issue's reproduction: `roadmap get-phase 26` resolves and `roadmap analyze` lists Phase 26 with the tag excluded from the name (phase_count 2, next 26); an all-tagged versioned roadmap now scopes correctly instead of falling back to a pass-all filter. Regression coverage in tests/phase.test.cjs asserts resolver parity (pre- vs post-colon), padding tolerance (#3537), decimal sub-phases, no cross-phase false match, the shared seam, enumeration coherence, renumber-preserves-tag, and seam/mirror drift. Full unit suite green (7291 pass, 0 fail); eslint + regression-name + resolution-provenance + changeset lints pass. Reviewed by Codex (no critical/high; the two enumeration misses it surfaced are folded in). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#1729): add changeset for pre-colon phase-tag fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
d96c7ef865
commit
12b35eeeaf
@@ -23,6 +23,24 @@ 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]*\))?` — 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]*\\))?';
|
||||
|
||||
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;
|
||||
@@ -220,6 +238,7 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean {
|
||||
export = {
|
||||
escapeRegex,
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
OPTIONAL_PHASE_TAG_SOURCE,
|
||||
stripProjectCodePrefix,
|
||||
normalizePhaseName,
|
||||
getMilestoneFromPhaseId,
|
||||
|
||||
Reference in New Issue
Block a user