@opengsd/gsd-core 1.5.0 → 1.6.0-rc.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/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/bin/install.js +108 -34
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +677 -2
- package/gsd-core/bin/lib/adr-parser.cjs +24 -17
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/config-loader.cjs +92 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +29 -0
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/verify.cjs +29 -13
- package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +4 -6
- package/package.json +3 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/run-tests.cjs +14 -0
- package/scripts/sync-manifest-versions.cjs +77 -5
|
@@ -8,67 +8,56 @@
|
|
|
8
8
|
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
|
|
9
9
|
* Returns {id, text, category, tags, trackable} per decision.
|
|
10
10
|
* CJS callers that only use {id, text} safely ignore the extra fields.
|
|
11
|
+
*
|
|
12
|
+
* ADR-1372 T1: rewritten to adopt the markdown-sectionizer seam.
|
|
13
|
+
* - `stripFencedCode` → seam's `stripFencedCode` (CommonMark-correct)
|
|
14
|
+
* - `extractDecisionsBlock` → seam's `extractTaggedBlocks(content,'decisions')`
|
|
15
|
+
* - Markdown-header fallback → seam's `collectSection(content, /decisions?/i, ...)`
|
|
16
|
+
* - Outer bullet loop → seam's `iterateBullets` (for the header-fallback path)
|
|
17
|
+
*
|
|
18
|
+
* Resolves #1364 (markdown-header + em-dash recall) and #1365 (fail-loud gate).
|
|
11
19
|
*/
|
|
12
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.extractDecisions = extractDecisions;
|
|
13
22
|
exports.parseDecisions = parseDecisions;
|
|
23
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
14
24
|
const DISCRETION_HEADINGS = new Set([
|
|
15
25
|
"claude's discretion",
|
|
16
26
|
'claudes discretion',
|
|
17
27
|
'claude discretion',
|
|
18
28
|
]);
|
|
19
29
|
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
|
|
30
|
+
// ─── Bullet parsers (decisions-specific grammar) ─────────────────────────────
|
|
20
31
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
32
|
+
* Colon form: `- **D-NN[ [tags]]:** text`
|
|
33
|
+
* (#1343: `[^:*]*` subsumes any pre-colon prose, stops at `:**`)
|
|
23
34
|
*/
|
|
24
|
-
|
|
25
|
-
return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' ');
|
|
26
|
-
}
|
|
35
|
+
const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
|
|
27
36
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
37
|
+
* Em-dash form: `- **D-NN[ [tags]] — title** body`
|
|
38
|
+
* The em-dash (U+2014) or its lookalike separates the ID+tags group from a title
|
|
39
|
+
* that lives inside the bold markers; the body (which may be empty) follows
|
|
40
|
+
* outside the closing `**`. This form was not handled pre-T1 (bug #1364).
|
|
30
41
|
*
|
|
31
|
-
*
|
|
32
|
-
* "current decisions" block plus a "carry-over from prior phase" block);
|
|
33
|
-
* dropping all-but-the-first silently lost the second batch (review F13).
|
|
42
|
+
* Accepts both U+2014 em-dash (—) and U+2013 en-dash (–) for robustness.
|
|
34
43
|
*/
|
|
35
|
-
|
|
36
|
-
const cleaned = stripFencedCode(content);
|
|
37
|
-
const matches = [...cleaned.matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)];
|
|
38
|
-
if (matches.length === 0)
|
|
39
|
-
return null;
|
|
40
|
-
return matches.map((m) => m[1]).join('\n\n');
|
|
41
|
-
}
|
|
44
|
+
const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^*]*[—–][^*]*\*\*\s*(.*)$/;
|
|
42
45
|
/**
|
|
43
|
-
* Parse
|
|
46
|
+
* Parse decision lines from a block of text (the inner text of a <decisions>
|
|
47
|
+
* or markdown-header section body). Returns the extracted decisions and a count
|
|
48
|
+
* of parse-misses (lines that looked like D-NN bullets but failed both regexes).
|
|
44
49
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
50
|
+
* FIX B (#1365): parseMisses > 0 means the caller must treat the result as
|
|
51
|
+
* could-not-parse even when some decisions were extracted — a silent drop is
|
|
52
|
+
* worse than a fail-loud signal.
|
|
48
53
|
*/
|
|
49
|
-
function
|
|
50
|
-
if (!content || typeof content !== 'string')
|
|
51
|
-
return [];
|
|
52
|
-
const block = extractDecisionsBlock(content);
|
|
53
|
-
if (block === null)
|
|
54
|
-
return [];
|
|
54
|
+
function parseDecisionLines(block) {
|
|
55
55
|
const lines = block.split(/\r?\n/);
|
|
56
56
|
const out = [];
|
|
57
57
|
let category = '';
|
|
58
58
|
let inDiscretion = false;
|
|
59
|
-
// Bullet line: `- **D-NN[ [tags]]:** text`
|
|
60
|
-
// Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
|
|
61
|
-
// in addition to numeric-only IDs (D-42). The first character after `D-` must
|
|
62
|
-
// be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
|
|
63
|
-
// CJS callers consume {id, text} and ignore the optional extras.
|
|
64
|
-
// #1343: `[^:*]*` replaces the old `\s*` before `:**` so that a freeform run
|
|
65
|
-
// such as `(parenthetical)`, an em-dash, or other prose between the optional
|
|
66
|
-
// bracket-tag group and the closing `:**` is tolerated rather than silently
|
|
67
|
-
// dropping the whole decision. `[^:*]*` subsumes plain whitespace and stops
|
|
68
|
-
// correctly at `:**`. Capture groups 1 (id), 2 (bracket tags), 3 (text) are
|
|
69
|
-
// unchanged.
|
|
70
|
-
const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
|
|
71
59
|
let current = null;
|
|
60
|
+
let parseMisses = 0;
|
|
72
61
|
const flush = () => {
|
|
73
62
|
if (current) {
|
|
74
63
|
current.text = current.text.trim();
|
|
@@ -84,39 +73,49 @@ function parseDecisions(content) {
|
|
|
84
73
|
flush();
|
|
85
74
|
category = headingMatch[1];
|
|
86
75
|
// Strip the full unicode-quote family so any rendering of "Claude's
|
|
87
|
-
// Discretion" (ASCII apostrophe, curly U+2019
|
|
88
|
-
// double-quote variants U+201C/D/E/F, etc.) collapses
|
|
89
|
-
// (review F20).
|
|
76
|
+
// Discretion" (ASCII apostrophe, curly U+2019 ’, U+2018 ‘,
|
|
77
|
+
// U+201A, U+201B, double-quote variants U+201C/D/E/F, etc.) collapses
|
|
78
|
+
// to the same key (FIX C + review F20).
|
|
90
79
|
const normalized = category
|
|
91
80
|
.toLowerCase()
|
|
92
|
-
.replace(/[‘’‚‛“”„‟'"`]/g, '')
|
|
81
|
+
.replace(/[‘’‚‛“”„‟''"`]/g, '')
|
|
93
82
|
.trim();
|
|
94
83
|
inDiscretion = DISCRETION_HEADINGS.has(normalized);
|
|
95
84
|
continue;
|
|
96
85
|
}
|
|
97
|
-
|
|
98
|
-
|
|
86
|
+
// Colon form: `- **D-NN[ [tags]]:** text`
|
|
87
|
+
const colonMatch = line.match(bulletColonRe);
|
|
88
|
+
if (colonMatch) {
|
|
89
|
+
flush();
|
|
90
|
+
const id = `D-${colonMatch[1]}`;
|
|
91
|
+
const tags = colonMatch[2]
|
|
92
|
+
? colonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
93
|
+
: [];
|
|
94
|
+
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
|
|
95
|
+
current = { id, text: colonMatch[3], category, tags, trackable };
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Em-dash form: `- **D-NN[ [tags]] — title** body`
|
|
99
|
+
const emDashMatch = line.match(bulletEmDashRe);
|
|
100
|
+
if (emDashMatch) {
|
|
99
101
|
flush();
|
|
100
|
-
const id = `D-${
|
|
101
|
-
const tags =
|
|
102
|
-
?
|
|
103
|
-
.split(',')
|
|
104
|
-
.map((t) => t.trim().toLowerCase())
|
|
105
|
-
.filter(Boolean)
|
|
102
|
+
const id = `D-${emDashMatch[1]}`;
|
|
103
|
+
const tags = emDashMatch[2]
|
|
104
|
+
? emDashMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
106
105
|
: [];
|
|
107
106
|
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
|
|
108
|
-
|
|
107
|
+
// The body (emDashMatch[3]) may be empty for the pure title form; the
|
|
108
|
+
// title itself is embedded in the bold run but we report the body as text
|
|
109
|
+
// (consistent with how the gate cares only about coverage, not title/body split).
|
|
110
|
+
current = { id, text: emDashMatch[3] || '', category, tags, trackable };
|
|
109
111
|
continue;
|
|
110
112
|
}
|
|
111
|
-
// Parse-miss guard (#1343): a line that looks like a `D-NN` decision
|
|
112
|
-
// but failed
|
|
113
|
-
//
|
|
114
|
-
// report a false pass. Surface it loudly instead.
|
|
113
|
+
// Parse-miss guard (FIX B + #1343): a line that looks like a `D-NN` decision
|
|
114
|
+
// bullet but failed both patterns — flush, warn, and record the miss.
|
|
115
|
+
// parseMisses > 0 forces could-not-parse even when other decisions parsed.
|
|
115
116
|
if (/^\s*-\s+\*\*D-/.test(line)) {
|
|
116
|
-
// A malformed D-bullet still starts a (failed) new decision, so it ends the
|
|
117
|
-
// previous one — flush before warning so a following continuation line cannot
|
|
118
|
-
// be mis-appended to the prior valid decision.
|
|
119
117
|
flush();
|
|
118
|
+
parseMisses += 1;
|
|
120
119
|
console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
|
|
121
120
|
continue;
|
|
122
121
|
}
|
|
@@ -132,5 +131,95 @@ function parseDecisions(content) {
|
|
|
132
131
|
}
|
|
133
132
|
}
|
|
134
133
|
flush();
|
|
135
|
-
return out;
|
|
134
|
+
return { decisions: out, parseMisses };
|
|
135
|
+
}
|
|
136
|
+
// ─── Primary entry point: extractDecisions ────────────────────────────────────
|
|
137
|
+
/**
|
|
138
|
+
* Extract decisions from CONTEXT.md content with a typed outcome.
|
|
139
|
+
*
|
|
140
|
+
* Strategy (in priority order):
|
|
141
|
+
* 1. If the content (fence-stripped) contains `<decisions>...</decisions>` blocks,
|
|
142
|
+
* parse ONLY those blocks (canonical form; markdown-header content outside blocks
|
|
143
|
+
* is ignored when a block is present — existing behavior preserved).
|
|
144
|
+
* 2. Otherwise, look for a /decisions?/i heading and collect its section body.
|
|
145
|
+
* This is the T1 recall fix for #1364.
|
|
146
|
+
* 3. If neither is found, return outcome based on decision-shape heuristics.
|
|
147
|
+
*/
|
|
148
|
+
function extractDecisions(content) {
|
|
149
|
+
if (!content || typeof content !== 'string') {
|
|
150
|
+
return { decisions: [], outcome: 'none-present' };
|
|
151
|
+
}
|
|
152
|
+
// Apply fence-stripping for block extraction (prevents example blocks inside
|
|
153
|
+
// ``` fences from polluting the parser — review F11).
|
|
154
|
+
const { text: stripped, unterminatedFence } = (0, markdown_sectionizer_cjs_1.stripFencedCode)(content);
|
|
155
|
+
// ── Path 1: <decisions> blocks present ──────────────────────────────────────
|
|
156
|
+
const taggedBlocks = (0, markdown_sectionizer_cjs_1.extractTaggedBlocks)(stripped, 'decisions');
|
|
157
|
+
if (taggedBlocks.length > 0) {
|
|
158
|
+
const combined = taggedBlocks.join('\n\n');
|
|
159
|
+
const { decisions, parseMisses } = parseDecisionLines(combined);
|
|
160
|
+
if (decisions.length > 0 && parseMisses === 0) {
|
|
161
|
+
return { decisions, outcome: 'parsed' };
|
|
162
|
+
}
|
|
163
|
+
// FIX B: parse-misses present — could-not-parse even if some decisions extracted.
|
|
164
|
+
if (parseMisses > 0) {
|
|
165
|
+
return { decisions, outcome: 'could-not-parse' };
|
|
166
|
+
}
|
|
167
|
+
// FIX A: Block present but 0 extracted and no parse-misses.
|
|
168
|
+
// Only report could-not-parse when there is genuine evidence of real decisions
|
|
169
|
+
// that failed to parse: a \bD- token in the block text, or an unterminated fence.
|
|
170
|
+
// An empty scaffold (<decisions></decisions>) or an all-prose block has no such
|
|
171
|
+
// evidence — treat as none-present so the gate passes cleanly.
|
|
172
|
+
const hasDecisionTokenInBlock = /\bD-[A-Za-z0-9]/m.test(combined);
|
|
173
|
+
if (hasDecisionTokenInBlock || unterminatedFence) {
|
|
174
|
+
return { decisions: [], outcome: 'could-not-parse' };
|
|
175
|
+
}
|
|
176
|
+
return { decisions: [], outcome: 'none-present' };
|
|
177
|
+
}
|
|
178
|
+
// ── Path 2: markdown-header fallback (#1364 fix) ─────────────────────────────
|
|
179
|
+
// Use the seam's collectSection to find a /decisions?/i heading section.
|
|
180
|
+
// levelBounded:true → stop at next same-or-higher-level heading.
|
|
181
|
+
// stripFences:true → inner fences inside the section body are stripped.
|
|
182
|
+
const section = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => /decisions?\b/i.test(h.text), { levelBounded: true, stripFences: true });
|
|
183
|
+
if (section !== null) {
|
|
184
|
+
const { decisions, parseMisses } = parseDecisionLines(section.body);
|
|
185
|
+
if (decisions.length > 0 && parseMisses === 0) {
|
|
186
|
+
return { decisions, outcome: 'parsed' };
|
|
187
|
+
}
|
|
188
|
+
// FIX B: parse-misses present — could-not-parse even if some decisions extracted.
|
|
189
|
+
if (parseMisses > 0) {
|
|
190
|
+
return { decisions, outcome: 'could-not-parse' };
|
|
191
|
+
}
|
|
192
|
+
// FIX A: Heading found but 0 extracted and no parse-misses.
|
|
193
|
+
// Only report could-not-parse when the section body contains a D- token.
|
|
194
|
+
// A heading with only prose, sub-headings, or all-discretion content
|
|
195
|
+
// (no trackable D- tokens) is a legitimate empty/discretion section → none-present.
|
|
196
|
+
const hasDecisionTokenInSection = /\bD-[A-Za-z0-9]/m.test(section.body);
|
|
197
|
+
if (hasDecisionTokenInSection) {
|
|
198
|
+
return { decisions: [], outcome: 'could-not-parse' };
|
|
199
|
+
}
|
|
200
|
+
return { decisions: [], outcome: 'none-present' };
|
|
201
|
+
}
|
|
202
|
+
// ── Path 3: no blocks, no heading ────────────────────────────────────────────
|
|
203
|
+
// Apply shape heuristics to distinguish none-present from could-not-parse.
|
|
204
|
+
// We re-use the already-computed unterminatedFence and check for D- tokens.
|
|
205
|
+
const hasDecisionToken = /\bD-[A-Za-z0-9]/m.test(stripped);
|
|
206
|
+
if (unterminatedFence || hasDecisionToken) {
|
|
207
|
+
return { decisions: [], outcome: 'could-not-parse' };
|
|
208
|
+
}
|
|
209
|
+
return { decisions: [], outcome: 'none-present' };
|
|
210
|
+
}
|
|
211
|
+
// ─── parseDecisions: thin delegate (backwards-compatible entry point) ─────────
|
|
212
|
+
/**
|
|
213
|
+
* Parse trackable decisions from CONTEXT.md content.
|
|
214
|
+
*
|
|
215
|
+
* Thin delegate over extractDecisions — callers receive the decisions array
|
|
216
|
+
* exactly as before; nothing breaks. Use extractDecisions directly when the
|
|
217
|
+
* outcome enum is needed (e.g. for the fail-loud gate logic).
|
|
218
|
+
*
|
|
219
|
+
* Returns ALL D-NN decisions found (including non-trackable ones, with
|
|
220
|
+
* `trackable: false`). Callers that only want the gate-enforced decisions
|
|
221
|
+
* should filter `.filter(d => d.trackable)`.
|
|
222
|
+
*/
|
|
223
|
+
function parseDecisions(content) {
|
|
224
|
+
return extractDecisions(content).decisions;
|
|
136
225
|
}
|
|
@@ -31,6 +31,7 @@ const { escapeRegex } = phaseId;
|
|
|
31
31
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
32
32
|
const { planningPaths, planningDir, findContextMdIn } = planningWorkspace;
|
|
33
33
|
const decisions_cjs_1 = require("./decisions.cjs");
|
|
34
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
34
35
|
/**
|
|
35
36
|
* Parse REQ-IDs from REQUIREMENTS.md content.
|
|
36
37
|
*
|
|
@@ -44,16 +45,27 @@ function parseRequirements(reqMd) {
|
|
|
44
45
|
const seen = new Set();
|
|
45
46
|
// Prefix-agnostic ID format: REQ-01, TST-01, BACK-07, INSP-04, etc.
|
|
46
47
|
const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const idRe = new RegExp(`^(${ID_PATTERN})$`);
|
|
49
|
+
// Checkbox-bullet path: migrate to seam's iterateBullets (checkbox markers).
|
|
50
|
+
// The **ID** is extracted from the bullet text caller-side — the seam provides
|
|
51
|
+
// the raw text; we parse the bold-ID prefix from it here.
|
|
52
|
+
const boldIdRe = new RegExp(`^\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`);
|
|
53
|
+
for (const bullet of (0, markdown_sectionizer_cjs_1.iterateBullets)(reqMd)) {
|
|
54
|
+
if (bullet.marker !== 'checkbox-unchecked' && bullet.marker !== 'checkbox-checked')
|
|
55
|
+
continue;
|
|
56
|
+
const m = boldIdRe.exec(bullet.text);
|
|
57
|
+
if (!m)
|
|
58
|
+
continue;
|
|
59
|
+
const id = m[1];
|
|
60
|
+
if (!idRe.test(id))
|
|
61
|
+
continue;
|
|
51
62
|
if (!seen.has(id)) {
|
|
52
63
|
seen.add(id);
|
|
53
|
-
out.push({ id, text: (
|
|
64
|
+
out.push({ id, text: (m[2] || '').trim() });
|
|
54
65
|
}
|
|
55
|
-
cm = checkboxRe.exec(reqMd);
|
|
56
66
|
}
|
|
67
|
+
// Pipe-table-row path and separator-row skip stay caller-side
|
|
68
|
+
// (table parsing is out of seam scope per ADR-1372 T3 spec).
|
|
57
69
|
const tableFirstCellRe = new RegExp(`^\\s*\\|\\s*(${ID_PATTERN})\\s*\\|`);
|
|
58
70
|
const separatorRowRe = /^\s*\|[\s:|-]+\|\s*$/;
|
|
59
71
|
const lines = reqMd.split(/\r?\n/);
|
|
@@ -128,13 +140,77 @@ function readGate(cwd) {
|
|
|
128
140
|
catch { /* fall through */ }
|
|
129
141
|
return true;
|
|
130
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Same-prefix ascending numeric range, e.g. `SEL-01..SEL-03`. Both sides must
|
|
145
|
+
* share an identical prefix and a numeric suffix. Captures are:
|
|
146
|
+
* 1 low prefix, 2 low digits, 3 high prefix (compared to group 1 for equality), 4 high digits.
|
|
147
|
+
*/
|
|
148
|
+
const PHASE_REQ_RANGE_RE = /^(.+-)(\d+)\.\.(.+-)(\d+)$/;
|
|
149
|
+
/**
|
|
150
|
+
* Maximum number of IDs a single range token may expand to. A range whose span
|
|
151
|
+
* exceeds this cap stays literal (fail-closed) rather than expanding, guarding
|
|
152
|
+
* against pathological input like `X-1..X-100000` ballooning the comparison set.
|
|
153
|
+
*/
|
|
154
|
+
const MAX_PHASE_REQ_RANGE = 1000;
|
|
155
|
+
/**
|
|
156
|
+
* Expand a single `--phase-req-ids` token in place. If it is a valid ascending
|
|
157
|
+
* same-prefix numeric range (`<PREFIX>-NN..<PREFIX>-MM`, identical prefix both
|
|
158
|
+
* sides, numeric NN ≤ MM), return the individual IDs `<PREFIX>-NN … <PREFIX>-MM`
|
|
159
|
+
* preserving the bounds' zero-pad width. Anything that does NOT cleanly match a
|
|
160
|
+
* valid range stays literal (fail-closed) — returned as a single-element array.
|
|
161
|
+
*
|
|
162
|
+
* The two numeric bounds must share the same digit width; a range with
|
|
163
|
+
* differing widths (e.g. `SEL-9..SEL-11`) is ambiguous (padding to the wider
|
|
164
|
+
* width could invent IDs like `SEL-09` that never appear unpadded in
|
|
165
|
+
* REQUIREMENTS) and is left literal. A range spanning more than
|
|
166
|
+
* MAX_PHASE_REQ_RANGE IDs also stays literal.
|
|
167
|
+
*/
|
|
168
|
+
function expandPhaseReqIdToken(token) {
|
|
169
|
+
const m = PHASE_REQ_RANGE_RE.exec(token);
|
|
170
|
+
if (!m)
|
|
171
|
+
return [token];
|
|
172
|
+
const [, prefixLow, lowDigits, prefixHigh, highDigits] = m;
|
|
173
|
+
// Fail closed unless the prefixes are identical.
|
|
174
|
+
if (prefixLow !== prefixHigh)
|
|
175
|
+
return [token];
|
|
176
|
+
// Fail closed unless the bounds share an identical digit width. Differing
|
|
177
|
+
// widths are ambiguous: padding to the wider width could invent IDs that
|
|
178
|
+
// never appear unpadded in REQUIREMENTS.
|
|
179
|
+
if (lowDigits.length !== highDigits.length)
|
|
180
|
+
return [token];
|
|
181
|
+
const low = Number(lowDigits);
|
|
182
|
+
const high = Number(highDigits);
|
|
183
|
+
// Fail closed on descending ranges (NN > MM). NN == MM is a valid single-element range.
|
|
184
|
+
if (!Number.isFinite(low) || !Number.isFinite(high) || low > high)
|
|
185
|
+
return [token];
|
|
186
|
+
// Fail closed (DoS guard) on ranges spanning more than the cap.
|
|
187
|
+
if (high - low + 1 > MAX_PHASE_REQ_RANGE)
|
|
188
|
+
return [token];
|
|
189
|
+
// Preserve the bounds' (shared) zero-pad width.
|
|
190
|
+
const width = lowDigits.length;
|
|
191
|
+
const out = [];
|
|
192
|
+
for (let n = low; n <= high; n++) {
|
|
193
|
+
out.push(`${prefixLow}${String(n).padStart(width, '0')}`);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
131
197
|
/**
|
|
132
198
|
* Normalize a raw `--phase-req-ids` argument into the scoping signal used by
|
|
133
199
|
* runGapAnalysis (#447). Mirrors §13's null/TBD skip semantics.
|
|
134
200
|
*
|
|
135
|
-
* undefined
|
|
136
|
-
* null | '' | TBD
|
|
137
|
-
* "REQ-01,REQ-02"
|
|
201
|
+
* undefined → flag absent: compare the whole REQUIREMENTS.md (back-compat)
|
|
202
|
+
* null | '' | TBD → no requirements mapped to this phase: skip the comparison
|
|
203
|
+
* "REQ-01,REQ-02" → restrict the comparison to these IDs
|
|
204
|
+
* "SEL-01..SEL-03" → range form: expands in place to SEL-01, SEL-02, SEL-03 (#1269)
|
|
205
|
+
*
|
|
206
|
+
* Range form (#1269): a list element of the shape `<PREFIX>-NN..<PREFIX>-MM`
|
|
207
|
+
* (identical prefix both sides, identical bound digit width, ascending numeric
|
|
208
|
+
* NN ≤ MM) is expanded in place to the individual IDs, preserving the bounds'
|
|
209
|
+
* zero-pad width; mixed lists expand in input order. Any element that does not
|
|
210
|
+
* cleanly match a valid ascending same-prefix numeric range (mismatched
|
|
211
|
+
* prefix, differing bound width, descending, non-numeric, missing bound, or
|
|
212
|
+
* spanning more than MAX_PHASE_REQ_RANGE IDs) stays literal — no partial
|
|
213
|
+
* expansion, no guessing.
|
|
138
214
|
*
|
|
139
215
|
* Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass
|
|
140
216
|
* the roadmap value through verbatim.
|
|
@@ -151,7 +227,9 @@ function normalizePhaseReqIds(rawVal) {
|
|
|
151
227
|
// Tolerate comma-, space-, or newline-separated lists (callers may pass the
|
|
152
228
|
// roadmap value verbatim, whose serialization is not guaranteed).
|
|
153
229
|
const ids = v.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
|
|
154
|
-
|
|
230
|
+
// Expand range tokens (#1269) per-token AFTER the split, preserving input order.
|
|
231
|
+
const expanded = ids.flatMap(expandPhaseReqIdToken);
|
|
232
|
+
return expanded.length === 0 ? null : expanded;
|
|
155
233
|
}
|
|
156
234
|
function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
157
235
|
const phaseReqIds = normalizePhaseReqIds(options.phaseReqIds);
|
|
@@ -193,7 +271,9 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
|
193
271
|
const ctxFile = findContextMdIn(phaseDirFiles);
|
|
194
272
|
const ctxPath = ctxFile ? node_path_1.default.join(absPhaseDir, ctxFile) : null;
|
|
195
273
|
const ctxMd = ctxPath ? node_fs_1.default.readFileSync(ctxPath, 'utf-8') : '';
|
|
196
|
-
|
|
274
|
+
// Use extractDecisions so gap-checker can distinguish could-not-parse from none-present.
|
|
275
|
+
const ctxExtraction = (0, decisions_cjs_1.extractDecisions)(ctxMd);
|
|
276
|
+
const dItems = ctxExtraction.decisions.map(d => ({ ...d, source: 'CONTEXT.md' }));
|
|
197
277
|
const items = [...reqItems, ...dItems];
|
|
198
278
|
let planText = '';
|
|
199
279
|
try {
|
|
@@ -210,6 +290,41 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
|
210
290
|
}
|
|
211
291
|
}
|
|
212
292
|
catch { /* unreadable */ }
|
|
293
|
+
// FIX D (#1365): surface decision could-not-parse independently of whether
|
|
294
|
+
// requirements items exist. Without this, a could-not-parse on decisions is
|
|
295
|
+
// silently masked whenever REQUIREMENTS.md has ≥1 item — the mismatch must
|
|
296
|
+
// appear in the report regardless of the requirements row count.
|
|
297
|
+
if (ctxExtraction.outcome === 'could-not-parse') {
|
|
298
|
+
const mismatchMsg = '## Post-Planning Gap Analysis\n\nextracted 0 of N — possible format mismatch in CONTEXT.md decisions block.\n';
|
|
299
|
+
// If there are also requirement items, include them in the return with the
|
|
300
|
+
// mismatch summary appended, so the caller still sees requirement coverage.
|
|
301
|
+
if (items.length > 0) {
|
|
302
|
+
const rows = sortRows([
|
|
303
|
+
...detectCoverage(items, planText),
|
|
304
|
+
...ghostReqIds.map(id => ({ source: 'REQUIREMENTS.md', item: id, status: 'Missing from REQUIREMENTS.md' })),
|
|
305
|
+
]);
|
|
306
|
+
const covered = rows.filter(r => r.status === 'Covered').length;
|
|
307
|
+
const uncovered = rows.length - covered;
|
|
308
|
+
const coverageSummary = uncovered === 0
|
|
309
|
+
? `✓ All ${rows.length} items covered by plans`
|
|
310
|
+
: `⚠ ${uncovered} of ${rows.length} items not covered by any plan`;
|
|
311
|
+
return {
|
|
312
|
+
enabled: true,
|
|
313
|
+
rows,
|
|
314
|
+
table: formatGapTable(rows) + '\n' + coverageSummary + '\n\n' + mismatchMsg,
|
|
315
|
+
summary: coverageSummary + '; extracted 0 of N — possible format mismatch',
|
|
316
|
+
counts: { total: rows.length, covered, uncovered },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
enabled: true,
|
|
321
|
+
rows: [],
|
|
322
|
+
table: mismatchMsg,
|
|
323
|
+
summary: 'extracted 0 of N — possible format mismatch',
|
|
324
|
+
counts: { total: 0, covered: 0, uncovered: 0 },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
// #1365: if no items at all, surface a clean no-check message.
|
|
213
328
|
if (items.length === 0) {
|
|
214
329
|
return {
|
|
215
330
|
enabled: true,
|