@opengsd/gsd-core 1.19.0 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +46 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +181 -68
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +627 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3039 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* Pure phase-id parsing/matching helpers — normalize, token match,
|
|
4
|
-
* milestone/phase-dir id parsing, phase-markdown regex builders
|
|
4
|
+
* milestone/phase-dir id parsing, phase-markdown regex builders, and identity
|
|
5
|
+
* indexes over caller-supplied roadmap text. No filesystem or config reads.
|
|
5
6
|
*
|
|
6
7
|
* Extracted from core.cts (ADR-857 rollout phase 2a / issue #865).
|
|
7
8
|
* The hand-written bodies are preserved byte-for-behaviour; only the module
|
|
8
9
|
* boundary moved. The core.cjs re-export spine was retired in epic #1267;
|
|
9
10
|
* callers import phase-id helpers from phase-id.cjs directly.
|
|
10
11
|
*
|
|
12
|
+
* Custom directory lookup decision table (#5186):
|
|
13
|
+
* 1. An exact physical basename selects that directory and reports its owner.
|
|
14
|
+
* 2. Exact recorded IDs win; a unique case/padding alias may name a record.
|
|
15
|
+
* Multiple alias records remain ambiguous rather than last-write-wins.
|
|
16
|
+
* 3. Markerless reads use known heading/ID prefixes; mutations refuse when
|
|
17
|
+
* more than one owner fits. Marked segment-prefix pairs are independent.
|
|
18
|
+
* 4. Unrecorded numeric and bracket queries keep their legacy matching paths.
|
|
19
|
+
*
|
|
11
20
|
* Dependencies:
|
|
21
|
+
* - ./markdown-sectionizer.cjs (tokenizeHeadings over supplied roadmap text)
|
|
12
22
|
* - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam; this module is no
|
|
13
23
|
* longer the owner of pattern-escaping, only a consumer)
|
|
14
24
|
* - ./core-utils.cjs (generateSlugInternal — #3883/ADR-3473 §8.3: the
|
|
@@ -23,7 +33,9 @@
|
|
|
23
33
|
* body) instead, mirroring the same cycle-break already used by
|
|
24
34
|
* core-utils.cts's own getPhaseFileStats/plan-scan.cjs seam.
|
|
25
35
|
*/
|
|
36
|
+
const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
|
|
26
37
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
38
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
27
39
|
// ─── Phase-id helpers ─────────────────────────────────────────────────────────
|
|
28
40
|
// project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
|
|
29
41
|
// leading underscores are not valid project codes per .planning/config.json.
|
|
@@ -59,7 +71,9 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
|
|
59
71
|
// (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
|
|
60
72
|
// introduced outside this module without a `// phase-id-owner:` justification.
|
|
61
73
|
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
|
62
|
-
// #
|
|
74
|
+
// #5676: milestone-prefixed M-NN token grammar (one optional -NN segment, the #5626 grammar).
|
|
75
|
+
const PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED = '\\d+(?:-\\d+)?[A-Z]?(?:\\.\\d+)*';
|
|
76
|
+
// #4764: a legacy phase REFERENCE in depends-on PROSE — the token in context, directly
|
|
63
77
|
// following "Phase"/"Phases", with bare-token list continuation ("Phases 1 and 2",
|
|
64
78
|
// "Phase 1, 2, and 3", "Phase 1-3"). Built HERE, beside the token grammar it is
|
|
65
79
|
// anchored on, because two readers consume Depends-on prose (init.manager's
|
|
@@ -72,7 +86,8 @@ const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
|
|
72
86
|
// dependencies. The `-` separator deliberately extracts range ENDPOINTS only
|
|
73
87
|
// ("Phase 1-3" → 1, 3) — the pre-#4764 behavior; interior enumeration stays
|
|
74
88
|
// out (a range's middle is not written as a reference).
|
|
75
|
-
const
|
|
89
|
+
const PHASE_DEP_TOKEN_LIST_SOURCE = `${PHASE_NUMBER_TOKEN_SOURCE}(?:(?:\\s*,\\s*(?:and\\s+)?|\\s+and\\s+|\\s*&\\s*|\\s+(?:to|through)\\s+|\\s*-\\s*)${PHASE_NUMBER_TOKEN_SOURCE})*`;
|
|
90
|
+
const PHASE_DEP_REF_SOURCE = `\\bphases?\\s+(${PHASE_DEP_TOKEN_LIST_SOURCE})`;
|
|
76
91
|
// #2528 review: the CASE-FLEXIBLE renderings of the two sources above, for call
|
|
77
92
|
// sites that scan directory names (where a project code or a variant suffix may
|
|
78
93
|
// legitimately be lowercase) and therefore cannot use a case-sensitive class.
|
|
@@ -303,6 +318,43 @@ const PHASE_HEADING_BASELINE = Object.freeze({
|
|
|
303
318
|
*
|
|
304
319
|
* Pure: takes the resolved convention, never reads config.
|
|
305
320
|
*/
|
|
321
|
+
// #4304: `bracketAlt`'s own alternation, factored out to a named
|
|
322
|
+
// function so every write-side consumer that needs "this bracket, optionally
|
|
323
|
+
// followed by the literal Phase label, then a digit" derives from the SAME
|
|
324
|
+
// expression the read grammar compiles — never a hand-retyped `[ \t]+` /
|
|
325
|
+
// case-sensitive copy. `idSrc` is the regex SOURCE for the bracket's
|
|
326
|
+
// `{CODE}.{MM}` interior: the generic `BRACKET_ID_SRC` class (capturing or
|
|
327
|
+
// not) for a classifier that must recognize ANY bracket identity, or one
|
|
328
|
+
// escaped, already-known project/milestone pair (via
|
|
329
|
+
// `bracketQualifiedIntroSrcFor` below) for a rewriter/detector that already
|
|
330
|
+
// knows exactly which identity it means. Returns only the regex SOURCE —
|
|
331
|
+
// every caller compiles it themselves (every reader uses the `i` flag), so
|
|
332
|
+
// this composes with `g`, an outer capturing group, or a trailing literal
|
|
333
|
+
// number.
|
|
334
|
+
function bracketAltIntroSrcFor(idSrc) {
|
|
335
|
+
// `[ \t]*` not `\s*`: `\s` spans newlines, so a bracket-terminated heading
|
|
336
|
+
// followed by a blank line and a digit-leading prose line read as one phase.
|
|
337
|
+
return `\\[${idSrc}\\][ \\t]*(?:Phase\\s+|(?=\\d))`;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* #4304: the qualified-mention intro for ONE already-known
|
|
341
|
+
* bracket identity — `[{PROJECT}.{MM}]` (both escaped literals, not a
|
|
342
|
+
* class), optionally followed by the `Phase` label — built from
|
|
343
|
+
* `bracketAltIntroSrcFor` so a rewriter or detector anchoring a specific
|
|
344
|
+
* phase-number token right after it accepts exactly the spellings
|
|
345
|
+
* `phaseHeadingPrefixSrcFor`'s bracket alternative does: any case (compile
|
|
346
|
+
* with `i`), zero-or-more spaces, an optional `Phase` label. phase.cts's
|
|
347
|
+
* write-side bracket-identity regexes (the qualified-reference replacer, the
|
|
348
|
+
* qualified-mention detector) build their intro through this instead of
|
|
349
|
+
* re-typing `[ \t]+` / case-sensitive brackets independently of the read
|
|
350
|
+
* grammar. A hand-composed case-sensitive, `[ \t]+`-spaced copy would reject
|
|
351
|
+
* `[ck.02] 02:`, `[CK.02] PHASE 02:`, and the no-space `[CK.02]02:`, all of
|
|
352
|
+
* which `roadmap get-phase`, `roadmap analyze`, `phase insert` and
|
|
353
|
+
* `phase add` accept as real phases.
|
|
354
|
+
*/
|
|
355
|
+
function bracketQualifiedIntroSrcFor(project, milestone) {
|
|
356
|
+
return bracketAltIntroSrcFor(`${(0, pattern_cjs_1.escapeRegex)(String(project))}\\.${(0, pattern_cjs_1.escapeRegex)(String(milestone))}`);
|
|
357
|
+
}
|
|
306
358
|
function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
|
|
307
359
|
const base = baseline === PHASE_HEADING_BASELINE.ANY_BRACKET
|
|
308
360
|
? BASE_ANY_BRACKET_HEADING_PREFIX_SRC
|
|
@@ -310,8 +362,6 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
|
|
|
310
362
|
if (convention !== 'bracket')
|
|
311
363
|
return base;
|
|
312
364
|
const id = capturing ? `(${BRACKET_ID_SRC})` : BRACKET_ID_SRC;
|
|
313
|
-
// `[ \t]*` not `\s*`: `\s` spans newlines, so a bracket-terminated heading
|
|
314
|
-
// followed by a blank line and a digit-leading prose line read as one phase.
|
|
315
365
|
// BOTH bracket forms are admitted at both baselines, and both CAPTURE. The
|
|
316
366
|
// any-bracket base already matches `[GSD.999] Phase 07:` on its own — but
|
|
317
367
|
// through the base alternative, which captures nothing, so the reader saw
|
|
@@ -319,9 +369,31 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
|
|
|
319
369
|
// counted a labeled icebox heading as a real phase while the label-less form
|
|
320
370
|
// beside it was excluded. Two derivations of one ROADMAP disagreed. The
|
|
321
371
|
// bracket alternative is tried FIRST so it wins the capture.
|
|
322
|
-
const bracketAlt =
|
|
372
|
+
const bracketAlt = bracketAltIntroSrcFor(id);
|
|
323
373
|
return `(?:${bracketAlt}|${base})`;
|
|
324
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* Parse one roadmap phase-checkbox row through the same anchored grammar used
|
|
377
|
+
* by readers and writers (#4982). The identity must be the first thing after
|
|
378
|
+
* the checkbox, tolerating only an optional `**`. Bracket mode captures the
|
|
379
|
+
* leading `[CODE.MM] PP` identity. Both paths treat whitespace or a colon
|
|
380
|
+
* immediately after the phase token as the boundary. Returning semantic fields
|
|
381
|
+
* keeps consumers from depending on capture-group offsets.
|
|
382
|
+
*/
|
|
383
|
+
function parsePhaseChecklistLine(line, convention, context) {
|
|
384
|
+
const capturesBracketId = convention === 'bracket';
|
|
385
|
+
const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturesBracketId);
|
|
386
|
+
const pattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${intro}(${context ? phaseScanTokenSource(context, convention) : phaseNumberTokenSource(convention)})`
|
|
387
|
+
+ `${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`, 'i', { state: 'captured' });
|
|
388
|
+
const match = pattern.exec(line);
|
|
389
|
+
if (!match)
|
|
390
|
+
return null;
|
|
391
|
+
return {
|
|
392
|
+
checked: (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(match[1]),
|
|
393
|
+
bracketId: capturesBracketId ? match[2] : undefined,
|
|
394
|
+
phaseToken: capturesBracketId ? match[3] : match[2],
|
|
395
|
+
};
|
|
396
|
+
}
|
|
325
397
|
/**
|
|
326
398
|
* The single owner of the "scan a whole document for every phase heading"
|
|
327
399
|
* pattern (#4865 / ADR-4910 §8) — anchored (`^ {0,3}#{2,4}`, so indentation up
|
|
@@ -339,13 +411,56 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
|
|
|
339
411
|
* This function answers "which phases exist in this content", not "does this
|
|
340
412
|
* specific phase exist".
|
|
341
413
|
*/
|
|
342
|
-
function buildPhaseHeadingScanRegex(baseline, convention) {
|
|
414
|
+
function buildPhaseHeadingScanRegex(baseline, convention, context) {
|
|
343
415
|
const bracketGroup = convention === 'bracket' ? 1 : null;
|
|
344
416
|
const phaseNumGroup = bracketGroup ? 2 : 1;
|
|
345
417
|
const phaseNameGroup = phaseNumGroup + 1;
|
|
346
|
-
const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${
|
|
418
|
+
const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${context && convention !== 'bracket' ? phaseScanTokenSource(context, convention) : phaseNumberTokenSource(convention)})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
|
|
347
419
|
return { regex, bracketGroup, phaseNumGroup, phaseNameGroup };
|
|
348
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* Every scan that answers "which phase numbers does this roadmap text declare" (#5528): section
|
|
423
|
+
* headings, plus checklist bullets when `withBullets`. A non-bracket convention gets the plain
|
|
424
|
+
* `Phase N` heading scan and the checkbox-led `Phase N` bullet it always had.
|
|
425
|
+
*
|
|
426
|
+
* Under the bracket convention it also gets, beyond `buildPhaseHeadingScanRegex`'s `[ID] NN` and
|
|
427
|
+
* `[ID] Phase NN` headings: the word-first heading `Phase [ID] NN`; and a bullet whose bracket IS
|
|
428
|
+
* the id (`- [GSD.02] 03: X`, no checkbox). The checkbox of the other bullet shape may not itself
|
|
429
|
+
* be a bracket id, or `- [GSD.999] Phase 07` would read `[GSD.999]` as a checkbox and drop the id
|
|
430
|
+
* that marks it an icebox row. A bullet is one line, so its label uses `[ \t]` where a heading's
|
|
431
|
+
* uses `\s`.
|
|
432
|
+
*/
|
|
433
|
+
function buildPhaseDeclarationScans(convention, withBullets = true) {
|
|
434
|
+
const heading = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
|
|
435
|
+
const scans = [{ regex: heading.regex, idGroup: heading.bracketGroup, numGroup: heading.phaseNumGroup }];
|
|
436
|
+
const bracket = convention === 'bracket';
|
|
437
|
+
// Under bracket a numbered-list item (`1. [ ] **[GSD.02] 05: n**`) is a checklist row too.
|
|
438
|
+
const bullet = bracket ? '^[ \\t]*(?:-|\\d+[.)])[ \\t]*' : '^[ \\t]*-[ \\t]*';
|
|
439
|
+
// Bracket ids may carry a letter suffix (`12A`), as the heading scan's phase token does.
|
|
440
|
+
const tail = bracket ? '(\\d+)(?=[A-Za-z]?(?:[:.\\s*]|$))' : convention === 'milestone-prefixed' ? `(${phaseNumberTokenSource(convention)})(?=[:.\\s*]|$)` : '(\\d+)(?=[:.\\s*]|$)';
|
|
441
|
+
const checkbox = `\\[${bracket ? `(?!${BRACKET_ID_SRC}\\])` : ''}[^\\]]{0,200}\\][ \\t]*`;
|
|
442
|
+
if (!bracket) {
|
|
443
|
+
if (withBullets)
|
|
444
|
+
scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}Phase[ \\t]+${tail}`, 'gim'), idGroup: null, numGroup: 1 });
|
|
445
|
+
return scans;
|
|
446
|
+
}
|
|
447
|
+
scans.push({ regex: new RegExp(`^ {0,3}#{2,4}\\s*Phase[ \\t]+\\[(${BRACKET_ID_SRC})\\][ \\t]*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gim'), idGroup: 1, numGroup: 2 });
|
|
448
|
+
if (withBullets) {
|
|
449
|
+
const label = `(?:\\[(${BRACKET_ID_SRC})\\][ \\t]*(?:Phase[ \\t]+|(?=\\d))|Phase[ \\t]+)`;
|
|
450
|
+
scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}${label}${tail}`, 'gim'), idGroup: 1, numGroup: 2 });
|
|
451
|
+
// The word-first heading's bullet twin: `- [ ] Phase [GSD.02] 07:`.
|
|
452
|
+
scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}Phase[ \\t]+\\[(${BRACKET_ID_SRC})\\][ \\t]*${tail}`, 'gim'), idGroup: 1, numGroup: 2 });
|
|
453
|
+
// The checkbox forms above deliberately over-count a colon-less `- [ ] [GSD.02] 2026 notes` as phase 2026: a
|
|
454
|
+
// skipped number is harmless, a reused one is not, so do not "fix" them with a colon requirement.
|
|
455
|
+
// With no checkbox to vouch for it the number needs a `:`, a bold close or end of line, or a dash
|
|
456
|
+
// separator (`- [GSD.02] 05 - Name`), or `- [GSD.02] 2026 notes` is phase 2026.
|
|
457
|
+
scans.push({ regex: new RegExp(`${bullet}\\*{0,2}\\[(${BRACKET_ID_SRC})\\][ \\t]*(?:Phase[ \\t]+|(?=\\d))(\\d+)(?=[A-Za-z]?(?::|\\*{0,2}[ \\t]*$|[ \\t]+[-\u2013\u2014][ \\t]))`, 'gim'), idGroup: 1, numGroup: 2 });
|
|
458
|
+
}
|
|
459
|
+
// A dash-separated heading (`### [GSD.02] 05 - Name`) is outside the colon grammar the readers share, but still a declaration.
|
|
460
|
+
// `###`/`####` only: a `##` milestone heading (`## [CK.02] 2 - Foundation`) names the milestone, not a phase.
|
|
461
|
+
scans.push({ regex: new RegExp(`^ {0,3}#{3,4}\\s*\\[(${BRACKET_ID_SRC})\\][ \\t]*(\\d+)(?=[A-Za-z]?[ \\t]+[-\u2013\u2014][ \\t])`, 'gim'), idGroup: 1, numGroup: 2 });
|
|
462
|
+
return scans;
|
|
463
|
+
}
|
|
349
464
|
function stripProjectCodePrefix(value, caseInsensitive = true) {
|
|
350
465
|
const input = String(value);
|
|
351
466
|
const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
|
|
@@ -489,6 +604,116 @@ function parsePhaseId(input) {
|
|
|
489
604
|
// tokens unchanged.
|
|
490
605
|
throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
|
|
491
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* Tokenize dependency references with source spans. Bracket-qualified lists
|
|
609
|
+
* canonicalize every numeric segment before strict identity parsing. If the
|
|
610
|
+
* qualified identity still cannot be parsed, the reader keeps the original
|
|
611
|
+
* token as a legacy dependency instead of silently dropping it.
|
|
612
|
+
*/
|
|
613
|
+
function tokenizePhaseDependencyReferences(prose, convention) {
|
|
614
|
+
const input = prose;
|
|
615
|
+
const found = [];
|
|
616
|
+
const qualifiedTokenSpans = [];
|
|
617
|
+
const legacyRefRe = new RegExp(`${PHASE_DEP_REF_SOURCE}`, 'gi');
|
|
618
|
+
const tokenRe = new RegExp(PHASE_NUMBER_TOKEN_SOURCE, convention === 'bracket' ? 'gi' : 'g');
|
|
619
|
+
let refMatch;
|
|
620
|
+
while ((refMatch = legacyRefRe.exec(input)) !== null) {
|
|
621
|
+
const referenceStart = refMatch.index + refMatch[0].length - refMatch[1].length;
|
|
622
|
+
tokenRe.lastIndex = 0;
|
|
623
|
+
let tokenMatch;
|
|
624
|
+
while ((tokenMatch = tokenRe.exec(refMatch[1])) !== null) {
|
|
625
|
+
const start = referenceStart + tokenMatch.index;
|
|
626
|
+
found.push({ kind: 'legacy', start, end: start + tokenMatch[0].length, token: tokenMatch[0] });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (convention !== 'bracket') {
|
|
630
|
+
return found;
|
|
631
|
+
}
|
|
632
|
+
const bracketDisplayRe = new RegExp(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, 'bracket', true)}(${PHASE_DEP_TOKEN_LIST_SOURCE})`, 'gi');
|
|
633
|
+
let displayMatch;
|
|
634
|
+
while ((displayMatch = bracketDisplayRe.exec(input)) !== null) {
|
|
635
|
+
if (!displayMatch[1])
|
|
636
|
+
continue;
|
|
637
|
+
const tokenList = displayMatch[2];
|
|
638
|
+
const tokenListStart = displayMatch.index + displayMatch[0].length - tokenList.length;
|
|
639
|
+
tokenRe.lastIndex = 0;
|
|
640
|
+
let tokenMatch;
|
|
641
|
+
while ((tokenMatch = tokenRe.exec(tokenList)) !== null) {
|
|
642
|
+
const start = tokenListStart + tokenMatch.index;
|
|
643
|
+
const end = start + tokenMatch[0].length;
|
|
644
|
+
qualifiedTokenSpans.push({ start, end });
|
|
645
|
+
const numeric = tokenMatch[0].split('.').every((part) => /^\d+$/.test(part))
|
|
646
|
+
? tokenMatch[0].split('.').map(pad2).join('.')
|
|
647
|
+
: null;
|
|
648
|
+
try {
|
|
649
|
+
if (numeric === null)
|
|
650
|
+
throw new Error('not a numeric bracket phase token');
|
|
651
|
+
const id = parsePhaseId(`[${foldBracketId(displayMatch[1])}] ${numeric}`);
|
|
652
|
+
found.push({
|
|
653
|
+
kind: 'qualified',
|
|
654
|
+
referenceStart: displayMatch.index,
|
|
655
|
+
start,
|
|
656
|
+
end,
|
|
657
|
+
token: renderPhaseId(id),
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
found.push({
|
|
662
|
+
kind: 'qualified-fallback',
|
|
663
|
+
referenceStart: displayMatch.index,
|
|
664
|
+
start,
|
|
665
|
+
end,
|
|
666
|
+
token: tokenMatch[0],
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const chunks = input.matchAll(/\S+/g);
|
|
672
|
+
for (const chunk of chunks) {
|
|
673
|
+
const candidate = chunk[0]
|
|
674
|
+
.replace(/^[`*_([{]+/, '')
|
|
675
|
+
.replace(/[`*_\])}.:,;]+$/, '');
|
|
676
|
+
try {
|
|
677
|
+
const id = parsePhaseId(candidate);
|
|
678
|
+
const sub = id.subphase ? `.${id.subphase}` : '';
|
|
679
|
+
const canonicalDash = `${id.project}.${id.milestone}-${id.phase}${sub}`;
|
|
680
|
+
if (id.plan || candidate !== canonicalDash)
|
|
681
|
+
continue;
|
|
682
|
+
found.push({
|
|
683
|
+
kind: 'identity',
|
|
684
|
+
start: chunk.index ?? 0,
|
|
685
|
+
end: (chunk.index ?? 0) + candidate.length,
|
|
686
|
+
token: renderPhaseId(id),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
// Most prose chunks are not identities. parsePhaseId is the classifier.
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const trimmed = input.trim();
|
|
694
|
+
if (new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i').test(trimmed)) {
|
|
695
|
+
const start = input.indexOf(trimmed);
|
|
696
|
+
found.push({ kind: 'bare', start, end: start + trimmed.length, token: trimmed });
|
|
697
|
+
}
|
|
698
|
+
const identityAware = found.filter(({ kind, start, end }) => {
|
|
699
|
+
if (kind !== 'legacy')
|
|
700
|
+
return true;
|
|
701
|
+
return !qualifiedTokenSpans.some((span) => start === span.start && end === span.end);
|
|
702
|
+
});
|
|
703
|
+
identityAware.sort((a, b) => a.start - b.start);
|
|
704
|
+
return identityAware;
|
|
705
|
+
}
|
|
706
|
+
function extractPhaseDependencyTokens(prose, convention) {
|
|
707
|
+
const found = tokenizePhaseDependencyReferences(prose, convention);
|
|
708
|
+
const seen = new Set();
|
|
709
|
+
return found.flatMap(({ token }) => {
|
|
710
|
+
const key = token.toUpperCase();
|
|
711
|
+
if (seen.has(key))
|
|
712
|
+
return [];
|
|
713
|
+
seen.add(key);
|
|
714
|
+
return [token];
|
|
715
|
+
});
|
|
716
|
+
}
|
|
492
717
|
function renderMilestoneId(id) {
|
|
493
718
|
return `[${id.project}.${id.milestone}]`;
|
|
494
719
|
}
|
|
@@ -660,7 +885,10 @@ function isSentinelPhaseDir(dirName) {
|
|
|
660
885
|
* Render a regex source fragment matching a phase number against ROADMAP/STATE
|
|
661
886
|
* prose regardless of zero-padding on either side.
|
|
662
887
|
*/
|
|
663
|
-
function phaseMarkdownRegexSource(phaseNum) {
|
|
888
|
+
function phaseMarkdownRegexSource(phaseNum, context) {
|
|
889
|
+
const custom = context && customPhaseQuery(String(phaseNum), context);
|
|
890
|
+
if (custom)
|
|
891
|
+
return (0, pattern_cjs_1.escapeRegex)(custom.id);
|
|
664
892
|
const stripped = stripProjectCodePrefix(phaseNum);
|
|
665
893
|
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
|
|
666
894
|
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
|
|
@@ -698,7 +926,33 @@ function phaseMarkdownRegexSourceExact(phaseNum) {
|
|
|
698
926
|
return null;
|
|
699
927
|
return (0, pattern_cjs_1.escapeRegex)(raw);
|
|
700
928
|
}
|
|
701
|
-
function comparePhaseNum(a, b) {
|
|
929
|
+
function comparePhaseNum(a, b, context) {
|
|
930
|
+
if (context?.phaseNaming === 'custom') {
|
|
931
|
+
const index = customIdentityIndex(context);
|
|
932
|
+
const identity = (value) => {
|
|
933
|
+
const text = String(value);
|
|
934
|
+
if (index.recorded.has(text))
|
|
935
|
+
return text;
|
|
936
|
+
const query = customPhaseQuery(text, context);
|
|
937
|
+
return query?.recordedId ?? (query ? (query.listed ? query.id : customDirectoryOwner(text, query)?.id ?? query.id) : text);
|
|
938
|
+
};
|
|
939
|
+
const first = identity(a), second = identity(b);
|
|
940
|
+
// Arbitrary custom IDs have no numeric ordering: use their declared order.
|
|
941
|
+
// The relation must stay a total preorder for every `.sort` it feeds, so
|
|
942
|
+
// once a project declares a custom ID every declared ID sorts by position
|
|
943
|
+
// and ahead of every undeclared one; undeclared IDs (including unlisted
|
|
944
|
+
// numeric directories) keep the original comparator among themselves.
|
|
945
|
+
// A project with only legacy numeric IDs never enters this branch.
|
|
946
|
+
if (index.hasCustom) {
|
|
947
|
+
const left = index.orderByFold.get(recordedPhaseKey(first)), right = index.orderByFold.get(recordedPhaseKey(second));
|
|
948
|
+
if (left !== undefined && right !== undefined)
|
|
949
|
+
return left - right;
|
|
950
|
+
if (left !== undefined)
|
|
951
|
+
return -1;
|
|
952
|
+
if (right !== undefined)
|
|
953
|
+
return 1;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
702
956
|
// Strip optional project_code prefix before comparing
|
|
703
957
|
const sa = stripProjectCodePrefix(a);
|
|
704
958
|
const sb = stripProjectCodePrefix(b);
|
|
@@ -1292,6 +1546,231 @@ const LEADING_DIGIT_RUN_PREFIX_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}`);
|
|
|
1292
1546
|
const BARE_INTEGER_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}$`);
|
|
1293
1547
|
/** Strip leading zeros for numeric-equality compare, keeping a lone "0". */
|
|
1294
1548
|
const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
|
|
1549
|
+
/** #5186: the directory shape shared by phase.add and custom-ID readers. */
|
|
1550
|
+
const UNSAFE_PHASE_DIRECTORY_PART_RE = /[/\\\0]|\.\./;
|
|
1551
|
+
class PhaseDirectoryNameError extends Error {
|
|
1552
|
+
kind;
|
|
1553
|
+
constructor(message, kind = 'invalid') {
|
|
1554
|
+
super(message);
|
|
1555
|
+
this.kind = kind;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
function phaseDirectoryName(id, slug, projectCode) {
|
|
1559
|
+
if (!id || [id, slug, projectCode ?? ''].some(part => UNSAFE_PHASE_DIRECTORY_PART_RE.test(part))) {
|
|
1560
|
+
throw new PhaseDirectoryNameError('Invalid phase id or directory component: paths, .. and NUL are not allowed');
|
|
1561
|
+
}
|
|
1562
|
+
return `${projectCode ? `${projectCode}-` : ''}${id}-${slug}`;
|
|
1563
|
+
}
|
|
1564
|
+
/** Grammar required only when writing an arbitrary custom identity. */
|
|
1565
|
+
function validateCustomPhaseId(id) {
|
|
1566
|
+
if (!id || id !== id.trim() || /[\s:]/.test(id) || id.startsWith('[')) {
|
|
1567
|
+
throw new PhaseDirectoryNameError('Invalid custom phase id: empty IDs, whitespace, colons and a leading [ are not allowed');
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
/** Keep numeric, letter-suffixed and bracket queries on their legacy path. */
|
|
1571
|
+
const LEGACY_PHASE_ID_RE = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}(?:-\\d+)*$`, 'i');
|
|
1572
|
+
function isCustomPhaseId(id) {
|
|
1573
|
+
return !LEGACY_PHASE_ID_RE.test(id)
|
|
1574
|
+
&& !id.startsWith('[');
|
|
1575
|
+
}
|
|
1576
|
+
/** Preserve every legacy segment while applying its normal padding rules. */
|
|
1577
|
+
function recordedPhaseKey(id) {
|
|
1578
|
+
// Whole-token normalization can discard a letter-suffixed continuation
|
|
1579
|
+
// (2A-2). Normalize its segments independently so it never aliases 2A.
|
|
1580
|
+
return (LEGACY_PHASE_ID_RE.test(id) ? id.split('-').map(normalizePhaseName).join('-') : id).toLowerCase();
|
|
1581
|
+
}
|
|
1582
|
+
const CUSTOM_PHASE_ID_FILE = '.phase-id';
|
|
1583
|
+
/** Naming policy is supplied by the config owner; grammar remains pure. */
|
|
1584
|
+
function phaseDirectoryContext(config, roadmap = '', knownIds = []) {
|
|
1585
|
+
return { phaseNaming: typeof config.phase_naming === 'string' ? config.phase_naming.toLowerCase() : config.phase_naming, projectCode: config.project_code ?? null, roadmap, knownIds };
|
|
1586
|
+
}
|
|
1587
|
+
/** #5676: the single owner of the numeric phase-token source per convention. */
|
|
1588
|
+
function phaseNumberTokenSource(convention) {
|
|
1589
|
+
return convention === 'milestone-prefixed' ? PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED : PHASE_NUMBER_TOKEN_SOURCE;
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Custom scans reuse the numeric source verbatim outside configured custom projects.
|
|
1593
|
+
* #5676: under `milestone-prefixed` the numeric source admits the `M-NN` token
|
|
1594
|
+
* (`3-02`), so a `03-02-beta` directory is read as phase `3-02`, not phase `3`.
|
|
1595
|
+
*/
|
|
1596
|
+
function phaseScanTokenSource(context, convention) {
|
|
1597
|
+
return context?.phaseNaming === 'custom' ? '[^ \t:\n]+' : phaseNumberTokenSource(convention);
|
|
1598
|
+
}
|
|
1599
|
+
const customIdentityIndexes = new WeakMap();
|
|
1600
|
+
function customIdentityIndex(options) {
|
|
1601
|
+
let index = customIdentityIndexes.get(options);
|
|
1602
|
+
if (!index || index.roadmap !== options.roadmap || index.knownIds !== options.knownIds) {
|
|
1603
|
+
const headingId = /^Phase[ \t]+([^ \t:\n]+)(?:[ \t]+\([^)\n]*\))?[ \t]*:/;
|
|
1604
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(options.roadmap ?? '')
|
|
1605
|
+
.filter(heading => heading.level >= 2 && heading.level <= 4)
|
|
1606
|
+
.flatMap(heading => heading.text.match(headingId)?.[1] ?? []);
|
|
1607
|
+
const recorded = new Set(Object.values(options.directoryIds ?? {}));
|
|
1608
|
+
const recordedByFold = new Map();
|
|
1609
|
+
for (const id of recorded) {
|
|
1610
|
+
const key = recordedPhaseKey(id);
|
|
1611
|
+
const aliases = recordedByFold.get(key);
|
|
1612
|
+
if (aliases)
|
|
1613
|
+
aliases.push(id);
|
|
1614
|
+
else
|
|
1615
|
+
recordedByFold.set(key, [id]);
|
|
1616
|
+
}
|
|
1617
|
+
const ids = new Set([...headings, ...options.knownIds ?? [], ...recorded]);
|
|
1618
|
+
index = {
|
|
1619
|
+
roadmap: options.roadmap, knownIds: options.knownIds, explicitIds: new Set(options.knownIds ?? []), ids, recorded, recordedByFold, order: new Map([...ids].map((id, i) => [id, i])),
|
|
1620
|
+
// Declared position under the same fold the writers use, so `02` and `2` share a slot.
|
|
1621
|
+
orderByFold: [...ids].reduce((fold, id, i) => fold.has(recordedPhaseKey(id)) ? fold : fold.set(recordedPhaseKey(id), i), new Map()),
|
|
1622
|
+
hasCustom: recorded.size > 0 || [...ids].some(isCustomPhaseId),
|
|
1623
|
+
prefixes: [...ids].filter(token => token && !UNSAFE_PHASE_DIRECTORY_PART_RE.test(token))
|
|
1624
|
+
.sort((a, b) => b.length - a.length)
|
|
1625
|
+
.map(token => ({ id: token, prefix: phaseDirectoryName(token, '') })),
|
|
1626
|
+
};
|
|
1627
|
+
customIdentityIndexes.set(options, index);
|
|
1628
|
+
}
|
|
1629
|
+
return index;
|
|
1630
|
+
}
|
|
1631
|
+
/** Preflight every custom writer, including all IDs in a batch, before writes. */
|
|
1632
|
+
function validateCustomPhaseIds(ids, context) {
|
|
1633
|
+
const index = customIdentityIndex(context);
|
|
1634
|
+
const known = new Set(index.ids);
|
|
1635
|
+
const prospective = { ...context, knownIds: [...context.knownIds ?? [], ...ids] };
|
|
1636
|
+
for (const id of ids) {
|
|
1637
|
+
validateCustomPhaseId(id);
|
|
1638
|
+
// The same sentinel grammar filters phases list and roadmap.analyze.
|
|
1639
|
+
// Refuse identities those readers would hide rather than creating orphans.
|
|
1640
|
+
if (isSentinelPhaseId(id)) {
|
|
1641
|
+
throw new PhaseDirectoryNameError(`Custom ID ${id} is reserved for backlog or icebox phases and would be hidden by phase readers; choose a visible ID`, 'collision');
|
|
1642
|
+
}
|
|
1643
|
+
if (context.projectCode && id.toLowerCase().startsWith(`${context.projectCode}-`.toLowerCase())) {
|
|
1644
|
+
throw new PhaseDirectoryNameError(`Custom ID ${id} already begins with project_code ${context.projectCode}; pass the ID without its project prefix`);
|
|
1645
|
+
}
|
|
1646
|
+
const collision = [...known].find(other => recordedPhaseKey(id) === recordedPhaseKey(other));
|
|
1647
|
+
if (collision) {
|
|
1648
|
+
throw new PhaseDirectoryNameError(`Custom ID ${id} collides with existing or pending ID ${collision}; choose a distinct ID`, 'collision');
|
|
1649
|
+
}
|
|
1650
|
+
for (const dir of context.unmarkedDirectories ?? []) {
|
|
1651
|
+
const candidates = customDirectoryCandidates(dir, prospective);
|
|
1652
|
+
if (candidates.some(candidate => recordedPhaseKey(candidate) === recordedPhaseKey(id))) {
|
|
1653
|
+
throw new PhaseDirectoryNameError(`Custom ID ${id} would claim markerless directory ${dir}; candidates: ${candidates.join(', ')}. Write the owning ID into ${dir}/${CUSTOM_PHASE_ID_FILE} and retry`, 'collision');
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
known.add(id);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
/** Custom heading IDs are identity hints only; titles never locate directories. */
|
|
1660
|
+
function customPhaseQuery(id, options) {
|
|
1661
|
+
if (options?.phaseNaming !== 'custom' || id.startsWith('['))
|
|
1662
|
+
return null;
|
|
1663
|
+
if (UNSAFE_PHASE_DIRECTORY_PART_RE.test(id))
|
|
1664
|
+
return null;
|
|
1665
|
+
const index = customIdentityIndex(options);
|
|
1666
|
+
const prefix = options.projectCode ? `${options.projectCode}-` : '';
|
|
1667
|
+
const queryBody = prefix && id.toLowerCase().startsWith(prefix.toLowerCase()) ? id.slice(prefix.length) : id;
|
|
1668
|
+
// Markers are authoritative; only legacy-shaped IDs share their ordinary
|
|
1669
|
+
// numeric padding normalization. Resolve recorded IDs before interpreting a
|
|
1670
|
+
// query as a basename: an ID may equal a different directory's full name.
|
|
1671
|
+
const aliases = index.recordedByFold.get(recordedPhaseKey(queryBody)) ?? [];
|
|
1672
|
+
let recorded = index.recorded.has(id) ? id : index.recorded.has(queryBody) ? queryBody : aliases.length === 1 ? aliases[0] : undefined;
|
|
1673
|
+
if (recorded !== undefined) {
|
|
1674
|
+
id = recorded;
|
|
1675
|
+
}
|
|
1676
|
+
else {
|
|
1677
|
+
const directoryId = options.directoryIds?.[id]
|
|
1678
|
+
?? (options.unmarkedDirectories?.includes(id) ? customDirectoryOwner(id, {
|
|
1679
|
+
id, listed: false, projectCode: options.projectCode, prefixes: index.prefixes,
|
|
1680
|
+
})?.id : undefined);
|
|
1681
|
+
if (directoryId !== undefined) {
|
|
1682
|
+
id = directoryId;
|
|
1683
|
+
if (index.recorded.has(id))
|
|
1684
|
+
recorded = id;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
const body = prefix && id.toLowerCase().startsWith(prefix.toLowerCase()) ? id.slice(prefix.length) : id;
|
|
1688
|
+
const listed = index.ids.has(id);
|
|
1689
|
+
// Legacy numeric tokens (including configured display prefixes) keep their
|
|
1690
|
+
// reading unless a writer explicitly recorded this exact ID. That record
|
|
1691
|
+
// also distinguishes an unpadded 2A from the custom continuation 2A-2.
|
|
1692
|
+
if (!index.recorded.has(id) && !body.includes('-') && LEGACY_PHASE_ID_RE.test(body))
|
|
1693
|
+
return null;
|
|
1694
|
+
if (!index.recorded.has(id) && !isCustomPhaseId(id) && !(listed && /[a-z]/i.test(id)))
|
|
1695
|
+
return null;
|
|
1696
|
+
// Only a complete legacy segment identifies a numeric directory. Digit-leading
|
|
1697
|
+
// custom IDs such as 2FA must not collapse to their leading digit run.
|
|
1698
|
+
if (!listed && !options.directoryIds?.[id] && body.includes('-') && LEGACY_PHASE_ID_RE.test(body.split('-')[0]))
|
|
1699
|
+
return null;
|
|
1700
|
+
// Listed identities outrank the query; a directory's slug never invents an ID.
|
|
1701
|
+
return {
|
|
1702
|
+
id, listed: listed, recordedId: recorded, projectCode: options.projectCode, directoryIds: options.directoryIds,
|
|
1703
|
+
prefixes: listed ? index.prefixes : [...index.prefixes, { id, prefix: phaseDirectoryName(id, '') }],
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
/** Candidate project prefixes for the custom directory shape. */
|
|
1707
|
+
function customDirectoryBodies(dir, projectCode) {
|
|
1708
|
+
const bodies = [dir];
|
|
1709
|
+
if (projectCode) {
|
|
1710
|
+
const prefix = `${projectCode}-`;
|
|
1711
|
+
if (dir.startsWith(prefix))
|
|
1712
|
+
bodies.unshift(dir.slice(prefix.length));
|
|
1713
|
+
}
|
|
1714
|
+
return bodies;
|
|
1715
|
+
}
|
|
1716
|
+
/** Known IDs and writer-shaped prefixes are computed once per query. */
|
|
1717
|
+
function customDirectoryOwner(dir, query) {
|
|
1718
|
+
// A writer-recorded identity survives heading edits and archival. A slug
|
|
1719
|
+
// cannot prove where an arbitrary hyphenated ID ends. Legacy directories
|
|
1720
|
+
// keep heading-assisted read compatibility; destructive callers require proof.
|
|
1721
|
+
const recordedId = query.directoryIds?.[dir];
|
|
1722
|
+
if (recordedId !== undefined) {
|
|
1723
|
+
if (UNSAFE_PHASE_DIRECTORY_PART_RE.test(recordedId))
|
|
1724
|
+
return null;
|
|
1725
|
+
const prefixes = [phaseDirectoryName(recordedId, '')];
|
|
1726
|
+
// Scaffold retains legacy numeric directory padding while recording the
|
|
1727
|
+
// requested ID. Resolve its directory shape independently of the query
|
|
1728
|
+
// normalization above; a digit-leading custom ID still owns its full token.
|
|
1729
|
+
if (LEGACY_PHASE_ID_RE.test(recordedId))
|
|
1730
|
+
prefixes.push(phaseDirectoryName(normalizePhaseName(recordedId), ''));
|
|
1731
|
+
for (const prefix of prefixes) {
|
|
1732
|
+
const body = customDirectoryBodies(dir, query.projectCode).find(body => body.startsWith(prefix));
|
|
1733
|
+
if (body)
|
|
1734
|
+
return { id: recordedId, slug: body.slice(prefix.length) };
|
|
1735
|
+
}
|
|
1736
|
+
return null;
|
|
1737
|
+
}
|
|
1738
|
+
for (const body of customDirectoryBodies(dir, query.projectCode)) {
|
|
1739
|
+
const owner = query.prefixes.find(candidate => body.startsWith(candidate.prefix));
|
|
1740
|
+
if (owner)
|
|
1741
|
+
return { id: owner.id, slug: body.slice(owner.prefix.length) };
|
|
1742
|
+
}
|
|
1743
|
+
return null;
|
|
1744
|
+
}
|
|
1745
|
+
/** All plausible owners of a markerless directory, for destructive preflight. */
|
|
1746
|
+
function customDirectoryCandidates(dir, context) {
|
|
1747
|
+
if (context.phaseNaming !== 'custom')
|
|
1748
|
+
return [];
|
|
1749
|
+
const recorded = context.directoryIds?.[dir];
|
|
1750
|
+
if (recorded !== undefined) {
|
|
1751
|
+
if (!recorded || UNSAFE_PHASE_DIRECTORY_PART_RE.test(recorded))
|
|
1752
|
+
return [];
|
|
1753
|
+
const owner = customDirectoryOwner(dir, {
|
|
1754
|
+
id: recorded, listed: true, projectCode: context.projectCode,
|
|
1755
|
+
directoryIds: context.directoryIds, prefixes: [],
|
|
1756
|
+
});
|
|
1757
|
+
return owner ? [owner.id] : [];
|
|
1758
|
+
}
|
|
1759
|
+
const index = customIdentityIndex(context);
|
|
1760
|
+
const candidates = [...new Set(customDirectoryBodies(dir, context.projectCode).flatMap(body => index.prefixes.filter(candidate => body.startsWith(candidate.prefix)).map(candidate => candidate.id)))];
|
|
1761
|
+
// A lone legacy numeric heading is not proof of a custom identity. Preserve
|
|
1762
|
+
// all candidates when ownership is ambiguous, and explicit recorded hints.
|
|
1763
|
+
if (candidates.length === 1 && LEGACY_PHASE_ID_RE.test(candidates[0])
|
|
1764
|
+
&& !candidates[0].includes('-') && !index.explicitIds.has(candidates[0]))
|
|
1765
|
+
return [];
|
|
1766
|
+
return candidates;
|
|
1767
|
+
}
|
|
1768
|
+
function customPhaseDirectoryParts(dir, query) {
|
|
1769
|
+
const owner = customDirectoryOwner(dir, query);
|
|
1770
|
+
if (query.recordedId !== undefined)
|
|
1771
|
+
return owner?.id === query.recordedId ? owner : null;
|
|
1772
|
+
return owner?.id.toLowerCase() === query.id.toLowerCase() ? owner : null;
|
|
1773
|
+
}
|
|
1295
1774
|
/**
|
|
1296
1775
|
* #2528: the CANONICAL phase-directory match selection — the one rule every
|
|
1297
1776
|
* directory-resolution path (the shared locator plus the `find-phase` and
|
|
@@ -1299,7 +1778,11 @@ const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
|
|
|
1299
1778
|
* here because the surrounding scan/ambiguity/shaping code exists per site and
|
|
1300
1779
|
* had already diverged; the selection itself must not.
|
|
1301
1780
|
*
|
|
1302
|
-
*
|
|
1781
|
+
* Readers supply their naming context; legacy direct callers default to the
|
|
1782
|
+
* context-free numeric policy. Configured custom IDs
|
|
1783
|
+
* resolve through the inverse of phaseDirectoryName before normalization can
|
|
1784
|
+
* collapse an ID such as A-2 into a numeric query. Other queries retain the
|
|
1785
|
+
* legacy two passes:
|
|
1303
1786
|
* 1. PRIMARY — exact token match (`phaseTokenMatches`), unchanged behavior.
|
|
1304
1787
|
* 2. BARE-INTEGER FALLBACK — only when the primary pass matched NOTHING and
|
|
1305
1788
|
* the query is a bare integer, re-filter by each directory's own LEADING
|
|
@@ -1343,31 +1826,95 @@ const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
|
|
|
1343
1826
|
*
|
|
1344
1827
|
* TAKE `matches[0]` — `cmdPhasesList`, `cmdInitManager`, `cmdRoadmapAnalyze`,
|
|
1345
1828
|
* the schema-drift gate (`resolvePhaseDirByToken`), `detectVerifyFailed`. Each read a directory to
|
|
1346
|
-
* DECORATE a row they are already emitting
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1829
|
+
* DECORATE a row they are already emitting. In bracket mode those current-
|
|
1830
|
+
* checkout callers pass the active project+milestone context: an exact
|
|
1831
|
+
* qualified match wins, and bracket directories from other milestones are
|
|
1832
|
+
* excluded before any first-match policy can run. Legacy selection order is
|
|
1833
|
+
* unchanged.
|
|
1350
1834
|
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
* — but it is a widening, and promoting any of them to refusal is a UX decision
|
|
1356
|
-
* about their own output, not a change to selection, so it does not belong here.
|
|
1835
|
+
* Without an active bracket context the historical first-match/ambiguity policy
|
|
1836
|
+
* remains the caller's responsibility. With a context, only migration-window
|
|
1837
|
+
* legacy names can participate in fallback when the exact bracket identity is
|
|
1838
|
+
* absent; a prior milestone's qualified directory can never mask the active one.
|
|
1357
1839
|
*
|
|
1358
1840
|
* `usedBareFallback` tells callers to derive the displayed phase number from
|
|
1359
1841
|
* the directory's leading digit run instead of `extractPhaseToken` (whose
|
|
1360
1842
|
* token for these dirs is the mis-absorbed multi-segment form).
|
|
1361
1843
|
*/
|
|
1362
|
-
|
|
1363
|
-
|
|
1844
|
+
/**
|
|
1845
|
+
* The query a directory lookup hands to `matchPhaseDirs`. Custom projects pass
|
|
1846
|
+
* the id as written (their ids have no padding form); every other convention
|
|
1847
|
+
* keeps the normalized token callers always supplied.
|
|
1848
|
+
*/
|
|
1849
|
+
function phaseLookupQuery(query, context) {
|
|
1850
|
+
return context?.phaseNaming === 'custom' ? query : normalizePhaseName(query);
|
|
1851
|
+
}
|
|
1852
|
+
function matchPhaseDirs(dirs, query, convention, bracketContext, context = phaseDirectoryContext({})) {
|
|
1853
|
+
const custom = convention === 'bracket' ? null : customPhaseQuery(query, context);
|
|
1854
|
+
if (custom?.recordedId !== undefined) {
|
|
1855
|
+
return { matches: dirs.filter(dir => customPhaseDirectoryParts(dir, custom) !== null), usedBareFallback: false };
|
|
1856
|
+
}
|
|
1857
|
+
// A complete basename names that physical directory when no record owns the
|
|
1858
|
+
// query. Custom projects only: a numeric project with `01` and `01-foo` must
|
|
1859
|
+
// still reach the #2237 ambiguity refusal for the bare token `01`.
|
|
1860
|
+
if (context.phaseNaming === 'custom' && convention !== 'bracket') {
|
|
1861
|
+
const directoryMatches = dirs.filter(dir => dir === query);
|
|
1862
|
+
if (directoryMatches.length > 0)
|
|
1863
|
+
return { matches: directoryMatches, usedBareFallback: false };
|
|
1864
|
+
}
|
|
1865
|
+
// Bracket queries arrive canonical from the lookup boundary; every other
|
|
1866
|
+
// convention normalizes here (custom ids keep their literal spelling below).
|
|
1867
|
+
const normalized = convention === 'bracket' ? query : normalizePhaseName(query);
|
|
1868
|
+
// Recorded directories never enter legacy token or bare-number fallback for
|
|
1869
|
+
// another query. Keep those compatibility passes for unmarked directories.
|
|
1870
|
+
if (context.phaseNaming === 'custom' && convention !== 'bracket') {
|
|
1871
|
+
dirs = dirs.filter(dir => context.directoryIds?.[dir] === undefined
|
|
1872
|
+
|| (custom !== null && customPhaseDirectoryParts(dir, custom) !== null));
|
|
1873
|
+
}
|
|
1874
|
+
if (custom) {
|
|
1875
|
+
// A full directory name is an existing lookup form, distinct from an ID prefix.
|
|
1876
|
+
const exact = dirs.filter(d => d === custom.id);
|
|
1877
|
+
if (exact.length > 0)
|
|
1878
|
+
return { matches: exact, usedBareFallback: false };
|
|
1879
|
+
const matches = dirs.filter(d => customPhaseDirectoryParts(d, custom) !== null);
|
|
1880
|
+
if (matches.length > 0 || isCustomPhaseId(query))
|
|
1881
|
+
return { matches, usedBareFallback: false };
|
|
1882
|
+
}
|
|
1883
|
+
// Custom writers preserve an explicit ID's spelling, including an unpadded
|
|
1884
|
+
// letter suffix (7B). Prefer that literal token before the legacy padded form.
|
|
1885
|
+
if (context?.phaseNaming === 'custom' && convention !== 'bracket' && query !== normalized) {
|
|
1886
|
+
const literal = dirs.filter(d => phaseTokenMatches(d, query, convention));
|
|
1887
|
+
if (literal.length > 0)
|
|
1888
|
+
return { matches: literal, usedBareFallback: false };
|
|
1889
|
+
}
|
|
1890
|
+
let candidates = dirs;
|
|
1891
|
+
// #4304: a bare phase number from STATE/ROADMAP names the active bracket
|
|
1892
|
+
// identity, not every physical directory left behind by prior milestones.
|
|
1893
|
+
// Prefer the exact qualified directory. If it is absent, retain only
|
|
1894
|
+
// migration-window legacy names; a differently-qualified bracket directory
|
|
1895
|
+
// must never win merely because it sorts first.
|
|
1896
|
+
if (convention === 'bracket' && bracketContext && !bracketQualifiedKey(normalized, convention)) {
|
|
1897
|
+
const qualified = `${bracketContext.project}.${bracketContext.milestone}-${normalized}`;
|
|
1898
|
+
const exact = dirs.filter(d => phaseTokenMatches(d, qualified, convention));
|
|
1899
|
+
if (exact.length > 0)
|
|
1900
|
+
return { matches: exact, usedBareFallback: false };
|
|
1901
|
+
candidates = dirs.filter(d => bracketQualifiedKey(d, convention) === null);
|
|
1902
|
+
}
|
|
1903
|
+
const primary = candidates.filter(d => phaseTokenMatches(d, normalized, convention));
|
|
1364
1904
|
if (primary.length > 0)
|
|
1365
1905
|
return { matches: primary, usedBareFallback: false };
|
|
1906
|
+
// Normalization pads a literal continuation ID such as 0-00 to 00-00.
|
|
1907
|
+
// Preserve its exact-token lookup before falling back to a bare integer.
|
|
1908
|
+
if (query !== normalized && query.includes('-')) {
|
|
1909
|
+
const literal = dirs.filter(d => phaseTokenMatches(d, query, convention));
|
|
1910
|
+
if (literal.length > 0)
|
|
1911
|
+
return { matches: literal, usedBareFallback: false };
|
|
1912
|
+
}
|
|
1366
1913
|
const bare = String(normalized);
|
|
1367
1914
|
if (!BARE_INTEGER_RE.test(bare))
|
|
1368
1915
|
return { matches: primary, usedBareFallback: false };
|
|
1369
1916
|
const want = unpad(bare);
|
|
1370
|
-
const fallback =
|
|
1917
|
+
const fallback = candidates.filter(d => {
|
|
1371
1918
|
const m = stripProjectCodePrefix(d).match(LEADING_DIGIT_RUN_RE);
|
|
1372
1919
|
return m !== null && unpad(m[1]) === want;
|
|
1373
1920
|
});
|
|
@@ -1379,9 +1926,9 @@ function matchPhaseDirs(dirs, normalized, convention) {
|
|
|
1379
1926
|
* directory's leading digit run (the whole point of the fallback is that the
|
|
1380
1927
|
* extracted token is wrong for these dirs).
|
|
1381
1928
|
*/
|
|
1382
|
-
function phaseNumberForMatch(dirName, usedBareFallback) {
|
|
1929
|
+
function phaseNumberForMatch(dirName, usedBareFallback, convention) {
|
|
1383
1930
|
if (!usedBareFallback)
|
|
1384
|
-
return extractPhaseToken(dirName);
|
|
1931
|
+
return extractPhaseToken(dirName, convention);
|
|
1385
1932
|
const stripped = stripProjectCodePrefix(dirName);
|
|
1386
1933
|
const prefix = dirName.slice(0, dirName.length - stripped.length);
|
|
1387
1934
|
const m = stripped.match(LEADING_DIGIT_RUN_PREFIX_RE);
|
|
@@ -1410,7 +1957,9 @@ function phaseNumberForMatch(dirName, usedBareFallback) {
|
|
|
1410
1957
|
* of wide IDs (`001.10`) is relied on by plan-ID capture and wave assignment.
|
|
1411
1958
|
* Arithmetic is avoided (`parseInt` would lose precision on a long digit run).
|
|
1412
1959
|
*/
|
|
1413
|
-
function phaseKeyFromToken(token) {
|
|
1960
|
+
function phaseKeyFromToken(token, context) {
|
|
1961
|
+
if (context && customPhaseQuery(String(token), context))
|
|
1962
|
+
return String(token).toUpperCase();
|
|
1414
1963
|
const stripped = String(token)
|
|
1415
1964
|
.split('-')
|
|
1416
1965
|
.map(segment => segment.replace(/^0+(?=\d)/, ''))
|
|
@@ -1430,8 +1979,16 @@ function phaseKeyFromToken(token) {
|
|
|
1430
1979
|
* #2562's own defect class reached from the other side: both sides of a comparison
|
|
1431
1980
|
* must be derived not merely by the same function but under the same convention.
|
|
1432
1981
|
*/
|
|
1433
|
-
function phaseKeyFromDir(dirName, convention) {
|
|
1434
|
-
|
|
1982
|
+
function phaseKeyFromDir(dirName, convention, context) {
|
|
1983
|
+
if (context && convention !== 'bracket') {
|
|
1984
|
+
const custom = customPhaseQuery(dirName, context);
|
|
1985
|
+
if (custom) {
|
|
1986
|
+
const owner = customDirectoryOwner(dirName, custom);
|
|
1987
|
+
if (owner)
|
|
1988
|
+
return phaseKeyFromToken(owner.id, context);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
return phaseKeyFromToken(extractPhaseToken(dirName, convention), context);
|
|
1435
1992
|
}
|
|
1436
1993
|
/**
|
|
1437
1994
|
* Canonical key for a phase referenced in PROSE — a ROADMAP `## Progress` table
|
|
@@ -1439,12 +1996,18 @@ function phaseKeyFromDir(dirName, convention) {
|
|
|
1439
1996
|
* `Phase:` value. Markdown emphasis is stripped first so a bolded cell is not
|
|
1440
1997
|
* mistaken for a non-phase. Returns null when the value does not BEGIN with a
|
|
1441
1998
|
* phase token (`parsePhaseFromProse` anchoring, #2111).
|
|
1999
|
+
*
|
|
2000
|
+
* #5675: `convention` is the project's `phase_id_convention`. Under `milestone-prefixed` the id is
|
|
2001
|
+
* the whole `M-NN` (`3-01`), so the key equals `phaseKeyFromDir` for the same phase; every other
|
|
2002
|
+
* convention (and an absent one) keeps the leading-integer read. It is the trailing parameter so the
|
|
2003
|
+
* existing `(value, context)` callers stay byte-identical.
|
|
1442
2004
|
*/
|
|
1443
|
-
function phaseKeyFromProse(value) {
|
|
2005
|
+
function phaseKeyFromProse(value, context, convention) {
|
|
1444
2006
|
if (value == null)
|
|
1445
2007
|
return null;
|
|
1446
|
-
const
|
|
1447
|
-
|
|
2008
|
+
const clean = String(value).replace(/[*_`~]/g, '');
|
|
2009
|
+
const { phase } = parsePhaseFromProse(clean, convention, context);
|
|
2010
|
+
return phase === null ? null : phaseKeyFromToken(phase, context);
|
|
1448
2011
|
}
|
|
1449
2012
|
/**
|
|
1450
2013
|
* The PARENT phase key of a sub-phase key (`"30.1"` → `"30"`), or null for a
|
|
@@ -1483,13 +2046,20 @@ function isPlannedPhaseStatusPhrase(value) {
|
|
|
1483
2046
|
// (`3-01`), so the number capture takes one optional `-NN` segment; every other convention (and an absent
|
|
1484
2047
|
// one) keeps the dotted-only capture, where `3-01` reads as phase `3` exactly as before.
|
|
1485
2048
|
const PHASE_PROSE_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i;
|
|
1486
|
-
const PHASE_PROSE_MILESTONE_PREFIXED_RE =
|
|
1487
|
-
function parsePhaseFromProse(value, convention) {
|
|
2049
|
+
const PHASE_PROSE_MILESTONE_PREFIXED_RE = new RegExp(`^\\s*(?:Phase\\s+)?(?:[A-Z][A-Z0-9_]*-)?(${PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED})\\b`, 'i');
|
|
2050
|
+
function parsePhaseFromProse(value, convention, context) {
|
|
1488
2051
|
if (!value)
|
|
1489
2052
|
return { phase: null, name: null };
|
|
1490
2053
|
// Coerce defensively so a non-string caller cannot throw on this canonical
|
|
1491
2054
|
// surface (mirrors the sibling #2121 functions' String(...) handling).
|
|
1492
2055
|
const str = String(value);
|
|
2056
|
+
const candidate = context?.phaseNaming === 'custom'
|
|
2057
|
+
? str.trim().replace(/^Phase[ \t]+/i, '').split(/[ \t:]/, 1)[0].replace(/\.$/, '')
|
|
2058
|
+
: null;
|
|
2059
|
+
const query = candidate && context && customPhaseQuery(candidate, context);
|
|
2060
|
+
// Only an identity a writer recorded or the roadmap lists is a phase id; an
|
|
2061
|
+
// ordinary word in the field (`complete`, `None`, `TBD`) is not one.
|
|
2062
|
+
const custom = query && (query.listed || query.recordedId !== undefined);
|
|
1493
2063
|
const phaseMatch = str.match(convention === 'milestone-prefixed' ? PHASE_PROSE_MILESTONE_PREFIXED_RE : PHASE_PROSE_RE);
|
|
1494
2064
|
// The name-extraction quantifiers are length-bounded so a crafted long
|
|
1495
2065
|
// unterminated run (many `(` or `—`) in an untrusted STATE.md field value
|
|
@@ -1533,7 +2103,7 @@ function parsePhaseFromProse(value, convention) {
|
|
|
1533
2103
|
? rawName.trim()
|
|
1534
2104
|
: null;
|
|
1535
2105
|
return {
|
|
1536
|
-
phase: phaseMatch ? phaseMatch[1] : null,
|
|
2106
|
+
phase: custom ? candidate : phaseMatch ? phaseMatch[1] : null,
|
|
1537
2107
|
name,
|
|
1538
2108
|
};
|
|
1539
2109
|
}
|
|
@@ -1647,11 +2217,15 @@ function roadmapPhaseLookupSources(phaseNum) {
|
|
|
1647
2217
|
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
|
|
1648
2218
|
return [...new Set(sources)];
|
|
1649
2219
|
}
|
|
1650
|
-
|
|
2220
|
+
const phaseIdExports = {
|
|
1651
2221
|
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
|
1652
2222
|
OPTIONAL_PHASE_TAG_SOURCE,
|
|
1653
2223
|
PHASE_NUMBER_TOKEN_SOURCE,
|
|
2224
|
+
PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED,
|
|
2225
|
+
phaseNumberTokenSource,
|
|
1654
2226
|
PHASE_DEP_REF_SOURCE,
|
|
2227
|
+
tokenizePhaseDependencyReferences,
|
|
2228
|
+
extractPhaseDependencyTokens,
|
|
1655
2229
|
CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE,
|
|
1656
2230
|
CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE,
|
|
1657
2231
|
PHASE_CONTINUATION_SEGMENT_SOURCE,
|
|
@@ -1668,7 +2242,10 @@ module.exports = {
|
|
|
1668
2242
|
BASE_PHASE_LABEL_PREFIX_SRC,
|
|
1669
2243
|
PHASE_HEADING_BASELINE,
|
|
1670
2244
|
phaseHeadingPrefixSrcFor,
|
|
2245
|
+
parsePhaseChecklistLine,
|
|
2246
|
+
bracketQualifiedIntroSrcFor,
|
|
1671
2247
|
buildPhaseHeadingScanRegex,
|
|
2248
|
+
buildPhaseDeclarationScans,
|
|
1672
2249
|
foldBracketId,
|
|
1673
2250
|
bracketQualifiedKey,
|
|
1674
2251
|
stripProjectCodePrefix,
|
|
@@ -1692,6 +2269,19 @@ module.exports = {
|
|
|
1692
2269
|
scopeToPhase,
|
|
1693
2270
|
phaseTokenMatches,
|
|
1694
2271
|
matchPhaseDirs,
|
|
2272
|
+
phaseLookupQuery,
|
|
2273
|
+
phaseDirectoryContext,
|
|
2274
|
+
phaseScanTokenSource,
|
|
2275
|
+
phaseDirectoryName,
|
|
2276
|
+
validateCustomPhaseId,
|
|
2277
|
+
validateCustomPhaseIds,
|
|
2278
|
+
PhaseDirectoryNameError,
|
|
2279
|
+
customPhaseQuery,
|
|
2280
|
+
customPhaseDirectoryParts,
|
|
2281
|
+
customDirectoryOwner,
|
|
2282
|
+
customDirectoryCandidates,
|
|
2283
|
+
CUSTOM_PHASE_ID_FILE,
|
|
2284
|
+
isCustomPhaseId,
|
|
1695
2285
|
phaseNumberForMatch,
|
|
1696
2286
|
phaseKeyFromToken,
|
|
1697
2287
|
phaseKeyFromDir,
|
|
@@ -1704,3 +2294,4 @@ module.exports = {
|
|
|
1704
2294
|
roadmapPhaseLookupSources,
|
|
1705
2295
|
renderPhaseBranchName,
|
|
1706
2296
|
};
|
|
2297
|
+
module.exports = phaseIdExports;
|