@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
|
@@ -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,261 @@ 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
|
+
* #5553: hide every archived `<details>` block, and every closed milestone
|
|
1741
|
+
* written as a plain heading section, behind a placeholder; `restore` puts them
|
|
1742
|
+
* back unchanged. Tag case, attributes and archive position
|
|
1743
|
+
* (before OR after the active milestone) do not matter, unlike a "region after
|
|
1744
|
+
* the last `</details>`" cut. A block stays visible only when it wraps the
|
|
1745
|
+
* start of a resolved active milestone range (`<details open>` around the
|
|
1746
|
+
* active milestone); every other block is hidden, whatever its `<summary>`
|
|
1747
|
+
* (none, non-closed, closed), including a collapsed block inside the active
|
|
1748
|
+
* range. That is deliberate for THIS write path only; the read-side
|
|
1749
|
+
* `stripClosedMilestoneDetails` window (#3982/#1341) is unchanged. With no
|
|
1750
|
+
* resolvable active milestone every block is hidden, as the old
|
|
1751
|
+
* last-`</details>` cut did. `ambiguous` flags unpaired tags that hide where an
|
|
1752
|
+
* archive ends (the caller declines); `activeRange` is the active milestone's
|
|
1753
|
+
* range in `visible` coordinates.
|
|
1754
|
+
*/
|
|
1755
|
+
function maskArchivedDetails(content, cwd) {
|
|
1756
|
+
if (!/<details\b/i.test(content) && !MILESTONE_CLOSED_MARKER_PATTERN.test(content)) {
|
|
1757
|
+
return { visible: content, restore: (edited) => edited, hiddenInActive: '', ambiguous: false, activeRange: null };
|
|
1758
|
+
}
|
|
1759
|
+
const resolved = currentMilestoneRawRanges(content, cwd);
|
|
1760
|
+
// A stale STATE can name a milestone whose own heading is closed (all headings closed): nothing is active, so everything shipped stays masked.
|
|
1761
|
+
const ranges = resolved && !isClosedMilestoneHeading(content.slice(resolved.primary.start).split('\n', 1)[0]) ? resolved : null;
|
|
1762
|
+
const active = ranges ? [ranges.primary, ...(ranges.details ? [ranges.details] : [])] : [];
|
|
1763
|
+
const { spans, inActive, ambiguous } = scanArchiveSpans(content, active);
|
|
1764
|
+
const blocks = [];
|
|
1765
|
+
// Unguessable per call: a plan filename or any other edited-in text cannot forge a marker.
|
|
1766
|
+
const nonce = (0, node_crypto_1.randomBytes)(8).toString('hex');
|
|
1767
|
+
const marker = (i) => `<!-- gsd-archived-details-${nonce}-${i} -->`;
|
|
1768
|
+
spans.sort((x, y) => x.start - y.start || y.end - x.end);
|
|
1769
|
+
let visible = '';
|
|
1770
|
+
let last = 0;
|
|
1771
|
+
const hidden = [];
|
|
1772
|
+
for (const span of spans) {
|
|
1773
|
+
if (span.start < last) {
|
|
1774
|
+
// Nested in an already-masked span: only a partial overlap extends it.
|
|
1775
|
+
if (span.end <= last)
|
|
1776
|
+
continue;
|
|
1777
|
+
span.start = last;
|
|
1778
|
+
}
|
|
1779
|
+
blocks.push(content.slice(span.start, span.end));
|
|
1780
|
+
visible += content.slice(last, span.start);
|
|
1781
|
+
const m = marker(blocks.length - 1);
|
|
1782
|
+
hidden.push({ start: span.start, end: span.end, vis: visible.length, len: m.length });
|
|
1783
|
+
visible += m;
|
|
1784
|
+
last = span.end;
|
|
1396
1785
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1786
|
+
visible += content.slice(last);
|
|
1787
|
+
// One pass, so a hostile ROADMAP with thousands of blocks stays linear.
|
|
1788
|
+
const markerRe = new RegExp(`<!-- gsd-archived-details-${nonce}-(\\d+) -->`, 'g');
|
|
1789
|
+
const restore = (edited) => {
|
|
1790
|
+
const seen = new Array(blocks.length).fill(0);
|
|
1791
|
+
let bad = false;
|
|
1792
|
+
const restored = edited.replace(markerRe, (m, i) => {
|
|
1793
|
+
const n = Number(i);
|
|
1794
|
+
if (n >= blocks.length || seen[n]++ > 0) {
|
|
1795
|
+
bad = true;
|
|
1796
|
+
return m;
|
|
1797
|
+
}
|
|
1798
|
+
return blocks[n];
|
|
1799
|
+
});
|
|
1800
|
+
// edit dropped or duplicated a placeholder: caller must refuse rather than lose or misplace an archive
|
|
1801
|
+
return bad || seen.includes(0) ? null : restored;
|
|
1802
|
+
};
|
|
1803
|
+
// Blocks hidden inside the active range stay unticked, but their rows still count as present.
|
|
1804
|
+
const hiddenInActive = inActive.map((r) => content.slice(r.start, r.end)).join('\n');
|
|
1805
|
+
// Original offset -> offset in `visible`.
|
|
1806
|
+
const toVisible = (p) => {
|
|
1807
|
+
let h;
|
|
1808
|
+
for (const x of hidden) {
|
|
1809
|
+
if (x.start < p)
|
|
1810
|
+
h = x;
|
|
1811
|
+
else
|
|
1812
|
+
break;
|
|
1813
|
+
}
|
|
1814
|
+
if (!h)
|
|
1815
|
+
return p;
|
|
1816
|
+
return p >= h.end ? h.vis + h.len + (p - h.end) : h.vis;
|
|
1817
|
+
};
|
|
1818
|
+
const activeRange = active.length > 0
|
|
1819
|
+
? { start: toVisible(Math.min(...active.map((r) => r.start))), end: toVisible(Math.max(...active.map((r) => r.end))) }
|
|
1820
|
+
: null;
|
|
1821
|
+
return { visible, restore, hiddenInActive, ambiguous, activeRange };
|
|
1401
1822
|
}
|
|
1402
1823
|
/**
|
|
1403
1824
|
* Resolve a single phase's detail-section heading (`### Phase N: …`, any level
|
|
@@ -1431,11 +1852,21 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
|
|
|
1431
1852
|
* phase-heading level, so a level-bounded stop could fold a deeper sibling
|
|
1432
1853
|
* heading (e.g. a `####` phase following a `###` phase) into this phase's
|
|
1433
1854
|
* body and let `edit` reach into it.
|
|
1855
|
+
*
|
|
1856
|
+
* `levelBounded: true` instead keeps the phase's own non-phase `####` sub-sections
|
|
1857
|
+
* (a plan list under a sub-heading) in the body, still ending at the next heading
|
|
1858
|
+
* that opens another phase, of any level. Serves the presence check and the
|
|
1859
|
+
* plan-row tick writer.
|
|
1434
1860
|
*/
|
|
1435
|
-
function withPhaseSection(content, phaseId, edit) {
|
|
1861
|
+
function withPhaseSection(content, phaseId, edit, levelBounded = false) {
|
|
1436
1862
|
const src = phaseMarkdownRegexSource(phaseId);
|
|
1437
1863
|
const headingRe = new RegExp(`^\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${src}(?=[\\s:(]|$)`, 'i');
|
|
1438
|
-
|
|
1864
|
+
// levelBounded runs to the next same-or-higher heading, so cut at a deeper phase heading too.
|
|
1865
|
+
const ownBody = !levelBounded ? edit : (body) => {
|
|
1866
|
+
const cut = body.search(/^#{1,6}[ \t]+(?:\[[^\]\n]{1,200}\][ \t]*)?Phase\s+\S/m);
|
|
1867
|
+
return cut === -1 ? edit(body) : edit(body.slice(0, cut)) + body.slice(cut);
|
|
1868
|
+
};
|
|
1869
|
+
return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), ownBody, { levelBounded });
|
|
1439
1870
|
}
|
|
1440
1871
|
// ─── Roadmap phase lookup ─────────────────────────────────────────────────────
|
|
1441
1872
|
// #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
|
|
@@ -1443,11 +1874,11 @@ function withPhaseSection(content, phaseId, edit) {
|
|
|
1443
1874
|
// is captured in group 1, the name in group 2; the separator may be an em-dash,
|
|
1444
1875
|
// en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and
|
|
1445
1876
|
// to count phases in a milestone that uses the bullet form.
|
|
1446
|
-
const BULLET_PHASE_LINE_PATTERN =
|
|
1877
|
+
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
1878
|
/** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */
|
|
1448
1879
|
function bulletPhaseLineFor(phaseNum, phaseSource) {
|
|
1449
1880
|
const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum);
|
|
1450
|
-
return
|
|
1881
|
+
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
1882
|
}
|
|
1452
1883
|
function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
|
|
1453
1884
|
// #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
|
|
@@ -1588,39 +2019,6 @@ function reportUnreadableRoadmap(err, roadmapPath) {
|
|
|
1588
2019
|
return;
|
|
1589
2020
|
warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath });
|
|
1590
2021
|
}
|
|
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
2022
|
/**
|
|
1625
2023
|
* Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
|
|
1626
2024
|
* milestone-name capture. Markdown headings commonly take the shape
|
|
@@ -1777,9 +2175,9 @@ function getMilestoneInfo(cwd) {
|
|
|
1777
2175
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
1778
2176
|
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
|
|
1779
2177
|
if (stateRaw !== null) {
|
|
1780
|
-
const
|
|
1781
|
-
if (
|
|
1782
|
-
const classified = classifyMilestoneScalar(
|
|
2178
|
+
const milestoneScalar = readStateMilestoneScalar(stateRaw);
|
|
2179
|
+
if (milestoneScalar !== null) {
|
|
2180
|
+
const classified = classifyMilestoneScalar(milestoneScalar);
|
|
1783
2181
|
explicitlyNoMilestone = classified.explicitNull;
|
|
1784
2182
|
stateVersion = classified.version;
|
|
1785
2183
|
}
|
|
@@ -1913,7 +2311,7 @@ function getMilestoneInfo(cwd) {
|
|
|
1913
2311
|
* resolution exactly, including its `GSD_WORKSTREAM` env fallback — every
|
|
1914
2312
|
* pre-#2562 call site is unaffected.
|
|
1915
2313
|
*/
|
|
1916
|
-
function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
2314
|
+
function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws, context) {
|
|
1917
2315
|
const milestonePhaseNums = new Set();
|
|
1918
2316
|
// #612: the milestone-QUALIFIED form (`{CODE}.{MM}-{PP}`) of each in-scope
|
|
1919
2317
|
// bracket heading, kept in its OWN set — deliberately not in
|
|
@@ -2010,7 +2408,7 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
|
2010
2408
|
// #3262 remains the single owner of the phase-set scan. #612 extends its
|
|
2011
2409
|
// return with qualified bracket ids rather than restoring the superseded
|
|
2012
2410
|
// inline duplicate this commit was originally written against.
|
|
2013
|
-
const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention);
|
|
2411
|
+
const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention, context);
|
|
2014
2412
|
for (const id of scanned.ids)
|
|
2015
2413
|
milestonePhaseNums.add(id);
|
|
2016
2414
|
for (const qualified of scanned.qualifiedIds)
|
|
@@ -2064,7 +2462,11 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
|
2064
2462
|
? new RegExp(`^0*(\\d+[A-Za-z]?(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?)*(?:\\.\\d+)*)(?=-|$)`)
|
|
2065
2463
|
// 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
2464
|
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
|
2465
|
+
const customKeys = context?.phaseNaming === 'custom' && headingConvention !== 'bracket'
|
|
2466
|
+
? new Set([...milestonePhaseNums].map(id => phaseIdModule.phaseKeyFromToken(id, context))) : null;
|
|
2067
2467
|
function isDirInMilestone(dirName) {
|
|
2468
|
+
if (customKeys)
|
|
2469
|
+
return customKeys.has(phaseIdModule.phaseKeyFromDir(dirName, headingConvention, context));
|
|
2068
2470
|
// #612: the DIR side of this filter, selected by the same convention the
|
|
2069
2471
|
// heading side selected. Without it the heading scan's new bracket reach was
|
|
2070
2472
|
// half a fix: `milestonePhaseNums` became non-empty, so the pass-all degrade
|
|
@@ -2153,27 +2555,31 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
|
2153
2555
|
* Details section) — both consume the same `locateMilestoneHeadings` /
|
|
2154
2556
|
* `computeMilestoneSectionEnd` owner (#3184), so there is no separate copy to
|
|
2155
2557
|
* keep in sync. Returns null when there is no versioned active milestone;
|
|
2156
|
-
* callers then fall back to
|
|
2558
|
+
* callers then fall back to the shipped-block-masked whole content
|
|
2559
|
+
* (`editOutsideShippedBlocks`) via `editCurrentMilestone`, or to unscoped
|
|
2560
|
+
* whole-content handling where they call this directly (`phase.cts`
|
|
2561
|
+
* `phaseEntryInsertOffset`).
|
|
2157
2562
|
*
|
|
2158
|
-
* #2761
|
|
2159
|
-
*
|
|
2160
|
-
*
|
|
2161
|
-
*
|
|
2162
|
-
*
|
|
2163
|
-
*
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2167
|
-
*
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2563
|
+
* #2761 / #4304: bracket-widened
|
|
2564
|
+
* in lockstep with the write path landing, per this doc's own prior note.
|
|
2565
|
+
* `phaseIdConvention` is the same additive, optional trailing parameter
|
|
2566
|
+
* `extractCurrentMilestoneScoped` accepts — omitted (every pre-#4304 2-arg
|
|
2567
|
+
* call site), it compiles byte-identically to before: `bracketFallbackHeadingMatches`
|
|
2568
|
+
* and `bracketAwareMilestoneSection`'s bracket branch are both no-ops unless
|
|
2569
|
+
* explicitly told `'bracket'`, so a version-string match still short-circuits
|
|
2570
|
+
* both, and a non-bracket/unresolved convention still returns null exactly as
|
|
2571
|
+
* it always has. Now shares its heading-selection and section-end grammar
|
|
2572
|
+
* with `extractCurrentMilestoneScoped` (`bracketFallbackHeadingMatches`,
|
|
2573
|
+
* `selectActiveMilestoneHeading`, `bracketAwareMilestoneSection`) instead of
|
|
2574
|
+
* carrying its own unwidened copy, so a version-less bracket milestone
|
|
2575
|
+
* heading (`## [GSD.09] Hidden`) bounds a section for this function's callers
|
|
2576
|
+
* too, not only for the read path.
|
|
2577
|
+
* Consumers: `editCurrentMilestone` (`phase complete`'s `mutateMilestonePhase`,
|
|
2578
|
+
* `roadmap update-plan-progress`) and `phase.cts` `phaseEntryInsertOffset`. On the null
|
|
2579
|
+
* path `editCurrentMilestone` edits the ROADMAP with closed-milestone `<details>`
|
|
2580
|
+
* blocks masked, so no writer reaches into a shipped block.
|
|
2175
2581
|
*/
|
|
2176
|
-
function currentMilestoneRawRanges(content, cwd) {
|
|
2582
|
+
function currentMilestoneRawRanges(content, cwd, phaseIdConvention) {
|
|
2177
2583
|
if (!cwd)
|
|
2178
2584
|
return null;
|
|
2179
2585
|
let version = null;
|
|
@@ -2182,9 +2588,9 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
2182
2588
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
2183
2589
|
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
|
|
2184
2590
|
if (stateRaw !== null) {
|
|
2185
|
-
const
|
|
2186
|
-
if (
|
|
2187
|
-
const classified = classifyMilestoneScalar(
|
|
2591
|
+
const milestoneScalar = readStateMilestoneScalar(stateRaw);
|
|
2592
|
+
if (milestoneScalar !== null) {
|
|
2593
|
+
const classified = classifyMilestoneScalar(milestoneScalar);
|
|
2188
2594
|
version = classified.version;
|
|
2189
2595
|
stateAssertsNoMilestone = classified.explicitNull;
|
|
2190
2596
|
}
|
|
@@ -2198,15 +2604,21 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
2198
2604
|
}
|
|
2199
2605
|
if (!version)
|
|
2200
2606
|
return null;
|
|
2201
|
-
const
|
|
2607
|
+
const bracketScopeConvention = phaseIdConvention ?? null;
|
|
2608
|
+
let headingMatches = locateMilestoneHeadings(content, version);
|
|
2609
|
+
if (headingMatches.length === 0) {
|
|
2610
|
+
headingMatches = bracketFallbackHeadingMatches(content, version, bracketScopeConvention);
|
|
2611
|
+
}
|
|
2202
2612
|
if (headingMatches.length === 0)
|
|
2203
2613
|
return null;
|
|
2204
2614
|
const isClosed = isClosedMilestoneHeading;
|
|
2205
2615
|
// #3184: selection collapses to the sole owner; `headingMatches` is still
|
|
2206
2616
|
// needed below for the detailsMatch search over all headings.
|
|
2207
|
-
const selected =
|
|
2617
|
+
const selected = selectActiveMilestoneHeading(content, version, headingMatches);
|
|
2208
2618
|
const sectionStart = selected.index ?? 0;
|
|
2209
|
-
const
|
|
2619
|
+
const bracketBoundaryActive = bracketScopeConvention === 'bracket';
|
|
2620
|
+
const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
2621
|
+
const { sectionEnd, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
|
|
2210
2622
|
const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
|
|
2211
2623
|
const detailsVersionBoundary = selectedVersionToken
|
|
2212
2624
|
? new RegExp(`${(0, pattern_cjs_1.escapeRegex)(selectedVersionToken)}(?![\\w.-])`, 'i')
|
|
@@ -2218,10 +2630,160 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
2218
2630
|
let details = null;
|
|
2219
2631
|
if (detailsMatch) {
|
|
2220
2632
|
const detailsStart = detailsMatch.index ?? 0;
|
|
2221
|
-
|
|
2633
|
+
// #4304: pass the SAME bracketBoundary (and heading tokens)
|
|
2634
|
+
// bracketAwareMilestoneSection already derived for the primary range —
|
|
2635
|
+
// extractCurrentMilestoneScoped's own details lookup (line ~1312) has
|
|
2636
|
+
// always done this; this copy omitted both trailing arguments, so a
|
|
2637
|
+
// version-less bracket milestone heading (no v\d+.\d+/emoji marker) never
|
|
2638
|
+
// stopped this details window at the NEXT milestone's own "(Phase
|
|
2639
|
+
// Details)" section, and the two copies silently disagreed on the active
|
|
2640
|
+
// details window.
|
|
2641
|
+
details = {
|
|
2642
|
+
start: detailsStart,
|
|
2643
|
+
end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart, bracketBoundary, currentMilestoneHeadings),
|
|
2644
|
+
};
|
|
2222
2645
|
}
|
|
2223
2646
|
return { primary: { start: sectionStart, end: sectionEnd }, details };
|
|
2224
2647
|
}
|
|
2648
|
+
/** `<details>` / `</details>` tags of `content` in document order (bare opener only: `<details open>` wraps the active milestone, it is not an archive). */
|
|
2649
|
+
function bareDetailsTags(content) {
|
|
2650
|
+
return [...content.matchAll(/<(\/?)details>/gi)].map((m) => ({ at: m.index, close: m[1] === '/' }));
|
|
2651
|
+
}
|
|
2652
|
+
/** Whether offset `idx` sits inside a bare `<details>` block that has not closed yet (`tags` from `bareDetailsTags`). */
|
|
2653
|
+
function insideBareDetails(tags, idx) {
|
|
2654
|
+
let lo = 0;
|
|
2655
|
+
let hi = tags.length;
|
|
2656
|
+
while (lo < hi) {
|
|
2657
|
+
const mid = (lo + hi) >> 1;
|
|
2658
|
+
if (tags[mid].at < idx)
|
|
2659
|
+
lo = mid + 1;
|
|
2660
|
+
else
|
|
2661
|
+
hi = mid;
|
|
2662
|
+
}
|
|
2663
|
+
return lo > 0 && !tags[lo - 1].close;
|
|
2664
|
+
}
|
|
2665
|
+
/** A phase heading or a bold phase-list entry: what makes a block a shipped/backlog milestone. */
|
|
2666
|
+
const ACTIVE_SCOPE_PHASE_ENTRY_SRC = `(?:^#{2,4}[ \\t]+Phase\\b|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ bold: 'required' })}Phase\\b)`;
|
|
2667
|
+
/**
|
|
2668
|
+
* The active milestone's writable scope as offsets into `content`.
|
|
2669
|
+
*
|
|
2670
|
+
* `start` is just past the last `</details>` that closes BEFORE the selected
|
|
2671
|
+
* milestone heading, so a backlog `</details>` AFTER it no longer hides it
|
|
2672
|
+
* (#5221) while phase/Progress content above the heading (#2947 preamble layout)
|
|
2673
|
+
* stays in scope. No resolved heading, or one that is itself closed (stale
|
|
2674
|
+
* STATE), uses the last `</details>` before the first phase entry outside any
|
|
2675
|
+
* open <details>, so a backlog `</details>` below the active phases does not
|
|
2676
|
+
* hide them.
|
|
2677
|
+
*
|
|
2678
|
+
* `end` is the first line-leading bare `<details>` AFTER that heading (or first
|
|
2679
|
+
* phase entry): a shipped block placed below the active milestone is never
|
|
2680
|
+
* writable, so a second run cannot tick the archived phase once the active one
|
|
2681
|
+
* is done. Writers that must reach a bottom `## Progress` past a backlog block
|
|
2682
|
+
* (`editProgressTableSlice`) use `start` alone. Tags match case-insensitively.
|
|
2683
|
+
* An archive whose `</details>` is missing cannot be told from a wrapper, so
|
|
2684
|
+
* `start` stays 0 for it (documented fail-open).
|
|
2685
|
+
*/
|
|
2686
|
+
function activeScope(content, cwd) {
|
|
2687
|
+
const tags = bareDetailsTags(content);
|
|
2688
|
+
const headingAt = currentMilestoneRawRanges(content, cwd)?.primary.start;
|
|
2689
|
+
const open = headingAt !== undefined && !isClosedMilestoneHeading(content.slice(headingAt).split('\n', 1)[0]);
|
|
2690
|
+
let anchor;
|
|
2691
|
+
if (open) {
|
|
2692
|
+
anchor = headingAt;
|
|
2693
|
+
}
|
|
2694
|
+
else {
|
|
2695
|
+
const firstActive = [...content.matchAll(new RegExp(ACTIVE_SCOPE_PHASE_ENTRY_SRC, 'gim'))]
|
|
2696
|
+
.find((m) => !insideBareDetails(tags, m.index));
|
|
2697
|
+
anchor = firstActive?.index ?? content.length;
|
|
2698
|
+
}
|
|
2699
|
+
let start = 0;
|
|
2700
|
+
for (const t of tags) {
|
|
2701
|
+
if (t.at >= anchor)
|
|
2702
|
+
break;
|
|
2703
|
+
if (t.close)
|
|
2704
|
+
start = t.at + '</details>'.length;
|
|
2705
|
+
}
|
|
2706
|
+
// Only a block that carries phase entries is a shipped/backlog milestone; a collapsible note inside the active milestone stays writable.
|
|
2707
|
+
const phaseEntry = new RegExp(ACTIVE_SCOPE_PHASE_ENTRY_SRC, 'im');
|
|
2708
|
+
const below = tags.find((t, i) => {
|
|
2709
|
+
if (t.close || t.at <= anchor || !/^[ \t]*$/.test(content.slice(content.lastIndexOf('\n', t.at - 1) + 1, t.at)))
|
|
2710
|
+
return false;
|
|
2711
|
+
const close = tags.slice(i + 1).find((c) => c.close);
|
|
2712
|
+
return phaseEntry.test(content.slice(t.at, close ? close.at : content.length));
|
|
2713
|
+
});
|
|
2714
|
+
return { start, end: below ? below.at : content.length };
|
|
2715
|
+
}
|
|
2716
|
+
function activeScopeStart(content, cwd) {
|
|
2717
|
+
return activeScope(content, cwd).start;
|
|
2718
|
+
}
|
|
2719
|
+
/** [start,end) windows of the current milestone, document order; the whole ROADMAP when none resolves. */
|
|
2720
|
+
function currentMilestoneWindows(content, cwd) {
|
|
2721
|
+
const ranges = currentMilestoneRawRanges(content, cwd);
|
|
2722
|
+
if (!ranges)
|
|
2723
|
+
return [{ start: 0, end: content.length }];
|
|
2724
|
+
// `details` always starts at or after `primary.end` (see `currentMilestoneRawRanges`),
|
|
2725
|
+
// so this is document order: a "first window wins" caller means the primary one.
|
|
2726
|
+
return [ranges.primary, ranges.details].filter((w) => w !== null);
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Read-only counterpart of `editCurrentMilestone`: the text of each window,
|
|
2730
|
+
* with closed-milestone `<details>` blocks hidden exactly as the writers see it.
|
|
2731
|
+
*/
|
|
2732
|
+
function readCurrentMilestone(content, cwd) {
|
|
2733
|
+
return currentMilestoneWindows(content, cwd).map((w) => maskShippedBlocks(content.slice(w.start, w.end)).masked);
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Run `edit` over the current milestone's raw window(s) from
|
|
2737
|
+
* `currentMilestoneRawRanges`, or, when no milestone resolves, over the whole
|
|
2738
|
+
* ROADMAP, each with closed-milestone `<details>` blocks hidden (#5543;
|
|
2739
|
+
* `editOutsideShippedBlocks`: a notes or phase-wrapper block stays editable).
|
|
2740
|
+
* `edit` is called once per window, in document order. The one milestone
|
|
2741
|
+
* scoping for per-phase writers (`phase complete`, `roadmap
|
|
2742
|
+
* update-plan-progress` #5483), so neither cuts at the last `</details>`.
|
|
2743
|
+
*/
|
|
2744
|
+
function editCurrentMilestone(content, cwd, edit) {
|
|
2745
|
+
const windows = currentMilestoneWindows(content, cwd);
|
|
2746
|
+
const edited = windows.map((w) => editOutsideShippedBlocks(content.slice(w.start, w.end), edit));
|
|
2747
|
+
// Splice last-first so an earlier window's offsets are not shifted by a
|
|
2748
|
+
// length-changing mutation in a later window.
|
|
2749
|
+
return windows.reduceRight((acc, w, i) => acc.slice(0, w.start) + edited[i] + acc.slice(w.end), content);
|
|
2750
|
+
}
|
|
2751
|
+
/**
|
|
2752
|
+
* The checklist bullet of the phase whose number matches `phasePattern`
|
|
2753
|
+
* (`- [ ] **Phase N: …**`), as the one grammar shared by the Plans-count /
|
|
2754
|
+
* completion stamp and the plan-row ticks. `anyState` also matches a checked box.
|
|
2755
|
+
* Group 1 is everything through the opening `[` (indent and marker included),
|
|
2756
|
+
* group 2 the rest of the line from the closing `]`. The prefix (marker, box, bold token) is the phase-entry grammar's (#5764); the
|
|
2757
|
+
* tail stops before `\r` so a stamp appended to group 2 lands on the entry's
|
|
2758
|
+
* line, before its CRLF.
|
|
2759
|
+
*/
|
|
2760
|
+
function phaseChecklistBullet(phasePattern, anyState) {
|
|
2761
|
+
return (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(`Phase[ \\t]+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s]|$)[^\\r\\n]*`, 'i', { state: anyState ? 'any' : 'unchecked' });
|
|
2762
|
+
}
|
|
2763
|
+
/** A checklist bullet of ANY phase: ends the rows nested under the previous one. */
|
|
2764
|
+
const ANY_PHASE_BULLET = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)('Phase[ \\t]', 'i');
|
|
2765
|
+
/**
|
|
2766
|
+
* [start,end) of the rows nested under a phase's checklist bullet: from the
|
|
2767
|
+
* line after `bullet` (a match of `phaseChecklistBullet` in `text`) up to a
|
|
2768
|
+
* blank line, a shallower line, or the next phase's bullet. Empty when the
|
|
2769
|
+
* bullet is the last line with no trailing newline.
|
|
2770
|
+
*/
|
|
2771
|
+
function checklistBulletExtent(text, bullet) {
|
|
2772
|
+
// The bullet's own indent, read from its line: a caller's capture group 1 may
|
|
2773
|
+
// be the indent alone (which has no non-space and used to give -1, so a
|
|
2774
|
+
// shallower line never ended the extent) or everything through the `[`.
|
|
2775
|
+
const depth = text.slice(bullet.index).search(/\S/);
|
|
2776
|
+
const start = text.indexOf('\n', bullet.index) + 1;
|
|
2777
|
+
let end = start;
|
|
2778
|
+
while (start > 0 && end < text.length) {
|
|
2779
|
+
const next = text.indexOf('\n', end);
|
|
2780
|
+
const line = text.slice(end, next === -1 ? text.length : next);
|
|
2781
|
+
if (line.trim() === '' || line.search(/\S/) < depth || ANY_PHASE_BULLET.test(line))
|
|
2782
|
+
break;
|
|
2783
|
+
end = next === -1 ? text.length : next + 1;
|
|
2784
|
+
}
|
|
2785
|
+
return { start, end };
|
|
2786
|
+
}
|
|
2225
2787
|
/**
|
|
2226
2788
|
* #4731 — extract a bold-labeled phase field (Goal, Requirements, ...) reading
|
|
2227
2789
|
* past hard-wrapped continuation lines. The roadmapper soft-wraps long fields
|
|
@@ -2286,22 +2848,32 @@ function extractPhaseFieldMultiline(section, label) {
|
|
|
2286
2848
|
return [firstLine, ...contLines].join(' ').trim() || null;
|
|
2287
2849
|
}
|
|
2288
2850
|
module.exports = {
|
|
2851
|
+
activeScope,
|
|
2852
|
+
activeScopeStart,
|
|
2289
2853
|
stripShippedMilestones,
|
|
2290
2854
|
extractCurrentMilestone,
|
|
2291
2855
|
extractCurrentMilestoneScoped,
|
|
2292
2856
|
isMilestoneShippedInRoadmap,
|
|
2293
2857
|
isMilestoneBoundedInRoadmap,
|
|
2294
|
-
|
|
2858
|
+
maskArchivedDetails,
|
|
2295
2859
|
getRoadmapPhaseInternal,
|
|
2296
2860
|
getMilestoneInfo,
|
|
2297
2861
|
classifyMilestoneScalar,
|
|
2862
|
+
readStateMilestoneScalar,
|
|
2298
2863
|
getMilestonePhaseFilter,
|
|
2299
2864
|
currentMilestoneRawRanges,
|
|
2300
2865
|
withPhaseSection,
|
|
2301
2866
|
editOutsideShippedBlocks,
|
|
2867
|
+
editCurrentMilestone,
|
|
2868
|
+
readCurrentMilestone,
|
|
2869
|
+
phaseChecklistBullet,
|
|
2870
|
+
checklistBulletExtent,
|
|
2302
2871
|
computeMilestoneSectionEnd,
|
|
2303
2872
|
locateMilestoneHeadings,
|
|
2304
2873
|
listMilestoneHeadings,
|
|
2874
|
+
// #4304: historical mutation guards consume the window locator's
|
|
2875
|
+
// milestone-vs-phase decision before applying CLOSED markers.
|
|
2876
|
+
isRecognizedMilestoneHeading,
|
|
2305
2877
|
selectMilestoneHeading,
|
|
2306
2878
|
classifyMilestoneWindow,
|
|
2307
2879
|
// #3184: the sole "give me this version's window" composition — see its
|
|
@@ -2313,9 +2885,6 @@ module.exports = {
|
|
|
2313
2885
|
hasMilestoneSectioning,
|
|
2314
2886
|
// #3642: the >=1 sibling buildStateFrontmatter's unbounded branch consumes.
|
|
2315
2887
|
hasAnyMilestoneSection,
|
|
2316
|
-
// #1956: sole owner of the #2012 decoy-avoidance scope for the
|
|
2317
|
-
// `drift-guard phase-status` CLI seam.
|
|
2318
|
-
findRoadmapProgressTable,
|
|
2319
2888
|
// #3262 (write-time milestone-scope guard): the window phase-id scan owner
|
|
2320
2889
|
// (consumed by getMilestonePhaseFilter above and the roadmap milestone-scope
|
|
2321
2890
|
// CLI probe) and the free-text predicate the phase add/add-batch/insert
|
|
@@ -2329,6 +2898,23 @@ module.exports = {
|
|
|
2329
2898
|
// owner (and its convention gate) instead of a private inline copy.
|
|
2330
2899
|
extractPhaseFieldMultiline,
|
|
2331
2900
|
hasPhaseEntries,
|
|
2901
|
+
isPhaseEntryHeading,
|
|
2902
|
+
// #4304: exported so `phase.cts`'s milestone-marker
|
|
2903
|
+
// enumeration (bracketRecognizedMilestoneMarkers) can recognize a
|
|
2904
|
+
// version-less bracket milestone heading through the SAME grammar the
|
|
2905
|
+
// window locator (bracketAwareMilestoneSection) already uses to decide
|
|
2906
|
+
// section boundaries, instead of a narrower, independently-typed copy.
|
|
2907
|
+
isBracketMilestoneBoundary,
|
|
2908
|
+
bracketFallbackHeadingMatches,
|
|
2909
|
+
// #4304: exported so `phase.cts`'s pre-mutation window guard
|
|
2910
|
+
// (bracketOwnedLineOutsideActiveWindow) can recognize a CLOSED/shipped
|
|
2911
|
+
// milestone heading through the SAME predicate `currentMilestoneRawRanges`
|
|
2912
|
+
// itself already uses to skip a closed heading when selecting the active
|
|
2913
|
+
// one, instead of a re-typed copy of MILESTONE_CLOSED_MARKER_PATTERN.
|
|
2914
|
+
isClosedMilestoneHeading,
|
|
2915
|
+
// #4304: destructive ROADMAP writers classify details blocks through
|
|
2916
|
+
// the same closed-summary rule as stripClosedMilestoneDetails.
|
|
2917
|
+
isClosedMilestoneDetails,
|
|
2332
2918
|
// #4144 round 5 Blocker 1: the readers' own phase-heading grammar, so the
|
|
2333
2919
|
// bracket roadmap migrator's "phase-like but unparsed" refusal
|
|
2334
2920
|
// (src/roadmap-upgrade.cts) can gate on it instead of a private, looser
|