@opengsd/gsd-core 1.19.0 → 1.20.0
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/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +73 -23
- 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 +27 -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/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 +180 -67
- 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 +609 -35
- 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 +2990 -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 +730 -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/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 +39 -10
- package/gsd-core/bin/lib/state.cjs +498 -179
- 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 +26 -20
- 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-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/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/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-update/SKILL.md +4 -1
- 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 +6 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/sync-next-version.cjs +38 -6
- 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,7 @@ 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
|
-
// #4764: a phase REFERENCE in depends-on PROSE — the token in context, directly
|
|
74
|
+
// #4764: a legacy phase REFERENCE in depends-on PROSE — the token in context, directly
|
|
63
75
|
// following "Phase"/"Phases", with bare-token list continuation ("Phases 1 and 2",
|
|
64
76
|
// "Phase 1, 2, and 3", "Phase 1-3"). Built HERE, beside the token grammar it is
|
|
65
77
|
// anchored on, because two readers consume Depends-on prose (init.manager's
|
|
@@ -72,7 +84,8 @@ const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
|
|
72
84
|
// dependencies. The `-` separator deliberately extracts range ENDPOINTS only
|
|
73
85
|
// ("Phase 1-3" → 1, 3) — the pre-#4764 behavior; interior enumeration stays
|
|
74
86
|
// out (a range's middle is not written as a reference).
|
|
75
|
-
const
|
|
87
|
+
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})*`;
|
|
88
|
+
const PHASE_DEP_REF_SOURCE = `\\bphases?\\s+(${PHASE_DEP_TOKEN_LIST_SOURCE})`;
|
|
76
89
|
// #2528 review: the CASE-FLEXIBLE renderings of the two sources above, for call
|
|
77
90
|
// sites that scan directory names (where a project code or a variant suffix may
|
|
78
91
|
// legitimately be lowercase) and therefore cannot use a case-sensitive class.
|
|
@@ -303,6 +316,43 @@ const PHASE_HEADING_BASELINE = Object.freeze({
|
|
|
303
316
|
*
|
|
304
317
|
* Pure: takes the resolved convention, never reads config.
|
|
305
318
|
*/
|
|
319
|
+
// #4304: `bracketAlt`'s own alternation, factored out to a named
|
|
320
|
+
// function so every write-side consumer that needs "this bracket, optionally
|
|
321
|
+
// followed by the literal Phase label, then a digit" derives from the SAME
|
|
322
|
+
// expression the read grammar compiles — never a hand-retyped `[ \t]+` /
|
|
323
|
+
// case-sensitive copy. `idSrc` is the regex SOURCE for the bracket's
|
|
324
|
+
// `{CODE}.{MM}` interior: the generic `BRACKET_ID_SRC` class (capturing or
|
|
325
|
+
// not) for a classifier that must recognize ANY bracket identity, or one
|
|
326
|
+
// escaped, already-known project/milestone pair (via
|
|
327
|
+
// `bracketQualifiedIntroSrcFor` below) for a rewriter/detector that already
|
|
328
|
+
// knows exactly which identity it means. Returns only the regex SOURCE —
|
|
329
|
+
// every caller compiles it themselves (every reader uses the `i` flag), so
|
|
330
|
+
// this composes with `g`, an outer capturing group, or a trailing literal
|
|
331
|
+
// number.
|
|
332
|
+
function bracketAltIntroSrcFor(idSrc) {
|
|
333
|
+
// `[ \t]*` not `\s*`: `\s` spans newlines, so a bracket-terminated heading
|
|
334
|
+
// followed by a blank line and a digit-leading prose line read as one phase.
|
|
335
|
+
return `\\[${idSrc}\\][ \\t]*(?:Phase\\s+|(?=\\d))`;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* #4304: the qualified-mention intro for ONE already-known
|
|
339
|
+
* bracket identity — `[{PROJECT}.{MM}]` (both escaped literals, not a
|
|
340
|
+
* class), optionally followed by the `Phase` label — built from
|
|
341
|
+
* `bracketAltIntroSrcFor` so a rewriter or detector anchoring a specific
|
|
342
|
+
* phase-number token right after it accepts exactly the spellings
|
|
343
|
+
* `phaseHeadingPrefixSrcFor`'s bracket alternative does: any case (compile
|
|
344
|
+
* with `i`), zero-or-more spaces, an optional `Phase` label. phase.cts's
|
|
345
|
+
* write-side bracket-identity regexes (the qualified-reference replacer, the
|
|
346
|
+
* qualified-mention detector) build their intro through this instead of
|
|
347
|
+
* re-typing `[ \t]+` / case-sensitive brackets independently of the read
|
|
348
|
+
* grammar. A hand-composed case-sensitive, `[ \t]+`-spaced copy would reject
|
|
349
|
+
* `[ck.02] 02:`, `[CK.02] PHASE 02:`, and the no-space `[CK.02]02:`, all of
|
|
350
|
+
* which `roadmap get-phase`, `roadmap analyze`, `phase insert` and
|
|
351
|
+
* `phase add` accept as real phases.
|
|
352
|
+
*/
|
|
353
|
+
function bracketQualifiedIntroSrcFor(project, milestone) {
|
|
354
|
+
return bracketAltIntroSrcFor(`${(0, pattern_cjs_1.escapeRegex)(String(project))}\\.${(0, pattern_cjs_1.escapeRegex)(String(milestone))}`);
|
|
355
|
+
}
|
|
306
356
|
function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
|
|
307
357
|
const base = baseline === PHASE_HEADING_BASELINE.ANY_BRACKET
|
|
308
358
|
? BASE_ANY_BRACKET_HEADING_PREFIX_SRC
|
|
@@ -310,8 +360,6 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
|
|
|
310
360
|
if (convention !== 'bracket')
|
|
311
361
|
return base;
|
|
312
362
|
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
363
|
// BOTH bracket forms are admitted at both baselines, and both CAPTURE. The
|
|
316
364
|
// any-bracket base already matches `[GSD.999] Phase 07:` on its own — but
|
|
317
365
|
// through the base alternative, which captures nothing, so the reader saw
|
|
@@ -319,9 +367,31 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
|
|
|
319
367
|
// counted a labeled icebox heading as a real phase while the label-less form
|
|
320
368
|
// beside it was excluded. Two derivations of one ROADMAP disagreed. The
|
|
321
369
|
// bracket alternative is tried FIRST so it wins the capture.
|
|
322
|
-
const bracketAlt =
|
|
370
|
+
const bracketAlt = bracketAltIntroSrcFor(id);
|
|
323
371
|
return `(?:${bracketAlt}|${base})`;
|
|
324
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* Parse one roadmap phase-checkbox row through the same anchored grammar used
|
|
375
|
+
* by readers and writers (#4982). The identity must be the first thing after
|
|
376
|
+
* the checkbox, tolerating only an optional `**`. Bracket mode captures the
|
|
377
|
+
* leading `[CODE.MM] PP` identity. Both paths treat whitespace or a colon
|
|
378
|
+
* immediately after the phase token as the boundary. Returning semantic fields
|
|
379
|
+
* keeps consumers from depending on capture-group offsets.
|
|
380
|
+
*/
|
|
381
|
+
function parsePhaseChecklistLine(line, convention, context) {
|
|
382
|
+
const capturesBracketId = convention === 'bracket';
|
|
383
|
+
const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturesBracketId);
|
|
384
|
+
const pattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${intro}(${context ? phaseScanTokenSource(context) : PHASE_NUMBER_TOKEN_SOURCE})`
|
|
385
|
+
+ `${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`, 'i', { state: 'captured' });
|
|
386
|
+
const match = pattern.exec(line);
|
|
387
|
+
if (!match)
|
|
388
|
+
return null;
|
|
389
|
+
return {
|
|
390
|
+
checked: (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(match[1]),
|
|
391
|
+
bracketId: capturesBracketId ? match[2] : undefined,
|
|
392
|
+
phaseToken: capturesBracketId ? match[3] : match[2],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
325
395
|
/**
|
|
326
396
|
* The single owner of the "scan a whole document for every phase heading"
|
|
327
397
|
* pattern (#4865 / ADR-4910 §8) — anchored (`^ {0,3}#{2,4}`, so indentation up
|
|
@@ -339,13 +409,56 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
|
|
|
339
409
|
* This function answers "which phases exist in this content", not "does this
|
|
340
410
|
* specific phase exist".
|
|
341
411
|
*/
|
|
342
|
-
function buildPhaseHeadingScanRegex(baseline, convention) {
|
|
412
|
+
function buildPhaseHeadingScanRegex(baseline, convention, context) {
|
|
343
413
|
const bracketGroup = convention === 'bracket' ? 1 : null;
|
|
344
414
|
const phaseNumGroup = bracketGroup ? 2 : 1;
|
|
345
415
|
const phaseNameGroup = phaseNumGroup + 1;
|
|
346
|
-
const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
|
|
416
|
+
const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${context && convention !== 'bracket' ? phaseScanTokenSource(context) : PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
|
|
347
417
|
return { regex, bracketGroup, phaseNumGroup, phaseNameGroup };
|
|
348
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Every scan that answers "which phase numbers does this roadmap text declare" (#5528): section
|
|
421
|
+
* headings, plus checklist bullets when `withBullets`. A non-bracket convention gets the plain
|
|
422
|
+
* `Phase N` heading scan and the checkbox-led `Phase N` bullet it always had.
|
|
423
|
+
*
|
|
424
|
+
* Under the bracket convention it also gets, beyond `buildPhaseHeadingScanRegex`'s `[ID] NN` and
|
|
425
|
+
* `[ID] Phase NN` headings: the word-first heading `Phase [ID] NN`; and a bullet whose bracket IS
|
|
426
|
+
* the id (`- [GSD.02] 03: X`, no checkbox). The checkbox of the other bullet shape may not itself
|
|
427
|
+
* be a bracket id, or `- [GSD.999] Phase 07` would read `[GSD.999]` as a checkbox and drop the id
|
|
428
|
+
* that marks it an icebox row. A bullet is one line, so its label uses `[ \t]` where a heading's
|
|
429
|
+
* uses `\s`.
|
|
430
|
+
*/
|
|
431
|
+
function buildPhaseDeclarationScans(convention, withBullets = true) {
|
|
432
|
+
const heading = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
|
|
433
|
+
const scans = [{ regex: heading.regex, idGroup: heading.bracketGroup, numGroup: heading.phaseNumGroup }];
|
|
434
|
+
const bracket = convention === 'bracket';
|
|
435
|
+
// Under bracket a numbered-list item (`1. [ ] **[GSD.02] 05: n**`) is a checklist row too.
|
|
436
|
+
const bullet = bracket ? '^[ \\t]*(?:-|\\d+[.)])[ \\t]*' : '^[ \\t]*-[ \\t]*';
|
|
437
|
+
// Bracket ids may carry a letter suffix (`12A`), as the heading scan's phase token does.
|
|
438
|
+
const tail = bracket ? '(\\d+)(?=[A-Za-z]?(?:[:.\\s*]|$))' : '(\\d+)(?=[:.\\s*]|$)';
|
|
439
|
+
const checkbox = `\\[${bracket ? `(?!${BRACKET_ID_SRC}\\])` : ''}[^\\]]{0,200}\\][ \\t]*`;
|
|
440
|
+
if (!bracket) {
|
|
441
|
+
if (withBullets)
|
|
442
|
+
scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}Phase[ \\t]+${tail}`, 'gim'), idGroup: null, numGroup: 1 });
|
|
443
|
+
return scans;
|
|
444
|
+
}
|
|
445
|
+
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 });
|
|
446
|
+
if (withBullets) {
|
|
447
|
+
const label = `(?:\\[(${BRACKET_ID_SRC})\\][ \\t]*(?:Phase[ \\t]+|(?=\\d))|Phase[ \\t]+)`;
|
|
448
|
+
scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}${label}${tail}`, 'gim'), idGroup: 1, numGroup: 2 });
|
|
449
|
+
// The word-first heading's bullet twin: `- [ ] Phase [GSD.02] 07:`.
|
|
450
|
+
scans.push({ regex: new RegExp(`${bullet}${checkbox}\\*{0,2}Phase[ \\t]+\\[(${BRACKET_ID_SRC})\\][ \\t]*${tail}`, 'gim'), idGroup: 1, numGroup: 2 });
|
|
451
|
+
// The checkbox forms above deliberately over-count a colon-less `- [ ] [GSD.02] 2026 notes` as phase 2026: a
|
|
452
|
+
// skipped number is harmless, a reused one is not, so do not "fix" them with a colon requirement.
|
|
453
|
+
// With no checkbox to vouch for it the number needs a `:`, a bold close or end of line, or a dash
|
|
454
|
+
// separator (`- [GSD.02] 05 - Name`), or `- [GSD.02] 2026 notes` is phase 2026.
|
|
455
|
+
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 });
|
|
456
|
+
}
|
|
457
|
+
// A dash-separated heading (`### [GSD.02] 05 - Name`) is outside the colon grammar the readers share, but still a declaration.
|
|
458
|
+
// `###`/`####` only: a `##` milestone heading (`## [CK.02] 2 - Foundation`) names the milestone, not a phase.
|
|
459
|
+
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 });
|
|
460
|
+
return scans;
|
|
461
|
+
}
|
|
349
462
|
function stripProjectCodePrefix(value, caseInsensitive = true) {
|
|
350
463
|
const input = String(value);
|
|
351
464
|
const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
|
|
@@ -489,6 +602,116 @@ function parsePhaseId(input) {
|
|
|
489
602
|
// tokens unchanged.
|
|
490
603
|
throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
|
|
491
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* Tokenize dependency references with source spans. Bracket-qualified lists
|
|
607
|
+
* canonicalize every numeric segment before strict identity parsing. If the
|
|
608
|
+
* qualified identity still cannot be parsed, the reader keeps the original
|
|
609
|
+
* token as a legacy dependency instead of silently dropping it.
|
|
610
|
+
*/
|
|
611
|
+
function tokenizePhaseDependencyReferences(prose, convention) {
|
|
612
|
+
const input = prose;
|
|
613
|
+
const found = [];
|
|
614
|
+
const qualifiedTokenSpans = [];
|
|
615
|
+
const legacyRefRe = new RegExp(`${PHASE_DEP_REF_SOURCE}`, 'gi');
|
|
616
|
+
const tokenRe = new RegExp(PHASE_NUMBER_TOKEN_SOURCE, convention === 'bracket' ? 'gi' : 'g');
|
|
617
|
+
let refMatch;
|
|
618
|
+
while ((refMatch = legacyRefRe.exec(input)) !== null) {
|
|
619
|
+
const referenceStart = refMatch.index + refMatch[0].length - refMatch[1].length;
|
|
620
|
+
tokenRe.lastIndex = 0;
|
|
621
|
+
let tokenMatch;
|
|
622
|
+
while ((tokenMatch = tokenRe.exec(refMatch[1])) !== null) {
|
|
623
|
+
const start = referenceStart + tokenMatch.index;
|
|
624
|
+
found.push({ kind: 'legacy', start, end: start + tokenMatch[0].length, token: tokenMatch[0] });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (convention !== 'bracket') {
|
|
628
|
+
return found;
|
|
629
|
+
}
|
|
630
|
+
const bracketDisplayRe = new RegExp(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, 'bracket', true)}(${PHASE_DEP_TOKEN_LIST_SOURCE})`, 'gi');
|
|
631
|
+
let displayMatch;
|
|
632
|
+
while ((displayMatch = bracketDisplayRe.exec(input)) !== null) {
|
|
633
|
+
if (!displayMatch[1])
|
|
634
|
+
continue;
|
|
635
|
+
const tokenList = displayMatch[2];
|
|
636
|
+
const tokenListStart = displayMatch.index + displayMatch[0].length - tokenList.length;
|
|
637
|
+
tokenRe.lastIndex = 0;
|
|
638
|
+
let tokenMatch;
|
|
639
|
+
while ((tokenMatch = tokenRe.exec(tokenList)) !== null) {
|
|
640
|
+
const start = tokenListStart + tokenMatch.index;
|
|
641
|
+
const end = start + tokenMatch[0].length;
|
|
642
|
+
qualifiedTokenSpans.push({ start, end });
|
|
643
|
+
const numeric = tokenMatch[0].split('.').every((part) => /^\d+$/.test(part))
|
|
644
|
+
? tokenMatch[0].split('.').map(pad2).join('.')
|
|
645
|
+
: null;
|
|
646
|
+
try {
|
|
647
|
+
if (numeric === null)
|
|
648
|
+
throw new Error('not a numeric bracket phase token');
|
|
649
|
+
const id = parsePhaseId(`[${foldBracketId(displayMatch[1])}] ${numeric}`);
|
|
650
|
+
found.push({
|
|
651
|
+
kind: 'qualified',
|
|
652
|
+
referenceStart: displayMatch.index,
|
|
653
|
+
start,
|
|
654
|
+
end,
|
|
655
|
+
token: renderPhaseId(id),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
found.push({
|
|
660
|
+
kind: 'qualified-fallback',
|
|
661
|
+
referenceStart: displayMatch.index,
|
|
662
|
+
start,
|
|
663
|
+
end,
|
|
664
|
+
token: tokenMatch[0],
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const chunks = input.matchAll(/\S+/g);
|
|
670
|
+
for (const chunk of chunks) {
|
|
671
|
+
const candidate = chunk[0]
|
|
672
|
+
.replace(/^[`*_([{]+/, '')
|
|
673
|
+
.replace(/[`*_\])}.:,;]+$/, '');
|
|
674
|
+
try {
|
|
675
|
+
const id = parsePhaseId(candidate);
|
|
676
|
+
const sub = id.subphase ? `.${id.subphase}` : '';
|
|
677
|
+
const canonicalDash = `${id.project}.${id.milestone}-${id.phase}${sub}`;
|
|
678
|
+
if (id.plan || candidate !== canonicalDash)
|
|
679
|
+
continue;
|
|
680
|
+
found.push({
|
|
681
|
+
kind: 'identity',
|
|
682
|
+
start: chunk.index ?? 0,
|
|
683
|
+
end: (chunk.index ?? 0) + candidate.length,
|
|
684
|
+
token: renderPhaseId(id),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
// Most prose chunks are not identities. parsePhaseId is the classifier.
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const trimmed = input.trim();
|
|
692
|
+
if (new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i').test(trimmed)) {
|
|
693
|
+
const start = input.indexOf(trimmed);
|
|
694
|
+
found.push({ kind: 'bare', start, end: start + trimmed.length, token: trimmed });
|
|
695
|
+
}
|
|
696
|
+
const identityAware = found.filter(({ kind, start, end }) => {
|
|
697
|
+
if (kind !== 'legacy')
|
|
698
|
+
return true;
|
|
699
|
+
return !qualifiedTokenSpans.some((span) => start === span.start && end === span.end);
|
|
700
|
+
});
|
|
701
|
+
identityAware.sort((a, b) => a.start - b.start);
|
|
702
|
+
return identityAware;
|
|
703
|
+
}
|
|
704
|
+
function extractPhaseDependencyTokens(prose, convention) {
|
|
705
|
+
const found = tokenizePhaseDependencyReferences(prose, convention);
|
|
706
|
+
const seen = new Set();
|
|
707
|
+
return found.flatMap(({ token }) => {
|
|
708
|
+
const key = token.toUpperCase();
|
|
709
|
+
if (seen.has(key))
|
|
710
|
+
return [];
|
|
711
|
+
seen.add(key);
|
|
712
|
+
return [token];
|
|
713
|
+
});
|
|
714
|
+
}
|
|
492
715
|
function renderMilestoneId(id) {
|
|
493
716
|
return `[${id.project}.${id.milestone}]`;
|
|
494
717
|
}
|
|
@@ -660,7 +883,10 @@ function isSentinelPhaseDir(dirName) {
|
|
|
660
883
|
* Render a regex source fragment matching a phase number against ROADMAP/STATE
|
|
661
884
|
* prose regardless of zero-padding on either side.
|
|
662
885
|
*/
|
|
663
|
-
function phaseMarkdownRegexSource(phaseNum) {
|
|
886
|
+
function phaseMarkdownRegexSource(phaseNum, context) {
|
|
887
|
+
const custom = context && customPhaseQuery(String(phaseNum), context);
|
|
888
|
+
if (custom)
|
|
889
|
+
return (0, pattern_cjs_1.escapeRegex)(custom.id);
|
|
664
890
|
const stripped = stripProjectCodePrefix(phaseNum);
|
|
665
891
|
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
|
|
666
892
|
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
|
|
@@ -698,7 +924,33 @@ function phaseMarkdownRegexSourceExact(phaseNum) {
|
|
|
698
924
|
return null;
|
|
699
925
|
return (0, pattern_cjs_1.escapeRegex)(raw);
|
|
700
926
|
}
|
|
701
|
-
function comparePhaseNum(a, b) {
|
|
927
|
+
function comparePhaseNum(a, b, context) {
|
|
928
|
+
if (context?.phaseNaming === 'custom') {
|
|
929
|
+
const index = customIdentityIndex(context);
|
|
930
|
+
const identity = (value) => {
|
|
931
|
+
const text = String(value);
|
|
932
|
+
if (index.recorded.has(text))
|
|
933
|
+
return text;
|
|
934
|
+
const query = customPhaseQuery(text, context);
|
|
935
|
+
return query?.recordedId ?? (query ? (query.listed ? query.id : customDirectoryOwner(text, query)?.id ?? query.id) : text);
|
|
936
|
+
};
|
|
937
|
+
const first = identity(a), second = identity(b);
|
|
938
|
+
// Arbitrary custom IDs have no numeric ordering: use their declared order.
|
|
939
|
+
// The relation must stay a total preorder for every `.sort` it feeds, so
|
|
940
|
+
// once a project declares a custom ID every declared ID sorts by position
|
|
941
|
+
// and ahead of every undeclared one; undeclared IDs (including unlisted
|
|
942
|
+
// numeric directories) keep the original comparator among themselves.
|
|
943
|
+
// A project with only legacy numeric IDs never enters this branch.
|
|
944
|
+
if (index.hasCustom) {
|
|
945
|
+
const left = index.orderByFold.get(recordedPhaseKey(first)), right = index.orderByFold.get(recordedPhaseKey(second));
|
|
946
|
+
if (left !== undefined && right !== undefined)
|
|
947
|
+
return left - right;
|
|
948
|
+
if (left !== undefined)
|
|
949
|
+
return -1;
|
|
950
|
+
if (right !== undefined)
|
|
951
|
+
return 1;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
702
954
|
// Strip optional project_code prefix before comparing
|
|
703
955
|
const sa = stripProjectCodePrefix(a);
|
|
704
956
|
const sb = stripProjectCodePrefix(b);
|
|
@@ -1292,6 +1544,223 @@ const LEADING_DIGIT_RUN_PREFIX_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}`);
|
|
|
1292
1544
|
const BARE_INTEGER_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}$`);
|
|
1293
1545
|
/** Strip leading zeros for numeric-equality compare, keeping a lone "0". */
|
|
1294
1546
|
const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
|
|
1547
|
+
/** #5186: the directory shape shared by phase.add and custom-ID readers. */
|
|
1548
|
+
const UNSAFE_PHASE_DIRECTORY_PART_RE = /[/\\\0]|\.\./;
|
|
1549
|
+
class PhaseDirectoryNameError extends Error {
|
|
1550
|
+
kind;
|
|
1551
|
+
constructor(message, kind = 'invalid') {
|
|
1552
|
+
super(message);
|
|
1553
|
+
this.kind = kind;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
function phaseDirectoryName(id, slug, projectCode) {
|
|
1557
|
+
if (!id || [id, slug, projectCode ?? ''].some(part => UNSAFE_PHASE_DIRECTORY_PART_RE.test(part))) {
|
|
1558
|
+
throw new PhaseDirectoryNameError('Invalid phase id or directory component: paths, .. and NUL are not allowed');
|
|
1559
|
+
}
|
|
1560
|
+
return `${projectCode ? `${projectCode}-` : ''}${id}-${slug}`;
|
|
1561
|
+
}
|
|
1562
|
+
/** Grammar required only when writing an arbitrary custom identity. */
|
|
1563
|
+
function validateCustomPhaseId(id) {
|
|
1564
|
+
if (!id || id !== id.trim() || /[\s:]/.test(id) || id.startsWith('[')) {
|
|
1565
|
+
throw new PhaseDirectoryNameError('Invalid custom phase id: empty IDs, whitespace, colons and a leading [ are not allowed');
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
/** Keep numeric, letter-suffixed and bracket queries on their legacy path. */
|
|
1569
|
+
const LEGACY_PHASE_ID_RE = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}(?:-\\d+)*$`, 'i');
|
|
1570
|
+
function isCustomPhaseId(id) {
|
|
1571
|
+
return !LEGACY_PHASE_ID_RE.test(id)
|
|
1572
|
+
&& !id.startsWith('[');
|
|
1573
|
+
}
|
|
1574
|
+
/** Preserve every legacy segment while applying its normal padding rules. */
|
|
1575
|
+
function recordedPhaseKey(id) {
|
|
1576
|
+
// Whole-token normalization can discard a letter-suffixed continuation
|
|
1577
|
+
// (2A-2). Normalize its segments independently so it never aliases 2A.
|
|
1578
|
+
return (LEGACY_PHASE_ID_RE.test(id) ? id.split('-').map(normalizePhaseName).join('-') : id).toLowerCase();
|
|
1579
|
+
}
|
|
1580
|
+
const CUSTOM_PHASE_ID_FILE = '.phase-id';
|
|
1581
|
+
/** Naming policy is supplied by the config owner; grammar remains pure. */
|
|
1582
|
+
function phaseDirectoryContext(config, roadmap = '', knownIds = []) {
|
|
1583
|
+
return { phaseNaming: typeof config.phase_naming === 'string' ? config.phase_naming.toLowerCase() : config.phase_naming, projectCode: config.project_code ?? null, roadmap, knownIds };
|
|
1584
|
+
}
|
|
1585
|
+
/** Custom scans reuse the numeric source verbatim outside configured custom projects. */
|
|
1586
|
+
function phaseScanTokenSource(context) {
|
|
1587
|
+
return context?.phaseNaming === 'custom' ? '[^ \t:\n]+' : PHASE_NUMBER_TOKEN_SOURCE;
|
|
1588
|
+
}
|
|
1589
|
+
const customIdentityIndexes = new WeakMap();
|
|
1590
|
+
function customIdentityIndex(options) {
|
|
1591
|
+
let index = customIdentityIndexes.get(options);
|
|
1592
|
+
if (!index || index.roadmap !== options.roadmap || index.knownIds !== options.knownIds) {
|
|
1593
|
+
const headingId = /^Phase[ \t]+([^ \t:\n]+)(?:[ \t]+\([^)\n]*\))?[ \t]*:/;
|
|
1594
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(options.roadmap ?? '')
|
|
1595
|
+
.filter(heading => heading.level >= 2 && heading.level <= 4)
|
|
1596
|
+
.flatMap(heading => heading.text.match(headingId)?.[1] ?? []);
|
|
1597
|
+
const recorded = new Set(Object.values(options.directoryIds ?? {}));
|
|
1598
|
+
const recordedByFold = new Map();
|
|
1599
|
+
for (const id of recorded) {
|
|
1600
|
+
const key = recordedPhaseKey(id);
|
|
1601
|
+
const aliases = recordedByFold.get(key);
|
|
1602
|
+
if (aliases)
|
|
1603
|
+
aliases.push(id);
|
|
1604
|
+
else
|
|
1605
|
+
recordedByFold.set(key, [id]);
|
|
1606
|
+
}
|
|
1607
|
+
const ids = new Set([...headings, ...options.knownIds ?? [], ...recorded]);
|
|
1608
|
+
index = {
|
|
1609
|
+
roadmap: options.roadmap, knownIds: options.knownIds, explicitIds: new Set(options.knownIds ?? []), ids, recorded, recordedByFold, order: new Map([...ids].map((id, i) => [id, i])),
|
|
1610
|
+
// Declared position under the same fold the writers use, so `02` and `2` share a slot.
|
|
1611
|
+
orderByFold: [...ids].reduce((fold, id, i) => fold.has(recordedPhaseKey(id)) ? fold : fold.set(recordedPhaseKey(id), i), new Map()),
|
|
1612
|
+
hasCustom: recorded.size > 0 || [...ids].some(isCustomPhaseId),
|
|
1613
|
+
prefixes: [...ids].filter(token => token && !UNSAFE_PHASE_DIRECTORY_PART_RE.test(token))
|
|
1614
|
+
.sort((a, b) => b.length - a.length)
|
|
1615
|
+
.map(token => ({ id: token, prefix: phaseDirectoryName(token, '') })),
|
|
1616
|
+
};
|
|
1617
|
+
customIdentityIndexes.set(options, index);
|
|
1618
|
+
}
|
|
1619
|
+
return index;
|
|
1620
|
+
}
|
|
1621
|
+
/** Preflight every custom writer, including all IDs in a batch, before writes. */
|
|
1622
|
+
function validateCustomPhaseIds(ids, context) {
|
|
1623
|
+
const index = customIdentityIndex(context);
|
|
1624
|
+
const known = new Set(index.ids);
|
|
1625
|
+
const prospective = { ...context, knownIds: [...context.knownIds ?? [], ...ids] };
|
|
1626
|
+
for (const id of ids) {
|
|
1627
|
+
validateCustomPhaseId(id);
|
|
1628
|
+
// The same sentinel grammar filters phases list and roadmap.analyze.
|
|
1629
|
+
// Refuse identities those readers would hide rather than creating orphans.
|
|
1630
|
+
if (isSentinelPhaseId(id)) {
|
|
1631
|
+
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');
|
|
1632
|
+
}
|
|
1633
|
+
if (context.projectCode && id.toLowerCase().startsWith(`${context.projectCode}-`.toLowerCase())) {
|
|
1634
|
+
throw new PhaseDirectoryNameError(`Custom ID ${id} already begins with project_code ${context.projectCode}; pass the ID without its project prefix`);
|
|
1635
|
+
}
|
|
1636
|
+
const collision = [...known].find(other => recordedPhaseKey(id) === recordedPhaseKey(other));
|
|
1637
|
+
if (collision) {
|
|
1638
|
+
throw new PhaseDirectoryNameError(`Custom ID ${id} collides with existing or pending ID ${collision}; choose a distinct ID`, 'collision');
|
|
1639
|
+
}
|
|
1640
|
+
for (const dir of context.unmarkedDirectories ?? []) {
|
|
1641
|
+
const candidates = customDirectoryCandidates(dir, prospective);
|
|
1642
|
+
if (candidates.some(candidate => recordedPhaseKey(candidate) === recordedPhaseKey(id))) {
|
|
1643
|
+
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');
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
known.add(id);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/** Custom heading IDs are identity hints only; titles never locate directories. */
|
|
1650
|
+
function customPhaseQuery(id, options) {
|
|
1651
|
+
if (options?.phaseNaming !== 'custom' || id.startsWith('['))
|
|
1652
|
+
return null;
|
|
1653
|
+
if (UNSAFE_PHASE_DIRECTORY_PART_RE.test(id))
|
|
1654
|
+
return null;
|
|
1655
|
+
const index = customIdentityIndex(options);
|
|
1656
|
+
const prefix = options.projectCode ? `${options.projectCode}-` : '';
|
|
1657
|
+
const queryBody = prefix && id.toLowerCase().startsWith(prefix.toLowerCase()) ? id.slice(prefix.length) : id;
|
|
1658
|
+
// Markers are authoritative; only legacy-shaped IDs share their ordinary
|
|
1659
|
+
// numeric padding normalization. Resolve recorded IDs before interpreting a
|
|
1660
|
+
// query as a basename: an ID may equal a different directory's full name.
|
|
1661
|
+
const aliases = index.recordedByFold.get(recordedPhaseKey(queryBody)) ?? [];
|
|
1662
|
+
let recorded = index.recorded.has(id) ? id : index.recorded.has(queryBody) ? queryBody : aliases.length === 1 ? aliases[0] : undefined;
|
|
1663
|
+
if (recorded !== undefined) {
|
|
1664
|
+
id = recorded;
|
|
1665
|
+
}
|
|
1666
|
+
else {
|
|
1667
|
+
const directoryId = options.directoryIds?.[id]
|
|
1668
|
+
?? (options.unmarkedDirectories?.includes(id) ? customDirectoryOwner(id, {
|
|
1669
|
+
id, listed: false, projectCode: options.projectCode, prefixes: index.prefixes,
|
|
1670
|
+
})?.id : undefined);
|
|
1671
|
+
if (directoryId !== undefined) {
|
|
1672
|
+
id = directoryId;
|
|
1673
|
+
if (index.recorded.has(id))
|
|
1674
|
+
recorded = id;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
const body = prefix && id.toLowerCase().startsWith(prefix.toLowerCase()) ? id.slice(prefix.length) : id;
|
|
1678
|
+
const listed = index.ids.has(id);
|
|
1679
|
+
// Legacy numeric tokens (including configured display prefixes) keep their
|
|
1680
|
+
// reading unless a writer explicitly recorded this exact ID. That record
|
|
1681
|
+
// also distinguishes an unpadded 2A from the custom continuation 2A-2.
|
|
1682
|
+
if (!index.recorded.has(id) && !body.includes('-') && LEGACY_PHASE_ID_RE.test(body))
|
|
1683
|
+
return null;
|
|
1684
|
+
if (!index.recorded.has(id) && !isCustomPhaseId(id) && !(listed && /[a-z]/i.test(id)))
|
|
1685
|
+
return null;
|
|
1686
|
+
// Only a complete legacy segment identifies a numeric directory. Digit-leading
|
|
1687
|
+
// custom IDs such as 2FA must not collapse to their leading digit run.
|
|
1688
|
+
if (!listed && !options.directoryIds?.[id] && body.includes('-') && LEGACY_PHASE_ID_RE.test(body.split('-')[0]))
|
|
1689
|
+
return null;
|
|
1690
|
+
// Listed identities outrank the query; a directory's slug never invents an ID.
|
|
1691
|
+
return {
|
|
1692
|
+
id, listed: listed, recordedId: recorded, projectCode: options.projectCode, directoryIds: options.directoryIds,
|
|
1693
|
+
prefixes: listed ? index.prefixes : [...index.prefixes, { id, prefix: phaseDirectoryName(id, '') }],
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
/** Candidate project prefixes for the custom directory shape. */
|
|
1697
|
+
function customDirectoryBodies(dir, projectCode) {
|
|
1698
|
+
const bodies = [dir];
|
|
1699
|
+
if (projectCode) {
|
|
1700
|
+
const prefix = `${projectCode}-`;
|
|
1701
|
+
if (dir.startsWith(prefix))
|
|
1702
|
+
bodies.unshift(dir.slice(prefix.length));
|
|
1703
|
+
}
|
|
1704
|
+
return bodies;
|
|
1705
|
+
}
|
|
1706
|
+
/** Known IDs and writer-shaped prefixes are computed once per query. */
|
|
1707
|
+
function customDirectoryOwner(dir, query) {
|
|
1708
|
+
// A writer-recorded identity survives heading edits and archival. A slug
|
|
1709
|
+
// cannot prove where an arbitrary hyphenated ID ends. Legacy directories
|
|
1710
|
+
// keep heading-assisted read compatibility; destructive callers require proof.
|
|
1711
|
+
const recordedId = query.directoryIds?.[dir];
|
|
1712
|
+
if (recordedId !== undefined) {
|
|
1713
|
+
if (UNSAFE_PHASE_DIRECTORY_PART_RE.test(recordedId))
|
|
1714
|
+
return null;
|
|
1715
|
+
const prefixes = [phaseDirectoryName(recordedId, '')];
|
|
1716
|
+
// Scaffold retains legacy numeric directory padding while recording the
|
|
1717
|
+
// requested ID. Resolve its directory shape independently of the query
|
|
1718
|
+
// normalization above; a digit-leading custom ID still owns its full token.
|
|
1719
|
+
if (LEGACY_PHASE_ID_RE.test(recordedId))
|
|
1720
|
+
prefixes.push(phaseDirectoryName(normalizePhaseName(recordedId), ''));
|
|
1721
|
+
for (const prefix of prefixes) {
|
|
1722
|
+
const body = customDirectoryBodies(dir, query.projectCode).find(body => body.startsWith(prefix));
|
|
1723
|
+
if (body)
|
|
1724
|
+
return { id: recordedId, slug: body.slice(prefix.length) };
|
|
1725
|
+
}
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
for (const body of customDirectoryBodies(dir, query.projectCode)) {
|
|
1729
|
+
const owner = query.prefixes.find(candidate => body.startsWith(candidate.prefix));
|
|
1730
|
+
if (owner)
|
|
1731
|
+
return { id: owner.id, slug: body.slice(owner.prefix.length) };
|
|
1732
|
+
}
|
|
1733
|
+
return null;
|
|
1734
|
+
}
|
|
1735
|
+
/** All plausible owners of a markerless directory, for destructive preflight. */
|
|
1736
|
+
function customDirectoryCandidates(dir, context) {
|
|
1737
|
+
if (context.phaseNaming !== 'custom')
|
|
1738
|
+
return [];
|
|
1739
|
+
const recorded = context.directoryIds?.[dir];
|
|
1740
|
+
if (recorded !== undefined) {
|
|
1741
|
+
if (!recorded || UNSAFE_PHASE_DIRECTORY_PART_RE.test(recorded))
|
|
1742
|
+
return [];
|
|
1743
|
+
const owner = customDirectoryOwner(dir, {
|
|
1744
|
+
id: recorded, listed: true, projectCode: context.projectCode,
|
|
1745
|
+
directoryIds: context.directoryIds, prefixes: [],
|
|
1746
|
+
});
|
|
1747
|
+
return owner ? [owner.id] : [];
|
|
1748
|
+
}
|
|
1749
|
+
const index = customIdentityIndex(context);
|
|
1750
|
+
const candidates = [...new Set(customDirectoryBodies(dir, context.projectCode).flatMap(body => index.prefixes.filter(candidate => body.startsWith(candidate.prefix)).map(candidate => candidate.id)))];
|
|
1751
|
+
// A lone legacy numeric heading is not proof of a custom identity. Preserve
|
|
1752
|
+
// all candidates when ownership is ambiguous, and explicit recorded hints.
|
|
1753
|
+
if (candidates.length === 1 && LEGACY_PHASE_ID_RE.test(candidates[0])
|
|
1754
|
+
&& !candidates[0].includes('-') && !index.explicitIds.has(candidates[0]))
|
|
1755
|
+
return [];
|
|
1756
|
+
return candidates;
|
|
1757
|
+
}
|
|
1758
|
+
function customPhaseDirectoryParts(dir, query) {
|
|
1759
|
+
const owner = customDirectoryOwner(dir, query);
|
|
1760
|
+
if (query.recordedId !== undefined)
|
|
1761
|
+
return owner?.id === query.recordedId ? owner : null;
|
|
1762
|
+
return owner?.id.toLowerCase() === query.id.toLowerCase() ? owner : null;
|
|
1763
|
+
}
|
|
1295
1764
|
/**
|
|
1296
1765
|
* #2528: the CANONICAL phase-directory match selection — the one rule every
|
|
1297
1766
|
* directory-resolution path (the shared locator plus the `find-phase` and
|
|
@@ -1299,7 +1768,11 @@ const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
|
|
|
1299
1768
|
* here because the surrounding scan/ambiguity/shaping code exists per site and
|
|
1300
1769
|
* had already diverged; the selection itself must not.
|
|
1301
1770
|
*
|
|
1302
|
-
*
|
|
1771
|
+
* Readers supply their naming context; legacy direct callers default to the
|
|
1772
|
+
* context-free numeric policy. Configured custom IDs
|
|
1773
|
+
* resolve through the inverse of phaseDirectoryName before normalization can
|
|
1774
|
+
* collapse an ID such as A-2 into a numeric query. Other queries retain the
|
|
1775
|
+
* legacy two passes:
|
|
1303
1776
|
* 1. PRIMARY — exact token match (`phaseTokenMatches`), unchanged behavior.
|
|
1304
1777
|
* 2. BARE-INTEGER FALLBACK — only when the primary pass matched NOTHING and
|
|
1305
1778
|
* the query is a bare integer, re-filter by each directory's own LEADING
|
|
@@ -1343,31 +1816,95 @@ const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
|
|
|
1343
1816
|
*
|
|
1344
1817
|
* TAKE `matches[0]` — `cmdPhasesList`, `cmdInitManager`, `cmdRoadmapAnalyze`,
|
|
1345
1818
|
* the schema-drift gate (`resolvePhaseDirByToken`), `detectVerifyFailed`. Each read a directory to
|
|
1346
|
-
* DECORATE a row they are already emitting
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1819
|
+
* DECORATE a row they are already emitting. In bracket mode those current-
|
|
1820
|
+
* checkout callers pass the active project+milestone context: an exact
|
|
1821
|
+
* qualified match wins, and bracket directories from other milestones are
|
|
1822
|
+
* excluded before any first-match policy can run. Legacy selection order is
|
|
1823
|
+
* unchanged.
|
|
1350
1824
|
*
|
|
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.
|
|
1825
|
+
* Without an active bracket context the historical first-match/ambiguity policy
|
|
1826
|
+
* remains the caller's responsibility. With a context, only migration-window
|
|
1827
|
+
* legacy names can participate in fallback when the exact bracket identity is
|
|
1828
|
+
* absent; a prior milestone's qualified directory can never mask the active one.
|
|
1357
1829
|
*
|
|
1358
1830
|
* `usedBareFallback` tells callers to derive the displayed phase number from
|
|
1359
1831
|
* the directory's leading digit run instead of `extractPhaseToken` (whose
|
|
1360
1832
|
* token for these dirs is the mis-absorbed multi-segment form).
|
|
1361
1833
|
*/
|
|
1362
|
-
|
|
1363
|
-
|
|
1834
|
+
/**
|
|
1835
|
+
* The query a directory lookup hands to `matchPhaseDirs`. Custom projects pass
|
|
1836
|
+
* the id as written (their ids have no padding form); every other convention
|
|
1837
|
+
* keeps the normalized token callers always supplied.
|
|
1838
|
+
*/
|
|
1839
|
+
function phaseLookupQuery(query, context) {
|
|
1840
|
+
return context?.phaseNaming === 'custom' ? query : normalizePhaseName(query);
|
|
1841
|
+
}
|
|
1842
|
+
function matchPhaseDirs(dirs, query, convention, bracketContext, context = phaseDirectoryContext({})) {
|
|
1843
|
+
const custom = convention === 'bracket' ? null : customPhaseQuery(query, context);
|
|
1844
|
+
if (custom?.recordedId !== undefined) {
|
|
1845
|
+
return { matches: dirs.filter(dir => customPhaseDirectoryParts(dir, custom) !== null), usedBareFallback: false };
|
|
1846
|
+
}
|
|
1847
|
+
// A complete basename names that physical directory when no record owns the
|
|
1848
|
+
// query. Custom projects only: a numeric project with `01` and `01-foo` must
|
|
1849
|
+
// still reach the #2237 ambiguity refusal for the bare token `01`.
|
|
1850
|
+
if (context.phaseNaming === 'custom' && convention !== 'bracket') {
|
|
1851
|
+
const directoryMatches = dirs.filter(dir => dir === query);
|
|
1852
|
+
if (directoryMatches.length > 0)
|
|
1853
|
+
return { matches: directoryMatches, usedBareFallback: false };
|
|
1854
|
+
}
|
|
1855
|
+
// Bracket queries arrive canonical from the lookup boundary; every other
|
|
1856
|
+
// convention normalizes here (custom ids keep their literal spelling below).
|
|
1857
|
+
const normalized = convention === 'bracket' ? query : normalizePhaseName(query);
|
|
1858
|
+
// Recorded directories never enter legacy token or bare-number fallback for
|
|
1859
|
+
// another query. Keep those compatibility passes for unmarked directories.
|
|
1860
|
+
if (context.phaseNaming === 'custom' && convention !== 'bracket') {
|
|
1861
|
+
dirs = dirs.filter(dir => context.directoryIds?.[dir] === undefined
|
|
1862
|
+
|| (custom !== null && customPhaseDirectoryParts(dir, custom) !== null));
|
|
1863
|
+
}
|
|
1864
|
+
if (custom) {
|
|
1865
|
+
// A full directory name is an existing lookup form, distinct from an ID prefix.
|
|
1866
|
+
const exact = dirs.filter(d => d === custom.id);
|
|
1867
|
+
if (exact.length > 0)
|
|
1868
|
+
return { matches: exact, usedBareFallback: false };
|
|
1869
|
+
const matches = dirs.filter(d => customPhaseDirectoryParts(d, custom) !== null);
|
|
1870
|
+
if (matches.length > 0 || isCustomPhaseId(query))
|
|
1871
|
+
return { matches, usedBareFallback: false };
|
|
1872
|
+
}
|
|
1873
|
+
// Custom writers preserve an explicit ID's spelling, including an unpadded
|
|
1874
|
+
// letter suffix (7B). Prefer that literal token before the legacy padded form.
|
|
1875
|
+
if (context?.phaseNaming === 'custom' && convention !== 'bracket' && query !== normalized) {
|
|
1876
|
+
const literal = dirs.filter(d => phaseTokenMatches(d, query, convention));
|
|
1877
|
+
if (literal.length > 0)
|
|
1878
|
+
return { matches: literal, usedBareFallback: false };
|
|
1879
|
+
}
|
|
1880
|
+
let candidates = dirs;
|
|
1881
|
+
// #4304: a bare phase number from STATE/ROADMAP names the active bracket
|
|
1882
|
+
// identity, not every physical directory left behind by prior milestones.
|
|
1883
|
+
// Prefer the exact qualified directory. If it is absent, retain only
|
|
1884
|
+
// migration-window legacy names; a differently-qualified bracket directory
|
|
1885
|
+
// must never win merely because it sorts first.
|
|
1886
|
+
if (convention === 'bracket' && bracketContext && !bracketQualifiedKey(normalized, convention)) {
|
|
1887
|
+
const qualified = `${bracketContext.project}.${bracketContext.milestone}-${normalized}`;
|
|
1888
|
+
const exact = dirs.filter(d => phaseTokenMatches(d, qualified, convention));
|
|
1889
|
+
if (exact.length > 0)
|
|
1890
|
+
return { matches: exact, usedBareFallback: false };
|
|
1891
|
+
candidates = dirs.filter(d => bracketQualifiedKey(d, convention) === null);
|
|
1892
|
+
}
|
|
1893
|
+
const primary = candidates.filter(d => phaseTokenMatches(d, normalized, convention));
|
|
1364
1894
|
if (primary.length > 0)
|
|
1365
1895
|
return { matches: primary, usedBareFallback: false };
|
|
1896
|
+
// Normalization pads a literal continuation ID such as 0-00 to 00-00.
|
|
1897
|
+
// Preserve its exact-token lookup before falling back to a bare integer.
|
|
1898
|
+
if (query !== normalized && query.includes('-')) {
|
|
1899
|
+
const literal = dirs.filter(d => phaseTokenMatches(d, query, convention));
|
|
1900
|
+
if (literal.length > 0)
|
|
1901
|
+
return { matches: literal, usedBareFallback: false };
|
|
1902
|
+
}
|
|
1366
1903
|
const bare = String(normalized);
|
|
1367
1904
|
if (!BARE_INTEGER_RE.test(bare))
|
|
1368
1905
|
return { matches: primary, usedBareFallback: false };
|
|
1369
1906
|
const want = unpad(bare);
|
|
1370
|
-
const fallback =
|
|
1907
|
+
const fallback = candidates.filter(d => {
|
|
1371
1908
|
const m = stripProjectCodePrefix(d).match(LEADING_DIGIT_RUN_RE);
|
|
1372
1909
|
return m !== null && unpad(m[1]) === want;
|
|
1373
1910
|
});
|
|
@@ -1379,9 +1916,9 @@ function matchPhaseDirs(dirs, normalized, convention) {
|
|
|
1379
1916
|
* directory's leading digit run (the whole point of the fallback is that the
|
|
1380
1917
|
* extracted token is wrong for these dirs).
|
|
1381
1918
|
*/
|
|
1382
|
-
function phaseNumberForMatch(dirName, usedBareFallback) {
|
|
1919
|
+
function phaseNumberForMatch(dirName, usedBareFallback, convention) {
|
|
1383
1920
|
if (!usedBareFallback)
|
|
1384
|
-
return extractPhaseToken(dirName);
|
|
1921
|
+
return extractPhaseToken(dirName, convention);
|
|
1385
1922
|
const stripped = stripProjectCodePrefix(dirName);
|
|
1386
1923
|
const prefix = dirName.slice(0, dirName.length - stripped.length);
|
|
1387
1924
|
const m = stripped.match(LEADING_DIGIT_RUN_PREFIX_RE);
|
|
@@ -1410,7 +1947,9 @@ function phaseNumberForMatch(dirName, usedBareFallback) {
|
|
|
1410
1947
|
* of wide IDs (`001.10`) is relied on by plan-ID capture and wave assignment.
|
|
1411
1948
|
* Arithmetic is avoided (`parseInt` would lose precision on a long digit run).
|
|
1412
1949
|
*/
|
|
1413
|
-
function phaseKeyFromToken(token) {
|
|
1950
|
+
function phaseKeyFromToken(token, context) {
|
|
1951
|
+
if (context && customPhaseQuery(String(token), context))
|
|
1952
|
+
return String(token).toUpperCase();
|
|
1414
1953
|
const stripped = String(token)
|
|
1415
1954
|
.split('-')
|
|
1416
1955
|
.map(segment => segment.replace(/^0+(?=\d)/, ''))
|
|
@@ -1430,8 +1969,16 @@ function phaseKeyFromToken(token) {
|
|
|
1430
1969
|
* #2562's own defect class reached from the other side: both sides of a comparison
|
|
1431
1970
|
* must be derived not merely by the same function but under the same convention.
|
|
1432
1971
|
*/
|
|
1433
|
-
function phaseKeyFromDir(dirName, convention) {
|
|
1434
|
-
|
|
1972
|
+
function phaseKeyFromDir(dirName, convention, context) {
|
|
1973
|
+
if (context && convention !== 'bracket') {
|
|
1974
|
+
const custom = customPhaseQuery(dirName, context);
|
|
1975
|
+
if (custom) {
|
|
1976
|
+
const owner = customDirectoryOwner(dirName, custom);
|
|
1977
|
+
if (owner)
|
|
1978
|
+
return phaseKeyFromToken(owner.id, context);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
return phaseKeyFromToken(extractPhaseToken(dirName, convention), context);
|
|
1435
1982
|
}
|
|
1436
1983
|
/**
|
|
1437
1984
|
* Canonical key for a phase referenced in PROSE — a ROADMAP `## Progress` table
|
|
@@ -1440,11 +1987,12 @@ function phaseKeyFromDir(dirName, convention) {
|
|
|
1440
1987
|
* mistaken for a non-phase. Returns null when the value does not BEGIN with a
|
|
1441
1988
|
* phase token (`parsePhaseFromProse` anchoring, #2111).
|
|
1442
1989
|
*/
|
|
1443
|
-
function phaseKeyFromProse(value) {
|
|
1990
|
+
function phaseKeyFromProse(value, context) {
|
|
1444
1991
|
if (value == null)
|
|
1445
1992
|
return null;
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1993
|
+
const clean = String(value).replace(/[*_`~]/g, '');
|
|
1994
|
+
const { phase } = parsePhaseFromProse(clean, undefined, context);
|
|
1995
|
+
return phase === null ? null : phaseKeyFromToken(phase, context);
|
|
1448
1996
|
}
|
|
1449
1997
|
/**
|
|
1450
1998
|
* The PARENT phase key of a sub-phase key (`"30.1"` → `"30"`), or null for a
|
|
@@ -1484,12 +2032,19 @@ function isPlannedPhaseStatusPhrase(value) {
|
|
|
1484
2032
|
// one) keeps the dotted-only capture, where `3-01` reads as phase `3` exactly as before.
|
|
1485
2033
|
const PHASE_PROSE_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i;
|
|
1486
2034
|
const PHASE_PROSE_MILESTONE_PREFIXED_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+(?:-\d+)?[A-Z]?(?:\.\d+)*)\b/i;
|
|
1487
|
-
function parsePhaseFromProse(value, convention) {
|
|
2035
|
+
function parsePhaseFromProse(value, convention, context) {
|
|
1488
2036
|
if (!value)
|
|
1489
2037
|
return { phase: null, name: null };
|
|
1490
2038
|
// Coerce defensively so a non-string caller cannot throw on this canonical
|
|
1491
2039
|
// surface (mirrors the sibling #2121 functions' String(...) handling).
|
|
1492
2040
|
const str = String(value);
|
|
2041
|
+
const candidate = context?.phaseNaming === 'custom'
|
|
2042
|
+
? str.trim().replace(/^Phase[ \t]+/i, '').split(/[ \t:]/, 1)[0].replace(/\.$/, '')
|
|
2043
|
+
: null;
|
|
2044
|
+
const query = candidate && context && customPhaseQuery(candidate, context);
|
|
2045
|
+
// Only an identity a writer recorded or the roadmap lists is a phase id; an
|
|
2046
|
+
// ordinary word in the field (`complete`, `None`, `TBD`) is not one.
|
|
2047
|
+
const custom = query && (query.listed || query.recordedId !== undefined);
|
|
1493
2048
|
const phaseMatch = str.match(convention === 'milestone-prefixed' ? PHASE_PROSE_MILESTONE_PREFIXED_RE : PHASE_PROSE_RE);
|
|
1494
2049
|
// The name-extraction quantifiers are length-bounded so a crafted long
|
|
1495
2050
|
// unterminated run (many `(` or `—`) in an untrusted STATE.md field value
|
|
@@ -1533,7 +2088,7 @@ function parsePhaseFromProse(value, convention) {
|
|
|
1533
2088
|
? rawName.trim()
|
|
1534
2089
|
: null;
|
|
1535
2090
|
return {
|
|
1536
|
-
phase: phaseMatch ? phaseMatch[1] : null,
|
|
2091
|
+
phase: custom ? candidate : phaseMatch ? phaseMatch[1] : null,
|
|
1537
2092
|
name,
|
|
1538
2093
|
};
|
|
1539
2094
|
}
|
|
@@ -1647,11 +2202,13 @@ function roadmapPhaseLookupSources(phaseNum) {
|
|
|
1647
2202
|
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
|
|
1648
2203
|
return [...new Set(sources)];
|
|
1649
2204
|
}
|
|
1650
|
-
|
|
2205
|
+
const phaseIdExports = {
|
|
1651
2206
|
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
|
1652
2207
|
OPTIONAL_PHASE_TAG_SOURCE,
|
|
1653
2208
|
PHASE_NUMBER_TOKEN_SOURCE,
|
|
1654
2209
|
PHASE_DEP_REF_SOURCE,
|
|
2210
|
+
tokenizePhaseDependencyReferences,
|
|
2211
|
+
extractPhaseDependencyTokens,
|
|
1655
2212
|
CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE,
|
|
1656
2213
|
CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE,
|
|
1657
2214
|
PHASE_CONTINUATION_SEGMENT_SOURCE,
|
|
@@ -1668,7 +2225,10 @@ module.exports = {
|
|
|
1668
2225
|
BASE_PHASE_LABEL_PREFIX_SRC,
|
|
1669
2226
|
PHASE_HEADING_BASELINE,
|
|
1670
2227
|
phaseHeadingPrefixSrcFor,
|
|
2228
|
+
parsePhaseChecklistLine,
|
|
2229
|
+
bracketQualifiedIntroSrcFor,
|
|
1671
2230
|
buildPhaseHeadingScanRegex,
|
|
2231
|
+
buildPhaseDeclarationScans,
|
|
1672
2232
|
foldBracketId,
|
|
1673
2233
|
bracketQualifiedKey,
|
|
1674
2234
|
stripProjectCodePrefix,
|
|
@@ -1692,6 +2252,19 @@ module.exports = {
|
|
|
1692
2252
|
scopeToPhase,
|
|
1693
2253
|
phaseTokenMatches,
|
|
1694
2254
|
matchPhaseDirs,
|
|
2255
|
+
phaseLookupQuery,
|
|
2256
|
+
phaseDirectoryContext,
|
|
2257
|
+
phaseScanTokenSource,
|
|
2258
|
+
phaseDirectoryName,
|
|
2259
|
+
validateCustomPhaseId,
|
|
2260
|
+
validateCustomPhaseIds,
|
|
2261
|
+
PhaseDirectoryNameError,
|
|
2262
|
+
customPhaseQuery,
|
|
2263
|
+
customPhaseDirectoryParts,
|
|
2264
|
+
customDirectoryOwner,
|
|
2265
|
+
customDirectoryCandidates,
|
|
2266
|
+
CUSTOM_PHASE_ID_FILE,
|
|
2267
|
+
isCustomPhaseId,
|
|
1695
2268
|
phaseNumberForMatch,
|
|
1696
2269
|
phaseKeyFromToken,
|
|
1697
2270
|
phaseKeyFromDir,
|
|
@@ -1704,3 +2277,4 @@ module.exports = {
|
|
|
1704
2277
|
roadmapPhaseLookupSources,
|
|
1705
2278
|
renderPhaseBranchName,
|
|
1706
2279
|
};
|
|
2280
|
+
module.exports = phaseIdExports;
|