@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
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
22
22
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
23
23
|
};
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
24
25
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
25
26
|
const node_path_1 = __importDefault(require("node:path"));
|
|
26
27
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
28
|
+
const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
|
|
27
29
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
30
|
const phaseIdModule = require("./phase-id.cjs");
|
|
29
31
|
const { phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE,
|
|
@@ -53,10 +55,14 @@ const planningWorkspace = require("./planning-workspace.cjs");
|
|
|
53
55
|
const { planningDir, resolvePhaseIdConvention } = planningWorkspace;
|
|
54
56
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
55
57
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
58
|
+
const frontmatterMod = require("./frontmatter.cjs");
|
|
59
|
+
const { rawFrontmatterMapping } = frontmatterMod;
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
56
61
|
const unusableInputMod = require("./unusable-input.cjs");
|
|
57
62
|
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
|
|
58
63
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
59
64
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
65
|
+
const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
|
|
60
66
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
61
67
|
const planningScopeMod = require("./planning-scope.cjs");
|
|
62
68
|
const { SCOPE } = planningScopeMod;
|
|
@@ -72,6 +78,21 @@ const MILESTONE_ACTIVE_MARKER_PATTERN = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progres
|
|
|
72
78
|
function isClosedMilestoneHeading(headingText) {
|
|
73
79
|
return MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) && !MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText);
|
|
74
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Is this `<details>` block a CLOSED milestone? One test for the read-side strip
|
|
83
|
+
* and the write-side mask. The `<summary>` is read with inline tags removed
|
|
84
|
+
* (`<b>v1.0</b> ✅`); a milestone-shaped summary (version token or "milestone")
|
|
85
|
+
* that says "Complete(d)" counts too. No `<summary>` means not closed.
|
|
86
|
+
*/
|
|
87
|
+
function isClosedMilestoneBlock(block) {
|
|
88
|
+
const summary = block.match(/<summary[^>]*>([\s\S]{0,1000}?)<\/summary>/i);
|
|
89
|
+
if (!summary)
|
|
90
|
+
return false;
|
|
91
|
+
const text = summary[1].replace(/<[^>]{0,200}>/g, '');
|
|
92
|
+
return isClosedMilestoneHeading(text)
|
|
93
|
+
|| (/\bcomplete(?:d)?\b/i.test(text) && /\bv\d+(?:\.\d+)*\b|\bmilestone\b/i.test(text)
|
|
94
|
+
&& !MILESTONE_ACTIVE_MARKER_PATTERN.test(text));
|
|
95
|
+
}
|
|
75
96
|
/**
|
|
76
97
|
* Strip shipped milestone content wrapped in <details> blocks.
|
|
77
98
|
*/
|
|
@@ -79,15 +100,71 @@ function stripShippedMilestones(content) {
|
|
|
79
100
|
return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
|
|
80
101
|
}
|
|
81
102
|
/**
|
|
82
|
-
* #5543:
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
103
|
+
* #5543: hide every CLOSED-milestone `<details>` block (`<details>` opener, a
|
|
104
|
+
* `<summary>` that `isClosedMilestoneHeading` reads as shipped/archived, the
|
|
105
|
+
* #3982 test) behind a placeholder comment; `restore` puts them back
|
|
106
|
+
* byte-for-byte, or returns null when `edited` lost or duplicated a placeholder.
|
|
107
|
+
* For writers whose first `### Phase N:` in document order must never sit in a
|
|
108
|
+
* shipped block. Other `<details>` (notes, a phase wrapper, an active
|
|
109
|
+
* milestone's collapsed block) stay visible, so a roadmap with no archived
|
|
110
|
+
* milestone is edited as before. Blocks are matched by depth, so a notes
|
|
111
|
+
* `<details>` nested inside a shipped one stays inside it (the flat
|
|
112
|
+
* `taggedBlockPattern` closes at the inner `</details>`). An unmatched
|
|
113
|
+
* opener (prose, a fence) wraps nothing, so a later shipped block is still
|
|
114
|
+
* masked. Fences are not parsed, as in every ROADMAP `<details>` reader here.
|
|
88
115
|
*/
|
|
116
|
+
function maskShippedBlocks(content) {
|
|
117
|
+
const blocks = [];
|
|
118
|
+
let nonce = 0;
|
|
119
|
+
while (content.includes(`<!-- gsd-masked-shipped-${nonce}-`))
|
|
120
|
+
nonce += 1;
|
|
121
|
+
const placeholder = (i) => `<!-- gsd-masked-shipped-${nonce}-${i} -->`;
|
|
122
|
+
const tags = /<details(?:\s[^>]{0,1000})?>|<\/details>/gi;
|
|
123
|
+
// Matched pairs by stack; an unmatched opener (prose, a fence) never closes, so it
|
|
124
|
+
// wraps nothing and a later block is still found. `outer` keeps only the pairs not
|
|
125
|
+
// nested in another matched pair.
|
|
126
|
+
const open = [];
|
|
127
|
+
const outer = [];
|
|
128
|
+
for (let m = tags.exec(content); m !== null; m = tags.exec(content)) {
|
|
129
|
+
if (m[0][1] !== '/')
|
|
130
|
+
open.push(m.index);
|
|
131
|
+
else if (open.length > 0) {
|
|
132
|
+
const start = open.pop();
|
|
133
|
+
while (outer.length > 0 && outer[outer.length - 1][0] >= start)
|
|
134
|
+
outer.pop();
|
|
135
|
+
outer.push([start, m.index + m[0].length]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let masked = '';
|
|
139
|
+
let copied = 0;
|
|
140
|
+
for (const [start, end] of outer) {
|
|
141
|
+
const block = content.slice(start, end);
|
|
142
|
+
if (block.startsWith('<details>') && isClosedMilestoneBlock(block)) {
|
|
143
|
+
masked += content.slice(copied, start) + placeholder(blocks.length);
|
|
144
|
+
blocks.push(block);
|
|
145
|
+
copied = end;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
masked += content.slice(copied);
|
|
149
|
+
return {
|
|
150
|
+
masked,
|
|
151
|
+
restore(edited) {
|
|
152
|
+
let restored = edited;
|
|
153
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
154
|
+
const marker = placeholder(i);
|
|
155
|
+
const at = restored.indexOf(marker);
|
|
156
|
+
if (at === -1 || restored.indexOf(marker, at + marker.length) !== -1)
|
|
157
|
+
return null;
|
|
158
|
+
restored = restored.slice(0, at) + blocks[i] + restored.slice(at + marker.length);
|
|
159
|
+
}
|
|
160
|
+
return restored;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
89
164
|
function editOutsideShippedBlocks(content, edit) {
|
|
90
|
-
|
|
165
|
+
const { masked, restore } = maskShippedBlocks(content);
|
|
166
|
+
const edited = edit(masked);
|
|
167
|
+
return (typeof edited === 'string' ? restore(edited) : null) ?? content;
|
|
91
168
|
}
|
|
92
169
|
/**
|
|
93
170
|
* #3982: strip only <details> blocks whose <summary> marks a CLOSED milestone
|
|
@@ -97,12 +174,15 @@ function editOutsideShippedBlocks(content, edit) {
|
|
|
97
174
|
* reproduce the phase_count: 0 class of #557/#2947.
|
|
98
175
|
*/
|
|
99
176
|
function stripClosedMilestoneDetails(content) {
|
|
100
|
-
return content.replace(/<details\b[^>]*>[\s\S]*?<\/details>/gi, (block) =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
177
|
+
return content.replace(/<details\b[^>]*>[\s\S]*?<\/details>/gi, (block) => (isClosedMilestoneBlock(block) ? '' : block));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Classify a complete `<details>` block through isClosedMilestoneBlock, the one rule shared with
|
|
181
|
+
* stripClosedMilestoneDetails and the write-side shipped-block mask. Destructive consumers share this predicate so
|
|
182
|
+
* an active collapsed phase list is never reclassified as shipped history.
|
|
183
|
+
*/
|
|
184
|
+
function isClosedMilestoneDetails(block) {
|
|
185
|
+
return isClosedMilestoneBlock(block);
|
|
106
186
|
}
|
|
107
187
|
/**
|
|
108
188
|
* #2562: is the milestone `version` marked SHIPPED by the ROADMAP itself?
|
|
@@ -358,6 +438,25 @@ function listMilestoneHeadings(content) {
|
|
|
358
438
|
// rather than typing `^#{1,3}\s+(?!Phase\s+\S)` a second time — the exact
|
|
359
439
|
// duplication class ADR-3180 §7.2's widened guard exists to catch.
|
|
360
440
|
const MILESTONE_HEADING_LINE_SOURCE = '^#{1,3}\\s+(?!Phase\\s+\\S)[^\\n]*';
|
|
441
|
+
/**
|
|
442
|
+
* #4304: classify one level-1..3 heading through the same milestone grammar
|
|
443
|
+
* the window locator owns. Phase headings are excluded first through the
|
|
444
|
+
* reader's own legacy/bracket discriminators, regardless of words or markers
|
|
445
|
+
* in their titles; `Failed Payment Recovery` and `Done ✅` remain phase
|
|
446
|
+
* titles, never milestone section boundaries. Version-bearing headings route
|
|
447
|
+
* through `listMilestoneHeadings`; version-less bracket headings route through
|
|
448
|
+
* `isBracketMilestoneBoundary`, the discriminator also used by the bracket
|
|
449
|
+
* fallback locator.
|
|
450
|
+
*/
|
|
451
|
+
function isRecognizedMilestoneHeading(headingText, level) {
|
|
452
|
+
if (level < 1 || level > 3)
|
|
453
|
+
return false;
|
|
454
|
+
if (/^Phase\s+\S/i.test(headingText) || BRACKET_PHASE_TAIL_RE.test(headingText))
|
|
455
|
+
return false;
|
|
456
|
+
const line = `${'#'.repeat(level)} ${headingText}`;
|
|
457
|
+
return listMilestoneHeadings(line).length > 0
|
|
458
|
+
|| isBracketMilestoneBoundary(headingText, level, null);
|
|
459
|
+
}
|
|
361
460
|
/**
|
|
362
461
|
* #3184: the sole milestone-heading locator. Boundary-matched on the version
|
|
363
462
|
* token with `\b`, NOT the stricter `(?![\w.-])`: this function keeps `\b`
|
|
@@ -676,23 +775,32 @@ const PHASE_HEADING_TEXT_RE = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:
|
|
|
676
775
|
* leading `#{1,6}` markers and surrounding whitespace already stripped
|
|
677
776
|
* (`tokenizeHeadings`'s own `h.text`, or the equivalent for a line a caller
|
|
678
777
|
* already knows is a `#{2,4}` heading) — is a phase heading by the readers'
|
|
679
|
-
* own grammar. The single owner of this test
|
|
680
|
-
*
|
|
778
|
+
* own grammar. The single owner of this test: `hasPhaseEntries` reaches it
|
|
779
|
+
* through `isPhaseEntryHeading` below, and the bracket roadmap migrator
|
|
780
|
+
* calls it directly.
|
|
681
781
|
*/
|
|
682
782
|
function isPhaseHeadingText(headingText) {
|
|
683
783
|
return PHASE_HEADING_TEXT_RE.test(headingText);
|
|
684
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* Does one fence-excluded heading token use the reader's phase-entry grammar?
|
|
787
|
+
* Kept separate from heading depth so destructive consumers can apply the
|
|
788
|
+
* same bracket-plus-legacy recognition to tokens from `tokenizeHeadings`.
|
|
789
|
+
* The legacy arm is `isPhaseHeadingText`, that grammar's single owner.
|
|
790
|
+
*/
|
|
791
|
+
function isPhaseEntryHeading(headingText, phaseIdConvention) {
|
|
792
|
+
if (isPhaseHeadingText(headingText))
|
|
793
|
+
return true;
|
|
794
|
+
return phaseIdConvention === 'bracket' && BRACKET_PHASE_ENTRY_HEADING_RE.test(headingText);
|
|
795
|
+
}
|
|
685
796
|
function hasPhaseEntries(markdown, phaseIdConvention) {
|
|
686
797
|
// #3641: the widened grammar engages ONLY when the resolved convention is
|
|
687
798
|
// 'bracket' — a project that has not opted in runs the legacy pattern
|
|
688
799
|
// alone, byte-identically.
|
|
689
|
-
const bracketMode = phaseIdConvention === 'bracket';
|
|
690
800
|
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(markdown)) {
|
|
691
801
|
if (h.level < 2 || h.level > 4)
|
|
692
802
|
continue;
|
|
693
|
-
if (
|
|
694
|
-
return true;
|
|
695
|
-
if (bracketMode && BRACKET_PHASE_ENTRY_HEADING_RE.test(h.text))
|
|
803
|
+
if (isPhaseEntryHeading(h.text, phaseIdConvention))
|
|
696
804
|
return true;
|
|
697
805
|
}
|
|
698
806
|
// #3184 review finding: the bullet fallback must be fence-aware too, or a
|
|
@@ -786,7 +894,7 @@ function hasPhaseListingTableHeader(content) {
|
|
|
786
894
|
* narrower question — "which phase ids does this window declare" — where only
|
|
787
895
|
* the 999 icebox range is excluded.
|
|
788
896
|
*/
|
|
789
|
-
function scanMilestonePhaseIdSets(window, convention) {
|
|
897
|
+
function scanMilestonePhaseIdSets(window, convention, context) {
|
|
790
898
|
const ids = new Set();
|
|
791
899
|
const qualifiedIds = new Set();
|
|
792
900
|
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
|
|
@@ -797,7 +905,7 @@ function scanMilestonePhaseIdSets(window, convention) {
|
|
|
797
905
|
// belonging to different milestones.
|
|
798
906
|
const capturing = convention === 'bracket';
|
|
799
907
|
const bracketGroups = capturing ? 1 : 0;
|
|
800
|
-
const phaseHeadingPattern = new RegExp(`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, capturing)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
|
|
908
|
+
const phaseHeadingPattern = new RegExp(`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, capturing)}(${context?.phaseNaming === 'custom' && convention !== 'bracket' ? phaseIdModule.phaseScanTokenSource(context) : '[\\w][\\w.-]*'})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
|
|
801
909
|
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(window)) {
|
|
802
910
|
if (h.level < 2 || h.level > 4)
|
|
803
911
|
continue;
|
|
@@ -955,6 +1063,87 @@ function classifyMilestoneWindow(input) {
|
|
|
955
1063
|
const PHASE_HEADING_BLOCK_STRIP_RE = new RegExp(
|
|
956
1064
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` (OPTIONAL_PHASE_TAG_SOURCE) tolerates a pre-colon ( ) tag.
|
|
957
1065
|
`^#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)}${PHASE_NUMBER_TOKEN_SOURCE}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:[^\\n]*(?:\\n(?!#{1,6}\\s)[^\\n]*)*\\n?`, 'gim');
|
|
1066
|
+
/**
|
|
1067
|
+
* #612/#4304: the bracket-fallback candidate search for a milestone heading
|
|
1068
|
+
* carrying no vX.Y version token at all — the ADR-612 canonical shape
|
|
1069
|
+
* (`## [GSD.09] Hidden`). Tried by a caller only when the legacy
|
|
1070
|
+
* version-string search (`locateMilestoneHeadings`) finds nothing and the
|
|
1071
|
+
* project is on the bracket convention. Extracted (#4304 PR-4) from
|
|
1072
|
+
* `extractCurrentMilestoneScoped`'s own inline block so it and
|
|
1073
|
+
* `currentMilestoneRawRanges` share one heading grammar instead of each
|
|
1074
|
+
* re-deriving the bracket intro pattern independently, the Generative Fix
|
|
1075
|
+
* Divergence class #2761 documents. Byte-identical logic to the block it replaces; moving it
|
|
1076
|
+
* changes no behavior.
|
|
1077
|
+
*/
|
|
1078
|
+
function bracketFallbackHeadingMatches(content, version, bracketScopeConvention) {
|
|
1079
|
+
if (bracketScopeConvention !== 'bracket')
|
|
1080
|
+
return [];
|
|
1081
|
+
const vMatch = version.match(/^v(\d+)/i);
|
|
1082
|
+
const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
|
|
1083
|
+
if (!Number.isSafeInteger(milestoneInt))
|
|
1084
|
+
return [];
|
|
1085
|
+
// #2761 M3: the phase-id owner supplies both the bracket intro grammar
|
|
1086
|
+
// and canonical pad2 spelling; `[CODE.2]` therefore cannot bound a
|
|
1087
|
+
// section whose phase headings the bracket grammar rejects.
|
|
1088
|
+
// #612: HeadingToken.offset is the line start, so require the
|
|
1089
|
+
// `#` there to preserve the old line-start anchor's indentation parity.
|
|
1090
|
+
// For every survivor, rebuilding [fullLine, fullLine] plus `.index`
|
|
1091
|
+
// retains the match shape and first-match order expected downstream.
|
|
1092
|
+
const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
|
|
1093
|
+
return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)
|
|
1094
|
+
.filter((h) => h.level <= 3 && content[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text))
|
|
1095
|
+
.map((h) => {
|
|
1096
|
+
const lineEnd = content.indexOf('\n', h.offset);
|
|
1097
|
+
const fullLine = content.slice(h.offset, lineEnd === -1 ? content.length : lineEnd);
|
|
1098
|
+
return Object.assign([fullLine, fullLine], { index: h.offset });
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* #612/#4304: the milestone-heading SELECTION fallback chain — prefers the
|
|
1103
|
+
* canonical legacy selector (`selectMilestoneHeading`), then falls back to
|
|
1104
|
+
* the gated bracket candidates (`headingMatches`, already populated by
|
|
1105
|
+
* `bracketFallbackHeadingMatches` when the legacy selector's own internal
|
|
1106
|
+
* `locateMilestoneHeadings` call finds nothing) when a name-only bracket
|
|
1107
|
+
* milestone carries no version token for that selector to find. Shared
|
|
1108
|
+
* (#4304 PR-4) by `extractCurrentMilestoneScoped` and
|
|
1109
|
+
* `currentMilestoneRawRanges` so both agree on which heading is "the active
|
|
1110
|
+
* one" for a version-less bracket milestone. `headingMatches` must be
|
|
1111
|
+
* non-empty (every caller checks this first).
|
|
1112
|
+
*/
|
|
1113
|
+
function selectActiveMilestoneHeading(content, version, headingMatches) {
|
|
1114
|
+
return selectMilestoneHeading(content, version)
|
|
1115
|
+
?? headingMatches.find((m) => !isClosedMilestoneHeading(m[1]))
|
|
1116
|
+
?? headingMatches[0];
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* #612/#4304: resolves the bracket-aware milestone SECTION END, the
|
|
1120
|
+
* SELECTED heading's own bracket id, and the `bracketBoundary` predicate
|
|
1121
|
+
* itself — the caller reuses all three afterward (`extractCurrentMilestoneScoped`'s
|
|
1122
|
+
* own "earliest milestone heading" preamble scan reuses `selectedBracketId`;
|
|
1123
|
+
* its "(Phase Details)" section-end lookup reuses `bracketBoundary`).
|
|
1124
|
+
* `bracketBoundaryActive` and `currentMilestoneHeadings` are supplied by the
|
|
1125
|
+
* caller (not recomputed here) because both callers need them again
|
|
1126
|
+
* afterward for their own, divergent purposes. Shared (#4304 PR-4) by
|
|
1127
|
+
* `extractCurrentMilestoneScoped` and `currentMilestoneRawRanges` so a
|
|
1128
|
+
* version-less bracket milestone heading (`## [GSD.09] Hidden`) bounds its
|
|
1129
|
+
* section identically for both, instead of one of them silently running the
|
|
1130
|
+
* LEGACY-only (vX.Y/emoji-only) boundary rule. Byte-identical logic to the
|
|
1131
|
+
* block it replaces; moving it changes no behavior.
|
|
1132
|
+
*/
|
|
1133
|
+
function bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings) {
|
|
1134
|
+
// #2761 B1: a bracket heading bearing the selected milestone's own id is
|
|
1135
|
+
// a continuation, not a boundary. Derive the selected id once and compose
|
|
1136
|
+
// the shared discriminator with upstream's centralized section-end walk.
|
|
1137
|
+
const selectedBracketMatch = bracketBoundaryActive
|
|
1138
|
+
? selected[0].match(new RegExp(`^#{1,3}\\s+\\[(${BRACKET_ID_SRC})\\]`, 'i'))
|
|
1139
|
+
: null;
|
|
1140
|
+
const selectedBracketId = selectedBracketMatch ? foldBracketId(selectedBracketMatch[1]) : null;
|
|
1141
|
+
const bracketBoundary = bracketBoundaryActive
|
|
1142
|
+
? (heading) => isBracketMilestoneBoundary(heading.text, heading.level, selectedBracketId)
|
|
1143
|
+
: undefined;
|
|
1144
|
+
const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart, bracketBoundary, currentMilestoneHeadings);
|
|
1145
|
+
return { sectionEnd, selectedBracketId, bracketBoundary };
|
|
1146
|
+
}
|
|
958
1147
|
/**
|
|
959
1148
|
* #5038: single owner for classifying a `milestone:` scalar as STATE.md spells
|
|
960
1149
|
* it. The frontmatter parser (FAILSAFE_SCHEMA) and the raw-regex readers both
|
|
@@ -975,6 +1164,28 @@ function classifyMilestoneScalar(raw) {
|
|
|
975
1164
|
return { explicitNull: true, version: null };
|
|
976
1165
|
return { explicitNull: false, version: trimmed };
|
|
977
1166
|
}
|
|
1167
|
+
/**
|
|
1168
|
+
* #4998: STATE.md's `milestone:` scalar as the frontmatter parser DECODES it.
|
|
1169
|
+
* The raw `^milestone:` line read captured the on-disk YAML text — quotes and
|
|
1170
|
+
* escapes included — and that text was re-serialized as the value, adding one
|
|
1171
|
+
* escape layer per `state.*` write. Falls back to the legacy line read only
|
|
1172
|
+
* when there is no parseable frontmatter, or its `milestone` is not text. One
|
|
1173
|
+
* that parses without a `milestone` key names none: a `milestone:` line further
|
|
1174
|
+
* down (a fenced example) is not one (#4998 review, Minor 5).
|
|
1175
|
+
*/
|
|
1176
|
+
function readStateMilestoneScalar(stateRaw) {
|
|
1177
|
+
const fm = rawFrontmatterMapping(stateRaw);
|
|
1178
|
+
if (fm) {
|
|
1179
|
+
if (!Object.prototype.hasOwnProperty.call(fm, 'milestone'))
|
|
1180
|
+
return null;
|
|
1181
|
+
if (typeof fm['milestone'] === 'string')
|
|
1182
|
+
return fm['milestone'];
|
|
1183
|
+
// A non-string value keeps the pre-#4998 line read, which finds this
|
|
1184
|
+
// frontmatter line first.
|
|
1185
|
+
}
|
|
1186
|
+
const m = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
1187
|
+
return m ? m[1] : null;
|
|
1188
|
+
}
|
|
978
1189
|
/**
|
|
979
1190
|
* Extract the current milestone section from ROADMAP.md by positive lookup,
|
|
980
1191
|
* carrying a `scope` discriminator (ADR-3180 Decision 2) alongside the value.
|
|
@@ -1014,9 +1225,9 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
|
|
|
1014
1225
|
const statePath = node_path_1.default.join(planningDir(cwd, ws), 'STATE.md');
|
|
1015
1226
|
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
|
|
1016
1227
|
if (stateRaw !== null) {
|
|
1017
|
-
const
|
|
1018
|
-
if (
|
|
1019
|
-
const classified = classifyMilestoneScalar(
|
|
1228
|
+
const milestoneScalar = readStateMilestoneScalar(stateRaw);
|
|
1229
|
+
if (milestoneScalar !== null) {
|
|
1230
|
+
const classified = classifyMilestoneScalar(milestoneScalar);
|
|
1020
1231
|
version = classified.version;
|
|
1021
1232
|
stateAssertsNoMilestone = classified.explicitNull;
|
|
1022
1233
|
}
|
|
@@ -1067,26 +1278,8 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
|
|
|
1067
1278
|
}
|
|
1068
1279
|
catch { /* unresolvable convention → preserve the legacy fallback */ }
|
|
1069
1280
|
}
|
|
1070
|
-
if (headingMatches.length === 0
|
|
1071
|
-
|
|
1072
|
-
const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
|
|
1073
|
-
if (Number.isSafeInteger(milestoneInt)) {
|
|
1074
|
-
// #2761 M3: the phase-id owner supplies both the bracket intro grammar
|
|
1075
|
-
// and canonical pad2 spelling; `[CODE.2]` therefore cannot bound a
|
|
1076
|
-
// section whose phase headings the bracket grammar rejects.
|
|
1077
|
-
// #612 round-5: HeadingToken.offset is the line start, so require the
|
|
1078
|
-
// `#` there to preserve the old line-start anchor's indentation parity.
|
|
1079
|
-
// For every survivor, rebuilding [fullLine, fullLine] plus `.index`
|
|
1080
|
-
// retains the match shape and first-match order expected downstream.
|
|
1081
|
-
const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
|
|
1082
|
-
headingMatches = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)
|
|
1083
|
-
.filter((h) => h.level <= 3 && content[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text))
|
|
1084
|
-
.map((h) => {
|
|
1085
|
-
const lineEnd = content.indexOf('\n', h.offset);
|
|
1086
|
-
const fullLine = content.slice(h.offset, lineEnd === -1 ? content.length : lineEnd);
|
|
1087
|
-
return Object.assign([fullLine, fullLine], { index: h.offset });
|
|
1088
|
-
});
|
|
1089
|
-
}
|
|
1281
|
+
if (headingMatches.length === 0) {
|
|
1282
|
+
headingMatches = bracketFallbackHeadingMatches(content, version, bracketScopeConvention);
|
|
1090
1283
|
}
|
|
1091
1284
|
if (headingMatches.length === 0) {
|
|
1092
1285
|
const summaryMatch = content.match(summaryPattern);
|
|
@@ -1141,29 +1334,14 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
|
|
|
1141
1334
|
// #3184: selection collapses to the sole owner; `allMatches` is still needed
|
|
1142
1335
|
// below (offsets, detailsMatch search), so only the selection itself routes
|
|
1143
1336
|
// through `selectMilestoneHeading` rather than the whole block.
|
|
1144
|
-
|
|
1145
|
-
// bracket candidates above when a name-only bracket milestone carries no
|
|
1146
|
-
// version token for that selector to find.
|
|
1147
|
-
const selected = selectMilestoneHeading(content, version)
|
|
1148
|
-
?? allMatches.find((m) => !isClosed(m[1]))
|
|
1149
|
-
?? firstMatch;
|
|
1337
|
+
const selected = selectActiveMilestoneHeading(content, version, allMatches);
|
|
1150
1338
|
const sectionStart = selected.index;
|
|
1151
|
-
// #2761 B1: a bracket heading bearing the selected milestone's own id is
|
|
1152
|
-
// a continuation, not a boundary. Derive the selected id once and compose
|
|
1153
|
-
// the shared discriminator with upstream's centralized section-end walk.
|
|
1154
1339
|
const bracketBoundaryActive = bracketScopeConvention === 'bracket';
|
|
1155
|
-
const selectedBracketMatch = bracketBoundaryActive
|
|
1156
|
-
? selected[0].match(new RegExp(`^#{1,3}\\s+\\[(${BRACKET_ID_SRC})\\]`, 'i'))
|
|
1157
|
-
: null;
|
|
1158
|
-
const selectedBracketId = selectedBracketMatch ? foldBracketId(selectedBracketMatch[1]) : null;
|
|
1159
1340
|
// #2761 B3: tokenize once for both the centralized section-end owner and
|
|
1160
1341
|
// the bracket preamble scan below. This keeps both boundary decisions
|
|
1161
1342
|
// fence-aware without restoring the local section walker retired by #3184.
|
|
1162
1343
|
const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
1163
|
-
const bracketBoundary = bracketBoundaryActive
|
|
1164
|
-
? (heading) => isBracketMilestoneBoundary(heading.text, heading.level, selectedBracketId)
|
|
1165
|
-
: undefined;
|
|
1166
|
-
const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart, bracketBoundary, currentMilestoneHeadings);
|
|
1344
|
+
const { sectionEnd, selectedBracketId, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
|
|
1167
1345
|
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
|
|
1168
1346
|
let earliestMilestoneIndex;
|
|
1169
1347
|
if (!bracketBoundaryActive) {
|
|
@@ -1386,18 +1564,286 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
|
|
|
1386
1564
|
function extractCurrentMilestone(content, cwd, ws) {
|
|
1387
1565
|
return extractCurrentMilestoneScoped(content, cwd, ws).value;
|
|
1388
1566
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1567
|
+
/**
|
|
1568
|
+
* Same-length copy of `s` with HTML comments blanked. An unterminated `<!--`, or
|
|
1569
|
+
* one whose `-->` lies beyond the next heading, is bounded at the end of its own
|
|
1570
|
+
* line so a prose mention never hides later archives. Linear: `-->` and heading
|
|
1571
|
+
* lookups are cached while they stay ahead of the cursor.
|
|
1572
|
+
*/
|
|
1573
|
+
function blankComments(s) {
|
|
1574
|
+
const heading = /\n#{1,6} /g;
|
|
1575
|
+
let out = '';
|
|
1576
|
+
let i = 0;
|
|
1577
|
+
let close = 0;
|
|
1578
|
+
let head = -2; // -2: heading not looked up yet (-1 means none ahead); offset 0 must still trigger the first lookup
|
|
1579
|
+
for (let o = s.indexOf('<!--'); o !== -1; o = s.indexOf('<!--', i)) {
|
|
1580
|
+
if (close !== -1 && close < o + 4)
|
|
1581
|
+
close = s.indexOf('-->', o + 4);
|
|
1582
|
+
if (head !== -1 && head < o) {
|
|
1583
|
+
heading.lastIndex = o;
|
|
1584
|
+
const m = heading.exec(s);
|
|
1585
|
+
head = m ? m.index : -1;
|
|
1586
|
+
}
|
|
1587
|
+
let end;
|
|
1588
|
+
if (close !== -1 && (head === -1 || close < head))
|
|
1589
|
+
end = close + 3;
|
|
1590
|
+
else {
|
|
1591
|
+
end = s.indexOf('\n', o);
|
|
1592
|
+
if (end === -1)
|
|
1593
|
+
end = s.length;
|
|
1594
|
+
}
|
|
1595
|
+
out += s.slice(i, o) + s.slice(o, end).replace(/[^\r\n]/g, ' ');
|
|
1596
|
+
i = end;
|
|
1597
|
+
}
|
|
1598
|
+
return out + s.slice(i);
|
|
1599
|
+
}
|
|
1600
|
+
const VERSION_TOKEN = /v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i;
|
|
1601
|
+
/** Same-length copy of `content` with fenced blocks, inline code spans and HTML comments blanked. */
|
|
1602
|
+
function blankCodeAndComments(content) {
|
|
1603
|
+
// Same-length copy with fenced blocks, inline code spans and HTML comments
|
|
1604
|
+
// blanked (code first: a backticked `<!--` must not open a comment), so a `<details>` mentioned in prose or an example never opens or
|
|
1605
|
+
// closes a block.
|
|
1606
|
+
const lines = content.split('\n');
|
|
1607
|
+
const blank = (t) => t.replace(/[^\r\n]/g, ' ');
|
|
1608
|
+
for (const f of (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(lines)) {
|
|
1609
|
+
// An unclosed fence is bounded at the next heading, not EOF, so later archives stay visible.
|
|
1610
|
+
let last = f.closeLineIdx;
|
|
1611
|
+
if (last === -1) {
|
|
1612
|
+
last = lines.length - 1;
|
|
1613
|
+
for (let i = f.openLineIdx + 1; i < lines.length; i++)
|
|
1614
|
+
if (/^#{1,6} /.test(lines[i])) {
|
|
1615
|
+
last = i - 1;
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
for (let i = f.openLineIdx; i <= last; i++)
|
|
1620
|
+
lines[i] = blank(lines[i]);
|
|
1621
|
+
}
|
|
1622
|
+
const joined = lines.join('\n');
|
|
1623
|
+
let codeBlanked = '';
|
|
1624
|
+
let at = 0;
|
|
1625
|
+
for (const sp of (0, markdown_sectionizer_cjs_1.scanInlineCodeSpans)(joined)) {
|
|
1626
|
+
codeBlanked += joined.slice(at, sp.start) + blank(joined.slice(sp.start, sp.end));
|
|
1627
|
+
at = sp.end;
|
|
1628
|
+
}
|
|
1629
|
+
return blankComments(codeBlanked + joined.slice(at));
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* A closed milestone written as a plain heading section (no `<details>`) is archive too; the
|
|
1633
|
+
* active milestone's own `## v2.0 Part 2 ✅` sections are not.
|
|
1634
|
+
*/
|
|
1635
|
+
function plainClosedSections(content, overlapsActive, activeVersion) {
|
|
1636
|
+
// A closed milestone written as a plain heading section (no `<details>`) is
|
|
1637
|
+
// archive too. It ends at the next heading of the same or shallower level, so
|
|
1638
|
+
// a following `## Progress` table stays visible.
|
|
1639
|
+
const headingTokens = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
1640
|
+
const spans = [];
|
|
1641
|
+
for (let i = 0; i < headingTokens.length; i++) {
|
|
1642
|
+
const h = headingTokens[i];
|
|
1643
|
+
if (/^Phase\s+\S/i.test(h.text))
|
|
1644
|
+
continue;
|
|
1645
|
+
if (!isClosedMilestoneHeading(h.text) || extractMilestoneHeadingName(h.text) === null)
|
|
1646
|
+
continue;
|
|
1647
|
+
let next;
|
|
1648
|
+
for (let j = i + 1; j < headingTokens.length && !next; j++) {
|
|
1649
|
+
const t = headingTokens[j];
|
|
1650
|
+
if (t.level <= h.level && !/^Phase\s+\S/i.test(t.text))
|
|
1651
|
+
next = t;
|
|
1652
|
+
}
|
|
1653
|
+
// End before the newline that precedes `next`, so that heading stays at line start.
|
|
1654
|
+
const end = next ? next.offset - (content[next.offset - 2] === '\r' ? 2 : 1) : content.length;
|
|
1655
|
+
if (!overlapsActive(h.offset, end) && !(activeVersion && h.text.match(VERSION_TOKEN)?.[0].toLowerCase() === activeVersion))
|
|
1656
|
+
spans.push({ start: h.offset, end });
|
|
1657
|
+
}
|
|
1658
|
+
return spans;
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* #5553: locate every archive span in `content` (`<details>` blocks and plain closed-milestone
|
|
1662
|
+
* sections) that does not wrap the active milestone; `inActive` is the subset overlapping it.
|
|
1663
|
+
*/
|
|
1664
|
+
function scanArchiveSpans(content, active) {
|
|
1665
|
+
const activeVersion = active.length > 0 ? content.slice(active[0].start, active[0].start + 300).split('\n', 1)[0].match(VERSION_TOKEN)?.[0].toLowerCase() : undefined;
|
|
1666
|
+
// A block whose own <summary> marks a closed milestone is an archive wherever it sits.
|
|
1667
|
+
const closedSummary = (at) => {
|
|
1668
|
+
const m = /^<details\b[^>]{0,200}>[\s>]*<summary[^>]{0,200}>([^<]{0,300})<\/summary>/i.exec(content.slice(at, at + 800));
|
|
1669
|
+
return m !== null && isClosedMilestoneHeading(m[1]);
|
|
1670
|
+
};
|
|
1671
|
+
const scan = blankCodeAndComments(content);
|
|
1672
|
+
// Depth-aware: an outer block closes at its own `</details>`, so a nested
|
|
1673
|
+
// collapsed section inside an archive is masked with it.
|
|
1674
|
+
const spans = [];
|
|
1675
|
+
const inActive = [];
|
|
1676
|
+
const overlapsActive = (start, end) => active.some((r) => start < r.end && end > r.start);
|
|
1677
|
+
// Block-level tags only: a tag at line start (<=3 spaces, optionally inside a
|
|
1678
|
+
// blockquote `>` prefix) starts the scan of its line, and further tags on that
|
|
1679
|
+
// same line (a one-line `<details>...</details>`) count with it. Inline or
|
|
1680
|
+
// indented mentions elsewhere never count, and the bounded attribute run keeps
|
|
1681
|
+
// a hostile run of `<details ` linear.
|
|
1682
|
+
const lineStart = /^[ \t]{0,3}(?:>[ \t]{0,3})*(?=<\/?details\b)/gim;
|
|
1683
|
+
const anyTag = /<details\b[^>]{0,200}>|<\/details[ \t]*>/gi;
|
|
1684
|
+
const tags = [];
|
|
1685
|
+
for (let l = lineStart.exec(scan); l; l = lineStart.exec(scan)) {
|
|
1686
|
+
const from = l.index + l[0].length;
|
|
1687
|
+
const nl = scan.indexOf('\n', from);
|
|
1688
|
+
const lineEnd = nl === -1 ? scan.length : nl;
|
|
1689
|
+
anyTag.lastIndex = from;
|
|
1690
|
+
for (let t = anyTag.exec(scan); t && t.index < lineEnd; t = anyTag.exec(scan)) {
|
|
1691
|
+
tags.push({ at: t.index, len: t[0].length, closer: t[0][1] === '/' });
|
|
1692
|
+
}
|
|
1693
|
+
lineStart.lastIndex = Math.max(lineStart.lastIndex, lineEnd);
|
|
1694
|
+
}
|
|
1695
|
+
// Stack pairing: every closer matches its nearest opener, so each closed pair is
|
|
1696
|
+
// evaluated on its own (a nested archive inside an active wrapper is masked) and
|
|
1697
|
+
// an unmatched opener or closer is ignored instead of disabling later masking.
|
|
1698
|
+
const openers = [];
|
|
1699
|
+
const kept = [];
|
|
1700
|
+
let paired = false;
|
|
1701
|
+
let strayCloser = false;
|
|
1702
|
+
for (const tag of tags) {
|
|
1703
|
+
if (!tag.closer) {
|
|
1704
|
+
openers.push(tag.at);
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
const start = openers.pop();
|
|
1708
|
+
if (start === undefined) {
|
|
1709
|
+
// A closer with nothing to pair after an earlier pair closed: that pair may have swallowed this block's own opener.
|
|
1710
|
+
if (paired)
|
|
1711
|
+
strayCloser = true;
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
paired = true;
|
|
1715
|
+
const end = tag.at + tag.len;
|
|
1716
|
+
// Only a block wrapping an active heading stays editable; any other block is archive, with or without a closed <summary>.
|
|
1717
|
+
if (active.some((r) => start <= r.start && r.start < end))
|
|
1718
|
+
continue;
|
|
1719
|
+
const inRange = overlapsActive(start, end) && !closedSummary(start);
|
|
1720
|
+
// A collapsed block of the active range that holds the Progress table stays editable, so the row moves with the plan ticks.
|
|
1721
|
+
// A level-1 `# Progress` has always counted here (#5692: no behavior change), hence `levels`.
|
|
1722
|
+
if (inRange && (0, roadmap_progress_cjs_1.findProgressSection)(scan.slice(start, end), { levels: [1, 2] }) !== null) {
|
|
1723
|
+
kept.push({ start, end });
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
spans.push({ start, end });
|
|
1727
|
+
if (inRange)
|
|
1728
|
+
inActive.push({ start, end });
|
|
1729
|
+
}
|
|
1730
|
+
// An opener left unpaired makes the structure ambiguous when it either opens a
|
|
1731
|
+
// closed-summary archive that does not close on its own line (its extent is unknown) or precedes a masked
|
|
1732
|
+
// block in the active range (it may be that block's real wrapper, with the
|
|
1733
|
+
// stray tag inside). A harmless stray opener is ignored.
|
|
1734
|
+
const eol = (o) => { const e = content.indexOf('\n', o); return e === -1 ? content.length : e; };
|
|
1735
|
+
const ambiguous = strayCloser || openers.some((o) => closedSummary(o) && !/<\/details/i.test(content.slice(o, eol(o)))) || (openers.length > 0 && [...inActive, ...kept].some((r) => r.start > openers[0]));
|
|
1736
|
+
spans.push(...plainClosedSections(content, overlapsActive, activeVersion));
|
|
1737
|
+
return { spans, inActive, ambiguous };
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* #5679: the milestone version that owns `offset`. An offset inside a `<details>`
|
|
1741
|
+
* archive (or a plain closed-milestone section) belongs to that block's
|
|
1742
|
+
* `<summary>` (or heading) version, wherever the block sits relative to the
|
|
1743
|
+
* active heading; any other offset belongs to the last milestone heading before
|
|
1744
|
+
* it. Blocks come from `scanArchiveSpans` (fence/comment-aware, depth-paired),
|
|
1745
|
+
* innermost first, so a notes block inside an archive does not hide the archive.
|
|
1746
|
+
* Null when neither names a version.
|
|
1747
|
+
*/
|
|
1748
|
+
function milestoneVersionAtOffset(content, offset) {
|
|
1749
|
+
const containing = scanArchiveSpans(content, []).spans
|
|
1750
|
+
.filter((span) => span.start <= offset && offset < span.end)
|
|
1751
|
+
.sort((a, b) => b.start - a.start);
|
|
1752
|
+
for (const span of containing) {
|
|
1753
|
+
const head = content.slice(span.start, span.start + 1200);
|
|
1754
|
+
// Only a <details> span owns its <summary>; a plain closed-milestone section must not borrow a later block's.
|
|
1755
|
+
const summary = /^<details\b/i.test(head) ? /<summary[^>]*>([\s\S]{0,1000}?)<\/summary>/i.exec(head) : null;
|
|
1756
|
+
const text = summary ? summary[1].replace(/<[^>]{0,200}>/g, '') : head.split('\n', 1)[0];
|
|
1757
|
+
const version = text.match(VERSION_TOKEN)?.[0];
|
|
1758
|
+
if (version)
|
|
1759
|
+
return version;
|
|
1396
1760
|
}
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1761
|
+
const preceding = listMilestoneHeadings(content.slice(0, offset));
|
|
1762
|
+
return preceding.length > 0 ? preceding[preceding.length - 1].version : null;
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* #5553: hide every archived `<details>` block, and every closed milestone
|
|
1766
|
+
* written as a plain heading section, behind a placeholder; `restore` puts them
|
|
1767
|
+
* back unchanged. Tag case, attributes and archive position
|
|
1768
|
+
* (before OR after the active milestone) do not matter, unlike a "region after
|
|
1769
|
+
* the last `</details>`" cut. A block stays visible only when it wraps the
|
|
1770
|
+
* start of a resolved active milestone range (`<details open>` around the
|
|
1771
|
+
* active milestone); every other block is hidden, whatever its `<summary>`
|
|
1772
|
+
* (none, non-closed, closed), including a collapsed block inside the active
|
|
1773
|
+
* range. That is deliberate for THIS write path only; the read-side
|
|
1774
|
+
* `stripClosedMilestoneDetails` window (#3982/#1341) is unchanged. With no
|
|
1775
|
+
* resolvable active milestone every block is hidden, as the old
|
|
1776
|
+
* last-`</details>` cut did. `ambiguous` flags unpaired tags that hide where an
|
|
1777
|
+
* archive ends (the caller declines); `activeRange` is the active milestone's
|
|
1778
|
+
* range in `visible` coordinates.
|
|
1779
|
+
*/
|
|
1780
|
+
function maskArchivedDetails(content, cwd) {
|
|
1781
|
+
if (!/<details\b/i.test(content) && !MILESTONE_CLOSED_MARKER_PATTERN.test(content)) {
|
|
1782
|
+
return { visible: content, restore: (edited) => edited, hiddenInActive: '', ambiguous: false, activeRange: null };
|
|
1783
|
+
}
|
|
1784
|
+
const resolved = currentMilestoneRawRanges(content, cwd);
|
|
1785
|
+
// A stale STATE can name a milestone whose own heading is closed (all headings closed): nothing is active, so everything shipped stays masked.
|
|
1786
|
+
const ranges = resolved && !isClosedMilestoneHeading(content.slice(resolved.primary.start).split('\n', 1)[0]) ? resolved : null;
|
|
1787
|
+
const active = ranges ? [ranges.primary, ...(ranges.details ? [ranges.details] : [])] : [];
|
|
1788
|
+
const { spans, inActive, ambiguous } = scanArchiveSpans(content, active);
|
|
1789
|
+
const blocks = [];
|
|
1790
|
+
// Unguessable per call: a plan filename or any other edited-in text cannot forge a marker.
|
|
1791
|
+
const nonce = (0, node_crypto_1.randomBytes)(8).toString('hex');
|
|
1792
|
+
const marker = (i) => `<!-- gsd-archived-details-${nonce}-${i} -->`;
|
|
1793
|
+
spans.sort((x, y) => x.start - y.start || y.end - x.end);
|
|
1794
|
+
let visible = '';
|
|
1795
|
+
let last = 0;
|
|
1796
|
+
const hidden = [];
|
|
1797
|
+
for (const span of spans) {
|
|
1798
|
+
if (span.start < last) {
|
|
1799
|
+
// Nested in an already-masked span: only a partial overlap extends it.
|
|
1800
|
+
if (span.end <= last)
|
|
1801
|
+
continue;
|
|
1802
|
+
span.start = last;
|
|
1803
|
+
}
|
|
1804
|
+
blocks.push(content.slice(span.start, span.end));
|
|
1805
|
+
visible += content.slice(last, span.start);
|
|
1806
|
+
const m = marker(blocks.length - 1);
|
|
1807
|
+
hidden.push({ start: span.start, end: span.end, vis: visible.length, len: m.length });
|
|
1808
|
+
visible += m;
|
|
1809
|
+
last = span.end;
|
|
1810
|
+
}
|
|
1811
|
+
visible += content.slice(last);
|
|
1812
|
+
// One pass, so a hostile ROADMAP with thousands of blocks stays linear.
|
|
1813
|
+
const markerRe = new RegExp(`<!-- gsd-archived-details-${nonce}-(\\d+) -->`, 'g');
|
|
1814
|
+
const restore = (edited) => {
|
|
1815
|
+
const seen = new Array(blocks.length).fill(0);
|
|
1816
|
+
let bad = false;
|
|
1817
|
+
const restored = edited.replace(markerRe, (m, i) => {
|
|
1818
|
+
const n = Number(i);
|
|
1819
|
+
if (n >= blocks.length || seen[n]++ > 0) {
|
|
1820
|
+
bad = true;
|
|
1821
|
+
return m;
|
|
1822
|
+
}
|
|
1823
|
+
return blocks[n];
|
|
1824
|
+
});
|
|
1825
|
+
// edit dropped or duplicated a placeholder: caller must refuse rather than lose or misplace an archive
|
|
1826
|
+
return bad || seen.includes(0) ? null : restored;
|
|
1827
|
+
};
|
|
1828
|
+
// Blocks hidden inside the active range stay unticked, but their rows still count as present.
|
|
1829
|
+
const hiddenInActive = inActive.map((r) => content.slice(r.start, r.end)).join('\n');
|
|
1830
|
+
// Original offset -> offset in `visible`.
|
|
1831
|
+
const toVisible = (p) => {
|
|
1832
|
+
let h;
|
|
1833
|
+
for (const x of hidden) {
|
|
1834
|
+
if (x.start < p)
|
|
1835
|
+
h = x;
|
|
1836
|
+
else
|
|
1837
|
+
break;
|
|
1838
|
+
}
|
|
1839
|
+
if (!h)
|
|
1840
|
+
return p;
|
|
1841
|
+
return p >= h.end ? h.vis + h.len + (p - h.end) : h.vis;
|
|
1842
|
+
};
|
|
1843
|
+
const activeRange = active.length > 0
|
|
1844
|
+
? { start: toVisible(Math.min(...active.map((r) => r.start))), end: toVisible(Math.max(...active.map((r) => r.end))) }
|
|
1845
|
+
: null;
|
|
1846
|
+
return { visible, restore, hiddenInActive, ambiguous, activeRange };
|
|
1401
1847
|
}
|
|
1402
1848
|
/**
|
|
1403
1849
|
* Resolve a single phase's detail-section heading (`### Phase N: …`, any level
|
|
@@ -1431,11 +1877,21 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
|
|
|
1431
1877
|
* phase-heading level, so a level-bounded stop could fold a deeper sibling
|
|
1432
1878
|
* heading (e.g. a `####` phase following a `###` phase) into this phase's
|
|
1433
1879
|
* body and let `edit` reach into it.
|
|
1880
|
+
*
|
|
1881
|
+
* `levelBounded: true` instead keeps the phase's own non-phase `####` sub-sections
|
|
1882
|
+
* (a plan list under a sub-heading) in the body, still ending at the next heading
|
|
1883
|
+
* that opens another phase, of any level. Serves the presence check and the
|
|
1884
|
+
* plan-row tick writer.
|
|
1434
1885
|
*/
|
|
1435
|
-
function withPhaseSection(content, phaseId, edit) {
|
|
1886
|
+
function withPhaseSection(content, phaseId, edit, levelBounded = false) {
|
|
1436
1887
|
const src = phaseMarkdownRegexSource(phaseId);
|
|
1437
1888
|
const headingRe = new RegExp(`^\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${src}(?=[\\s:(]|$)`, 'i');
|
|
1438
|
-
|
|
1889
|
+
// levelBounded runs to the next same-or-higher heading, so cut at a deeper phase heading too.
|
|
1890
|
+
const ownBody = !levelBounded ? edit : (body) => {
|
|
1891
|
+
const cut = body.search(/^#{1,6}[ \t]+(?:\[[^\]\n]{1,200}\][ \t]*)?Phase\s+\S/m);
|
|
1892
|
+
return cut === -1 ? edit(body) : edit(body.slice(0, cut)) + body.slice(cut);
|
|
1893
|
+
};
|
|
1894
|
+
return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), ownBody, { levelBounded });
|
|
1439
1895
|
}
|
|
1440
1896
|
// ─── Roadmap phase lookup ─────────────────────────────────────────────────────
|
|
1441
1897
|
// #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
|
|
@@ -1443,11 +1899,11 @@ function withPhaseSection(content, phaseId, edit) {
|
|
|
1443
1899
|
// is captured in group 1, the name in group 2; the separator may be an em-dash,
|
|
1444
1900
|
// en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and
|
|
1445
1901
|
// to count phases in a milestone that uses the bullet form.
|
|
1446
|
-
const BULLET_PHASE_LINE_PATTERN =
|
|
1902
|
+
const BULLET_PHASE_LINE_PATTERN = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*(.+?)${phase_entry_grammar_cjs_1.PHASE_ENTRY_BOLD_CLOSE_SRC}`, 'im', { box: 'optional', bold: 'required' });
|
|
1447
1903
|
/** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */
|
|
1448
1904
|
function bulletPhaseLineFor(phaseNum, phaseSource) {
|
|
1449
1905
|
const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum);
|
|
1450
|
-
return
|
|
1906
|
+
return (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)${phase_entry_grammar_cjs_1.PHASE_ENTRY_BOLD_CLOSE_SRC}`, 'im', { box: 'optional', bold: 'required' });
|
|
1451
1907
|
}
|
|
1452
1908
|
function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
|
|
1453
1909
|
// #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
|
|
@@ -1588,39 +2044,6 @@ function reportUnreadableRoadmap(err, roadmapPath) {
|
|
|
1588
2044
|
return;
|
|
1589
2045
|
warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath });
|
|
1590
2046
|
}
|
|
1591
|
-
// ─── Roadmap progress table (#1956/#2012 decoy avoidance) ─────────────────────
|
|
1592
|
-
/**
|
|
1593
|
-
* Locate ROADMAP.md's "Progress" table — the sole owner of the #2012
|
|
1594
|
-
* decoy-avoidance scope for the `drift-guard phase-status` CLI seam (#1956).
|
|
1595
|
-
*
|
|
1596
|
-
* Scopes to the `## Progress` heading first (level-2, exact case-insensitive
|
|
1597
|
-
* text `'progress'`, `{ levelBounded: true }`) via `collectSection` — the
|
|
1598
|
-
* same CRLF-safe seam `stateCurrentPositionSlice` (state-document.cts) uses
|
|
1599
|
-
* to scope STATE.md's `## Current Position` — so a differently-headed table
|
|
1600
|
-
* that happens to share the same column names (e.g. an "Archive Notes"
|
|
1601
|
-
* table) is never picked up instead of the real one (#2012). Falls back to
|
|
1602
|
-
* scanning the WHOLE document when no `## Progress` heading exists, so a
|
|
1603
|
-
* headingless milestone slice (#1445) still resolves rather than going
|
|
1604
|
-
* uncheckable — the same fallback `deriveProgressFromRoadmap`
|
|
1605
|
-
* (phase-lifecycle.cts) deliberately preserves.
|
|
1606
|
-
*
|
|
1607
|
-
* `deriveProgressFromRoadmap` independently expresses this same "scope to
|
|
1608
|
-
* `## Progress`, else whole document" rule via its own regex-based scope
|
|
1609
|
-
* (kept there deliberately rather than refactored onto this function — its
|
|
1610
|
-
* blast radius is large). The two locators are therefore separate
|
|
1611
|
-
* implementations of the same scoping rule and must agree about WHICH table
|
|
1612
|
-
* is the Progress table; a parity test in
|
|
1613
|
-
* tests/adr-22-plan-drift-guard.test.cjs asserts they do, per the repo's
|
|
1614
|
-
* generative-fix-divergence guard.
|
|
1615
|
-
*
|
|
1616
|
-
* Returns the same shape `findTableWithColumns` returns (or `null`).
|
|
1617
|
-
*/
|
|
1618
|
-
function findRoadmapProgressTable(roadmapContent) {
|
|
1619
|
-
const isProgressHeading = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'progress';
|
|
1620
|
-
const section = (0, markdown_sectionizer_cjs_1.collectSection)(roadmapContent, isProgressHeading, { levelBounded: true });
|
|
1621
|
-
const scoped = section ? section.body : roadmapContent;
|
|
1622
|
-
return (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
|
|
1623
|
-
}
|
|
1624
2047
|
/**
|
|
1625
2048
|
* Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
|
|
1626
2049
|
* milestone-name capture. Markdown headings commonly take the shape
|
|
@@ -1777,9 +2200,9 @@ function getMilestoneInfo(cwd) {
|
|
|
1777
2200
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
1778
2201
|
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
|
|
1779
2202
|
if (stateRaw !== null) {
|
|
1780
|
-
const
|
|
1781
|
-
if (
|
|
1782
|
-
const classified = classifyMilestoneScalar(
|
|
2203
|
+
const milestoneScalar = readStateMilestoneScalar(stateRaw);
|
|
2204
|
+
if (milestoneScalar !== null) {
|
|
2205
|
+
const classified = classifyMilestoneScalar(milestoneScalar);
|
|
1783
2206
|
explicitlyNoMilestone = classified.explicitNull;
|
|
1784
2207
|
stateVersion = classified.version;
|
|
1785
2208
|
}
|
|
@@ -1913,7 +2336,7 @@ function getMilestoneInfo(cwd) {
|
|
|
1913
2336
|
* resolution exactly, including its `GSD_WORKSTREAM` env fallback — every
|
|
1914
2337
|
* pre-#2562 call site is unaffected.
|
|
1915
2338
|
*/
|
|
1916
|
-
function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
2339
|
+
function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws, context) {
|
|
1917
2340
|
const milestonePhaseNums = new Set();
|
|
1918
2341
|
// #612: the milestone-QUALIFIED form (`{CODE}.{MM}-{PP}`) of each in-scope
|
|
1919
2342
|
// bracket heading, kept in its OWN set — deliberately not in
|
|
@@ -2010,7 +2433,7 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
|
2010
2433
|
// #3262 remains the single owner of the phase-set scan. #612 extends its
|
|
2011
2434
|
// return with qualified bracket ids rather than restoring the superseded
|
|
2012
2435
|
// inline duplicate this commit was originally written against.
|
|
2013
|
-
const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention);
|
|
2436
|
+
const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention, context);
|
|
2014
2437
|
for (const id of scanned.ids)
|
|
2015
2438
|
milestonePhaseNums.add(id);
|
|
2016
2439
|
for (const qualified of scanned.qualifiedIds)
|
|
@@ -2064,7 +2487,11 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
|
2064
2487
|
? new RegExp(`^0*(\\d+[A-Za-z]?(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?)*(?:\\.\\d+)*)(?=-|$)`)
|
|
2065
2488
|
// phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
|
|
2066
2489
|
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
|
2490
|
+
const customKeys = context?.phaseNaming === 'custom' && headingConvention !== 'bracket'
|
|
2491
|
+
? new Set([...milestonePhaseNums].map(id => phaseIdModule.phaseKeyFromToken(id, context))) : null;
|
|
2067
2492
|
function isDirInMilestone(dirName) {
|
|
2493
|
+
if (customKeys)
|
|
2494
|
+
return customKeys.has(phaseIdModule.phaseKeyFromDir(dirName, headingConvention, context));
|
|
2068
2495
|
// #612: the DIR side of this filter, selected by the same convention the
|
|
2069
2496
|
// heading side selected. Without it the heading scan's new bracket reach was
|
|
2070
2497
|
// half a fix: `milestonePhaseNums` became non-empty, so the pass-all degrade
|
|
@@ -2153,27 +2580,31 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
|
2153
2580
|
* Details section) — both consume the same `locateMilestoneHeadings` /
|
|
2154
2581
|
* `computeMilestoneSectionEnd` owner (#3184), so there is no separate copy to
|
|
2155
2582
|
* keep in sync. Returns null when there is no versioned active milestone;
|
|
2156
|
-
* callers then fall back to
|
|
2583
|
+
* callers then fall back to the shipped-block-masked whole content
|
|
2584
|
+
* (`editOutsideShippedBlocks`) via `editCurrentMilestone`, or to unscoped
|
|
2585
|
+
* whole-content handling where they call this directly (`phase.cts`
|
|
2586
|
+
* `phaseEntryInsertOffset`).
|
|
2157
2587
|
*
|
|
2158
|
-
* #2761
|
|
2159
|
-
*
|
|
2160
|
-
*
|
|
2161
|
-
*
|
|
2162
|
-
*
|
|
2163
|
-
*
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2167
|
-
*
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2588
|
+
* #2761 / #4304: bracket-widened
|
|
2589
|
+
* in lockstep with the write path landing, per this doc's own prior note.
|
|
2590
|
+
* `phaseIdConvention` is the same additive, optional trailing parameter
|
|
2591
|
+
* `extractCurrentMilestoneScoped` accepts — omitted (every pre-#4304 2-arg
|
|
2592
|
+
* call site), it compiles byte-identically to before: `bracketFallbackHeadingMatches`
|
|
2593
|
+
* and `bracketAwareMilestoneSection`'s bracket branch are both no-ops unless
|
|
2594
|
+
* explicitly told `'bracket'`, so a version-string match still short-circuits
|
|
2595
|
+
* both, and a non-bracket/unresolved convention still returns null exactly as
|
|
2596
|
+
* it always has. Now shares its heading-selection and section-end grammar
|
|
2597
|
+
* with `extractCurrentMilestoneScoped` (`bracketFallbackHeadingMatches`,
|
|
2598
|
+
* `selectActiveMilestoneHeading`, `bracketAwareMilestoneSection`) instead of
|
|
2599
|
+
* carrying its own unwidened copy, so a version-less bracket milestone
|
|
2600
|
+
* heading (`## [GSD.09] Hidden`) bounds a section for this function's callers
|
|
2601
|
+
* too, not only for the read path.
|
|
2602
|
+
* Consumers: `editCurrentMilestone` (`phase complete`'s `mutateMilestonePhase`,
|
|
2603
|
+
* `roadmap update-plan-progress`) and `phase.cts` `phaseEntryInsertOffset`. On the null
|
|
2604
|
+
* path `editCurrentMilestone` edits the ROADMAP with closed-milestone `<details>`
|
|
2605
|
+
* blocks masked, so no writer reaches into a shipped block.
|
|
2175
2606
|
*/
|
|
2176
|
-
function currentMilestoneRawRanges(content, cwd) {
|
|
2607
|
+
function currentMilestoneRawRanges(content, cwd, phaseIdConvention) {
|
|
2177
2608
|
if (!cwd)
|
|
2178
2609
|
return null;
|
|
2179
2610
|
let version = null;
|
|
@@ -2182,9 +2613,9 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
2182
2613
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
2183
2614
|
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
|
|
2184
2615
|
if (stateRaw !== null) {
|
|
2185
|
-
const
|
|
2186
|
-
if (
|
|
2187
|
-
const classified = classifyMilestoneScalar(
|
|
2616
|
+
const milestoneScalar = readStateMilestoneScalar(stateRaw);
|
|
2617
|
+
if (milestoneScalar !== null) {
|
|
2618
|
+
const classified = classifyMilestoneScalar(milestoneScalar);
|
|
2188
2619
|
version = classified.version;
|
|
2189
2620
|
stateAssertsNoMilestone = classified.explicitNull;
|
|
2190
2621
|
}
|
|
@@ -2198,15 +2629,21 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
2198
2629
|
}
|
|
2199
2630
|
if (!version)
|
|
2200
2631
|
return null;
|
|
2201
|
-
const
|
|
2632
|
+
const bracketScopeConvention = phaseIdConvention ?? null;
|
|
2633
|
+
let headingMatches = locateMilestoneHeadings(content, version);
|
|
2634
|
+
if (headingMatches.length === 0) {
|
|
2635
|
+
headingMatches = bracketFallbackHeadingMatches(content, version, bracketScopeConvention);
|
|
2636
|
+
}
|
|
2202
2637
|
if (headingMatches.length === 0)
|
|
2203
2638
|
return null;
|
|
2204
2639
|
const isClosed = isClosedMilestoneHeading;
|
|
2205
2640
|
// #3184: selection collapses to the sole owner; `headingMatches` is still
|
|
2206
2641
|
// needed below for the detailsMatch search over all headings.
|
|
2207
|
-
const selected =
|
|
2642
|
+
const selected = selectActiveMilestoneHeading(content, version, headingMatches);
|
|
2208
2643
|
const sectionStart = selected.index ?? 0;
|
|
2209
|
-
const
|
|
2644
|
+
const bracketBoundaryActive = bracketScopeConvention === 'bracket';
|
|
2645
|
+
const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
2646
|
+
const { sectionEnd, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
|
|
2210
2647
|
const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
|
|
2211
2648
|
const detailsVersionBoundary = selectedVersionToken
|
|
2212
2649
|
? new RegExp(`${(0, pattern_cjs_1.escapeRegex)(selectedVersionToken)}(?![\\w.-])`, 'i')
|
|
@@ -2218,10 +2655,160 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
2218
2655
|
let details = null;
|
|
2219
2656
|
if (detailsMatch) {
|
|
2220
2657
|
const detailsStart = detailsMatch.index ?? 0;
|
|
2221
|
-
|
|
2658
|
+
// #4304: pass the SAME bracketBoundary (and heading tokens)
|
|
2659
|
+
// bracketAwareMilestoneSection already derived for the primary range —
|
|
2660
|
+
// extractCurrentMilestoneScoped's own details lookup (line ~1312) has
|
|
2661
|
+
// always done this; this copy omitted both trailing arguments, so a
|
|
2662
|
+
// version-less bracket milestone heading (no v\d+.\d+/emoji marker) never
|
|
2663
|
+
// stopped this details window at the NEXT milestone's own "(Phase
|
|
2664
|
+
// Details)" section, and the two copies silently disagreed on the active
|
|
2665
|
+
// details window.
|
|
2666
|
+
details = {
|
|
2667
|
+
start: detailsStart,
|
|
2668
|
+
end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart, bracketBoundary, currentMilestoneHeadings),
|
|
2669
|
+
};
|
|
2222
2670
|
}
|
|
2223
2671
|
return { primary: { start: sectionStart, end: sectionEnd }, details };
|
|
2224
2672
|
}
|
|
2673
|
+
/** `<details>` / `</details>` tags of `content` in document order (bare opener only: `<details open>` wraps the active milestone, it is not an archive). */
|
|
2674
|
+
function bareDetailsTags(content) {
|
|
2675
|
+
return [...content.matchAll(/<(\/?)details>/gi)].map((m) => ({ at: m.index, close: m[1] === '/' }));
|
|
2676
|
+
}
|
|
2677
|
+
/** Whether offset `idx` sits inside a bare `<details>` block that has not closed yet (`tags` from `bareDetailsTags`). */
|
|
2678
|
+
function insideBareDetails(tags, idx) {
|
|
2679
|
+
let lo = 0;
|
|
2680
|
+
let hi = tags.length;
|
|
2681
|
+
while (lo < hi) {
|
|
2682
|
+
const mid = (lo + hi) >> 1;
|
|
2683
|
+
if (tags[mid].at < idx)
|
|
2684
|
+
lo = mid + 1;
|
|
2685
|
+
else
|
|
2686
|
+
hi = mid;
|
|
2687
|
+
}
|
|
2688
|
+
return lo > 0 && !tags[lo - 1].close;
|
|
2689
|
+
}
|
|
2690
|
+
/** A phase heading or a bold phase-list entry: what makes a block a shipped/backlog milestone. */
|
|
2691
|
+
const ACTIVE_SCOPE_PHASE_ENTRY_SRC = `(?:^#{2,4}[ \\t]+Phase\\b|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ bold: 'required' })}Phase\\b)`;
|
|
2692
|
+
/**
|
|
2693
|
+
* The active milestone's writable scope as offsets into `content`.
|
|
2694
|
+
*
|
|
2695
|
+
* `start` is just past the last `</details>` that closes BEFORE the selected
|
|
2696
|
+
* milestone heading, so a backlog `</details>` AFTER it no longer hides it
|
|
2697
|
+
* (#5221) while phase/Progress content above the heading (#2947 preamble layout)
|
|
2698
|
+
* stays in scope. No resolved heading, or one that is itself closed (stale
|
|
2699
|
+
* STATE), uses the last `</details>` before the first phase entry outside any
|
|
2700
|
+
* open <details>, so a backlog `</details>` below the active phases does not
|
|
2701
|
+
* hide them.
|
|
2702
|
+
*
|
|
2703
|
+
* `end` is the first line-leading bare `<details>` AFTER that heading (or first
|
|
2704
|
+
* phase entry): a shipped block placed below the active milestone is never
|
|
2705
|
+
* writable, so a second run cannot tick the archived phase once the active one
|
|
2706
|
+
* is done. Writers that must reach a bottom `## Progress` past a backlog block
|
|
2707
|
+
* (`editProgressTableSlice`) use `start` alone. Tags match case-insensitively.
|
|
2708
|
+
* An archive whose `</details>` is missing cannot be told from a wrapper, so
|
|
2709
|
+
* `start` stays 0 for it (documented fail-open).
|
|
2710
|
+
*/
|
|
2711
|
+
function activeScope(content, cwd) {
|
|
2712
|
+
const tags = bareDetailsTags(content);
|
|
2713
|
+
const headingAt = currentMilestoneRawRanges(content, cwd)?.primary.start;
|
|
2714
|
+
const open = headingAt !== undefined && !isClosedMilestoneHeading(content.slice(headingAt).split('\n', 1)[0]);
|
|
2715
|
+
let anchor;
|
|
2716
|
+
if (open) {
|
|
2717
|
+
anchor = headingAt;
|
|
2718
|
+
}
|
|
2719
|
+
else {
|
|
2720
|
+
const firstActive = [...content.matchAll(new RegExp(ACTIVE_SCOPE_PHASE_ENTRY_SRC, 'gim'))]
|
|
2721
|
+
.find((m) => !insideBareDetails(tags, m.index));
|
|
2722
|
+
anchor = firstActive?.index ?? content.length;
|
|
2723
|
+
}
|
|
2724
|
+
let start = 0;
|
|
2725
|
+
for (const t of tags) {
|
|
2726
|
+
if (t.at >= anchor)
|
|
2727
|
+
break;
|
|
2728
|
+
if (t.close)
|
|
2729
|
+
start = t.at + '</details>'.length;
|
|
2730
|
+
}
|
|
2731
|
+
// Only a block that carries phase entries is a shipped/backlog milestone; a collapsible note inside the active milestone stays writable.
|
|
2732
|
+
const phaseEntry = new RegExp(ACTIVE_SCOPE_PHASE_ENTRY_SRC, 'im');
|
|
2733
|
+
const below = tags.find((t, i) => {
|
|
2734
|
+
if (t.close || t.at <= anchor || !/^[ \t]*$/.test(content.slice(content.lastIndexOf('\n', t.at - 1) + 1, t.at)))
|
|
2735
|
+
return false;
|
|
2736
|
+
const close = tags.slice(i + 1).find((c) => c.close);
|
|
2737
|
+
return phaseEntry.test(content.slice(t.at, close ? close.at : content.length));
|
|
2738
|
+
});
|
|
2739
|
+
return { start, end: below ? below.at : content.length };
|
|
2740
|
+
}
|
|
2741
|
+
function activeScopeStart(content, cwd) {
|
|
2742
|
+
return activeScope(content, cwd).start;
|
|
2743
|
+
}
|
|
2744
|
+
/** [start,end) windows of the current milestone, document order; the whole ROADMAP when none resolves. */
|
|
2745
|
+
function currentMilestoneWindows(content, cwd) {
|
|
2746
|
+
const ranges = currentMilestoneRawRanges(content, cwd);
|
|
2747
|
+
if (!ranges)
|
|
2748
|
+
return [{ start: 0, end: content.length }];
|
|
2749
|
+
// `details` always starts at or after `primary.end` (see `currentMilestoneRawRanges`),
|
|
2750
|
+
// so this is document order: a "first window wins" caller means the primary one.
|
|
2751
|
+
return [ranges.primary, ranges.details].filter((w) => w !== null);
|
|
2752
|
+
}
|
|
2753
|
+
/**
|
|
2754
|
+
* Read-only counterpart of `editCurrentMilestone`: the text of each window,
|
|
2755
|
+
* with closed-milestone `<details>` blocks hidden exactly as the writers see it.
|
|
2756
|
+
*/
|
|
2757
|
+
function readCurrentMilestone(content, cwd) {
|
|
2758
|
+
return currentMilestoneWindows(content, cwd).map((w) => maskShippedBlocks(content.slice(w.start, w.end)).masked);
|
|
2759
|
+
}
|
|
2760
|
+
/**
|
|
2761
|
+
* Run `edit` over the current milestone's raw window(s) from
|
|
2762
|
+
* `currentMilestoneRawRanges`, or, when no milestone resolves, over the whole
|
|
2763
|
+
* ROADMAP, each with closed-milestone `<details>` blocks hidden (#5543;
|
|
2764
|
+
* `editOutsideShippedBlocks`: a notes or phase-wrapper block stays editable).
|
|
2765
|
+
* `edit` is called once per window, in document order. The one milestone
|
|
2766
|
+
* scoping for per-phase writers (`phase complete`, `roadmap
|
|
2767
|
+
* update-plan-progress` #5483), so neither cuts at the last `</details>`.
|
|
2768
|
+
*/
|
|
2769
|
+
function editCurrentMilestone(content, cwd, edit) {
|
|
2770
|
+
const windows = currentMilestoneWindows(content, cwd);
|
|
2771
|
+
const edited = windows.map((w) => editOutsideShippedBlocks(content.slice(w.start, w.end), edit));
|
|
2772
|
+
// Splice last-first so an earlier window's offsets are not shifted by a
|
|
2773
|
+
// length-changing mutation in a later window.
|
|
2774
|
+
return windows.reduceRight((acc, w, i) => acc.slice(0, w.start) + edited[i] + acc.slice(w.end), content);
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* The checklist bullet of the phase whose number matches `phasePattern`
|
|
2778
|
+
* (`- [ ] **Phase N: …**`), as the one grammar shared by the Plans-count /
|
|
2779
|
+
* completion stamp and the plan-row ticks. `anyState` also matches a checked box.
|
|
2780
|
+
* Group 1 is everything through the opening `[` (indent and marker included),
|
|
2781
|
+
* group 2 the rest of the line from the closing `]`. The prefix (marker, box, bold token) is the phase-entry grammar's (#5764); the
|
|
2782
|
+
* tail stops before `\r` so a stamp appended to group 2 lands on the entry's
|
|
2783
|
+
* line, before its CRLF.
|
|
2784
|
+
*/
|
|
2785
|
+
function phaseChecklistBullet(phasePattern, anyState) {
|
|
2786
|
+
return (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(`Phase[ \\t]+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s]|$)[^\\r\\n]*`, 'i', { state: anyState ? 'any' : 'unchecked' });
|
|
2787
|
+
}
|
|
2788
|
+
/** A checklist bullet of ANY phase: ends the rows nested under the previous one. */
|
|
2789
|
+
const ANY_PHASE_BULLET = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)('Phase[ \\t]', 'i');
|
|
2790
|
+
/**
|
|
2791
|
+
* [start,end) of the rows nested under a phase's checklist bullet: from the
|
|
2792
|
+
* line after `bullet` (a match of `phaseChecklistBullet` in `text`) up to a
|
|
2793
|
+
* blank line, a shallower line, or the next phase's bullet. Empty when the
|
|
2794
|
+
* bullet is the last line with no trailing newline.
|
|
2795
|
+
*/
|
|
2796
|
+
function checklistBulletExtent(text, bullet) {
|
|
2797
|
+
// The bullet's own indent, read from its line: a caller's capture group 1 may
|
|
2798
|
+
// be the indent alone (which has no non-space and used to give -1, so a
|
|
2799
|
+
// shallower line never ended the extent) or everything through the `[`.
|
|
2800
|
+
const depth = text.slice(bullet.index).search(/\S/);
|
|
2801
|
+
const start = text.indexOf('\n', bullet.index) + 1;
|
|
2802
|
+
let end = start;
|
|
2803
|
+
while (start > 0 && end < text.length) {
|
|
2804
|
+
const next = text.indexOf('\n', end);
|
|
2805
|
+
const line = text.slice(end, next === -1 ? text.length : next);
|
|
2806
|
+
if (line.trim() === '' || line.search(/\S/) < depth || ANY_PHASE_BULLET.test(line))
|
|
2807
|
+
break;
|
|
2808
|
+
end = next === -1 ? text.length : next + 1;
|
|
2809
|
+
}
|
|
2810
|
+
return { start, end };
|
|
2811
|
+
}
|
|
2225
2812
|
/**
|
|
2226
2813
|
* #4731 — extract a bold-labeled phase field (Goal, Requirements, ...) reading
|
|
2227
2814
|
* past hard-wrapped continuation lines. The roadmapper soft-wraps long fields
|
|
@@ -2286,22 +2873,32 @@ function extractPhaseFieldMultiline(section, label) {
|
|
|
2286
2873
|
return [firstLine, ...contLines].join(' ').trim() || null;
|
|
2287
2874
|
}
|
|
2288
2875
|
module.exports = {
|
|
2876
|
+
activeScope,
|
|
2877
|
+
activeScopeStart,
|
|
2289
2878
|
stripShippedMilestones,
|
|
2290
2879
|
extractCurrentMilestone,
|
|
2291
2880
|
extractCurrentMilestoneScoped,
|
|
2292
2881
|
isMilestoneShippedInRoadmap,
|
|
2293
2882
|
isMilestoneBoundedInRoadmap,
|
|
2294
|
-
|
|
2883
|
+
maskArchivedDetails,
|
|
2295
2884
|
getRoadmapPhaseInternal,
|
|
2296
2885
|
getMilestoneInfo,
|
|
2297
2886
|
classifyMilestoneScalar,
|
|
2887
|
+
readStateMilestoneScalar,
|
|
2298
2888
|
getMilestonePhaseFilter,
|
|
2299
2889
|
currentMilestoneRawRanges,
|
|
2300
2890
|
withPhaseSection,
|
|
2301
2891
|
editOutsideShippedBlocks,
|
|
2892
|
+
editCurrentMilestone,
|
|
2893
|
+
readCurrentMilestone,
|
|
2894
|
+
phaseChecklistBullet,
|
|
2895
|
+
checklistBulletExtent,
|
|
2302
2896
|
computeMilestoneSectionEnd,
|
|
2303
2897
|
locateMilestoneHeadings,
|
|
2304
2898
|
listMilestoneHeadings,
|
|
2899
|
+
// #4304: historical mutation guards consume the window locator's
|
|
2900
|
+
// milestone-vs-phase decision before applying CLOSED markers.
|
|
2901
|
+
isRecognizedMilestoneHeading,
|
|
2305
2902
|
selectMilestoneHeading,
|
|
2306
2903
|
classifyMilestoneWindow,
|
|
2307
2904
|
// #3184: the sole "give me this version's window" composition — see its
|
|
@@ -2313,9 +2910,6 @@ module.exports = {
|
|
|
2313
2910
|
hasMilestoneSectioning,
|
|
2314
2911
|
// #3642: the >=1 sibling buildStateFrontmatter's unbounded branch consumes.
|
|
2315
2912
|
hasAnyMilestoneSection,
|
|
2316
|
-
// #1956: sole owner of the #2012 decoy-avoidance scope for the
|
|
2317
|
-
// `drift-guard phase-status` CLI seam.
|
|
2318
|
-
findRoadmapProgressTable,
|
|
2319
2913
|
// #3262 (write-time milestone-scope guard): the window phase-id scan owner
|
|
2320
2914
|
// (consumed by getMilestonePhaseFilter above and the roadmap milestone-scope
|
|
2321
2915
|
// CLI probe) and the free-text predicate the phase add/add-batch/insert
|
|
@@ -2329,6 +2923,27 @@ module.exports = {
|
|
|
2329
2923
|
// owner (and its convention gate) instead of a private inline copy.
|
|
2330
2924
|
extractPhaseFieldMultiline,
|
|
2331
2925
|
hasPhaseEntries,
|
|
2926
|
+
isPhaseEntryHeading,
|
|
2927
|
+
// #4304: exported so `phase.cts`'s milestone-marker
|
|
2928
|
+
// enumeration (bracketRecognizedMilestoneMarkers) can recognize a
|
|
2929
|
+
// version-less bracket milestone heading through the SAME grammar the
|
|
2930
|
+
// window locator (bracketAwareMilestoneSection) already uses to decide
|
|
2931
|
+
// section boundaries, instead of a narrower, independently-typed copy.
|
|
2932
|
+
isBracketMilestoneBoundary,
|
|
2933
|
+
bracketFallbackHeadingMatches,
|
|
2934
|
+
// #4304: exported so `phase.cts`'s pre-mutation window guard
|
|
2935
|
+
// (bracketOwnedLineOutsideActiveWindow) can recognize a CLOSED/shipped
|
|
2936
|
+
// milestone heading through the SAME predicate `currentMilestoneRawRanges`
|
|
2937
|
+
// itself already uses to skip a closed heading when selecting the active
|
|
2938
|
+
// one, instead of a re-typed copy of MILESTONE_CLOSED_MARKER_PATTERN.
|
|
2939
|
+
isClosedMilestoneHeading,
|
|
2940
|
+
// #4304: destructive ROADMAP writers classify details blocks through
|
|
2941
|
+
// the same closed-summary rule as stripClosedMilestoneDetails.
|
|
2942
|
+
isClosedMilestoneDetails,
|
|
2943
|
+
// #5679: the owning milestone version of a ROADMAP offset, archive-aware.
|
|
2944
|
+
milestoneVersionAtOffset,
|
|
2945
|
+
// #5679: same-length copy with fenced blocks, code spans and HTML comments blanked.
|
|
2946
|
+
blankCodeAndComments,
|
|
2332
2947
|
// #4144 round 5 Blocker 1: the readers' own phase-heading grammar, so the
|
|
2333
2948
|
// bracket roadmap migrator's "phase-like but unparsed" refusal
|
|
2334
2949
|
// (src/roadmap-upgrade.cts) can gate on it instead of a private, looser
|