@dev-loops/core 1.0.0 → 1.0.2-pre.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/package.json +4 -2
- package/src/claude/hook-decisions.mjs +144 -20
- package/src/config/config.mjs +11 -97
- package/src/config/extension-defaults.yaml +18 -7
- package/src/github/comment-id-guard.mjs +38 -0
- package/src/github/gh.mjs +49 -0
- package/src/loop/commit-msg-guard.mjs +1 -1
- package/src/loop/gate-carry-forward.mjs +45 -21
- package/src/loop/gate-evidence-reconcile.mjs +75 -0
- package/src/loop/gate-fanin.mjs +47 -20
- package/src/loop/handoff-envelope.mjs +2 -2
- package/src/loop/issue-refinement-artifact.mjs +608 -75
- package/src/loop/pr-gate-coordination.mjs +27 -2
- package/src/loop/queue-board-sync.mjs +7 -38
- package/src/loop/review-dispatch-plan.mjs +1 -0
- package/src/loop/spec-authority.mjs +759 -0
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/worktree-guard.mjs +55 -0
- package/src/projects/list-queue-items.mjs +1 -30
- package/src/projects/move-queue-item.mjs +1 -30
- package/src/projects/resolve-project.mjs +6 -6
- package/src/security/secret-scan.mjs +13 -1
|
@@ -2,15 +2,35 @@
|
|
|
2
2
|
* Deterministic issue refinement-artifact detection.
|
|
3
3
|
*
|
|
4
4
|
* Implements the bounded refinement check required by the draft gate per
|
|
5
|
-
* issue #532
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* issue #532, reshaped by #1951 to honor "matrix on the issue, checklist on
|
|
6
|
+
* the PR" WITHOUT duplicate issue-side checklists: a draft PR cannot leave
|
|
7
|
+
* draft unless the linked issue carries the authoritative semantic AC→DoD
|
|
8
|
+
* mapping MATRIX (a real two-column table mapping each acceptance-criterion
|
|
9
|
+
* outcome to its required completion evidence) plus an explicit Non-goals
|
|
10
|
+
* section — or a linked refinement doc that is a complete artifact on its own
|
|
11
|
+
* (the doc carries the matrix). Interactive issue-side Acceptance criteria /
|
|
12
|
+
* Definition of done CHECKLISTS are NO LONGER required merely to satisfy
|
|
13
|
+
* detection (#1951 AC1): the matrix is the authoritative issue artifact, and
|
|
14
|
+
* the PR carries the derived self-contained list-form AC/DoD checklists
|
|
15
|
+
* (`derivePrChecklistsFromIssueMatrix`; the PR body is validated by
|
|
16
|
+
* `validateTrackerBackedPrBodySpec`, never this predicate).
|
|
11
17
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
18
|
+
* Detection validates the structural PRESENCE and SHAPE of the mapping table,
|
|
19
|
+
* not its semantic truthfulness (that stays a reviewer responsibility). An
|
|
20
|
+
* issue whose AC content, DoD content, and Non-goals are present but whose
|
|
21
|
+
* mapping table is absent, empty, malformed, or identifier-only fails closed
|
|
22
|
+
* (#1951 AC2) with the matching finding (`missing_ac_dod_matrix`,
|
|
23
|
+
* `malformed_ac_dod_matrix`, `missing_explicit_non_goals`, or
|
|
24
|
+
* `missing_refinement_artifact`); prose-only issues (Problem / Root Cause /
|
|
25
|
+
* Fix) cause the draft gate to post `verdict=blocked` with the
|
|
26
|
+
* `missing_refinement_artifact` finding.
|
|
27
|
+
*
|
|
28
|
+
* Migration (#1951 AC7/D7): existing checklist-bearing issues stay readable —
|
|
29
|
+
* the parser still extracts their AC/DoD checklist content — but a body that
|
|
30
|
+
* carries only checklists and no mapping matrix now fails closed with
|
|
31
|
+
* `missing_ac_dod_matrix` and is re-grilled (loop-grill synthesizes the
|
|
32
|
+
* matrix) rather than being silently grandfathered. No compatibility alias is
|
|
33
|
+
* retained.
|
|
14
34
|
*/
|
|
15
35
|
import { existsSync } from "node:fs";
|
|
16
36
|
import path from "node:path";
|
|
@@ -28,6 +48,7 @@ import path from "node:path";
|
|
|
28
48
|
*/
|
|
29
49
|
|
|
30
50
|
export const REFINEMENT_SOURCE = Object.freeze({
|
|
51
|
+
ISSUE_BODY_MATRIX: "issue-body-matrix",
|
|
31
52
|
ISSUE_BODY_AC: "issue-body-ac",
|
|
32
53
|
ISSUE_BODY_DOD: "issue-body-dod",
|
|
33
54
|
LINKED_DOC: "linked-doc",
|
|
@@ -36,12 +57,14 @@ export const REFINEMENT_SOURCE = Object.freeze({
|
|
|
36
57
|
|
|
37
58
|
const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
|
|
38
59
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
60
|
+
// REFINEMENT_ARTIFACT_SOURCES: the refinement floor vocabulary (#1951). The
|
|
61
|
+
// floor is the authoritative AC→DoD mapping MATRIX plus an explicit Non-goals
|
|
62
|
+
// section (a linked refinement doc remains a complete artifact on its own) —
|
|
63
|
+
// this list is the shape of a COMPLETE artifact, not a menu where any one
|
|
64
|
+
// entry suffices.
|
|
42
65
|
export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
|
|
43
|
-
"
|
|
44
|
-
"
|
|
66
|
+
"AC→DoD mapping matrix (a two-column table)",
|
|
67
|
+
"explicit Non-goals section",
|
|
45
68
|
"linked refinement doc",
|
|
46
69
|
]);
|
|
47
70
|
|
|
@@ -54,6 +77,25 @@ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
|
|
|
54
77
|
*/
|
|
55
78
|
export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
|
|
56
79
|
|
|
80
|
+
/**
|
|
81
|
+
* #1951: finding reported when the issue body carries refinement content (AC
|
|
82
|
+
* content, DoD content, and/or a Non-goals section) but NO authoritative
|
|
83
|
+
* AC→DoD mapping matrix table. Under "matrix on the issue, checklist on the
|
|
84
|
+
* PR" the mapping table is the authoritative issue artifact; interactive
|
|
85
|
+
* issue-side AC/DoD checklists are not a substitute for it. Fails closed so
|
|
86
|
+
* the issue is re-grilled to add the matrix.
|
|
87
|
+
*/
|
|
88
|
+
export const MISSING_AC_DOD_MATRIX_FINDING = "missing_ac_dod_matrix";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* #1951: finding reported when the issue body carries an AC→DoD mapping table
|
|
92
|
+
* but it is empty (header/separator only, no data rows) or identifier-only /
|
|
93
|
+
* tautological (cells such as `AC1 → D1` with no concrete criterion or
|
|
94
|
+
* completion-evidence prose). Structural shape validation only — semantic
|
|
95
|
+
* truthfulness of the mapping stays a reviewer responsibility.
|
|
96
|
+
*/
|
|
97
|
+
export const MALFORMED_AC_DOD_MATRIX_FINDING = "malformed_ac_dod_matrix";
|
|
98
|
+
|
|
57
99
|
/**
|
|
58
100
|
* Canonical list of section headings that satisfy the refinement check.
|
|
59
101
|
* Matching is case-insensitive and tolerates trailing/leading whitespace.
|
|
@@ -62,16 +104,152 @@ export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
|
|
|
62
104
|
* - one DoD-style section (DoD or Definition of Done)
|
|
63
105
|
*/
|
|
64
106
|
const ACCEPTANCE_SECTION_PATTERNS = Object.freeze([
|
|
65
|
-
|
|
107
|
+
// #1877 round-6: index 0 is the exact-canonical ANCHOR family —
|
|
108
|
+
// `^acceptance criteria\b` — so a decorated-variant canonical heading
|
|
109
|
+
// (`## Acceptance criteria (v2)`, `## Definition of done — core`) still lands
|
|
110
|
+
// in the exact bucket rather than matching NO pattern at all (the alias
|
|
111
|
+
// families anchor on the `AC`/`DoD` abbreviations and never fire for the
|
|
112
|
+
// spelled-out phrase). The anchor stays distinct from the alias family below
|
|
113
|
+
// (`/^ac\b/`), so the precedence contract is unchanged: a spelled-out
|
|
114
|
+
// canonical heading always outranks an abbreviation-shaped alias heading.
|
|
115
|
+
/^acceptance criteria\b.*$/i,
|
|
66
116
|
/^ac\b.*$/i,
|
|
67
117
|
]);
|
|
68
118
|
|
|
69
119
|
const DOD_SECTION_PATTERNS = Object.freeze([
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
120
|
+
// Same anchor-family widening as the AC family (see above). #1877 round-7:
|
|
121
|
+
// the ALIAS arms are widened symmetrically with the AC family too — a
|
|
122
|
+
// decorated-variant alias heading (`## DoD (v2)`, `## Done — core`) must land
|
|
123
|
+
// in the alias bucket, not in NO bucket (a `$`-anchored alias silently
|
|
124
|
+
// disarms the PR-side DoD read and false-blocks the issue side).
|
|
125
|
+
/^definition of done\b.*$/i,
|
|
126
|
+
/^done\b.*$/i,
|
|
127
|
+
/^dod\b.*$/i,
|
|
73
128
|
]);
|
|
74
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Normalize a heading name before section-pattern matching (#1877 round-6
|
|
132
|
+
* parser hardening): GitHub authors legitimately write decorated canonical
|
|
133
|
+
* headings — `## **Acceptance criteria**`, `## Acceptance criteria:`,
|
|
134
|
+
* `## Acceptance criteria ##` — and the raw ATX capture (`match[2]`)
|
|
135
|
+
* fails every pattern family on them, silently disarming the deterministic
|
|
136
|
+
* AC/DoD reads (PR-side extractor fail-open; issue-side false
|
|
137
|
+
* missing_refinement_artifact). Strip the harmless decoration once, at the
|
|
138
|
+
* parse boundary, so exact-vs-alias precedence stays intact: a normalized
|
|
139
|
+
* `Acceptance criteria` still matches the exact pattern, a decorated alias
|
|
140
|
+
* still matches its alias family. Strips: surrounding emphasis runs of any
|
|
141
|
+
* of `*`/`_` (bold `**`/`__` and single-char italic `*`/`_` alike, #1877
|
|
142
|
+
* round-7), surrounding backtick runs, trailing `:` and surrounding
|
|
143
|
+
* whitespace.
|
|
144
|
+
* Deliberately NOT touched: interior text (a real `AC (v2) - final` name keeps
|
|
145
|
+
* its interior), leading `#` (ATX markers never reach `match[2]`), and any
|
|
146
|
+
* decoration a section pattern itself could rely on (none does — every family
|
|
147
|
+
* anchors at the name's start).
|
|
148
|
+
*/
|
|
149
|
+
function normalizeHeadingName(name) {
|
|
150
|
+
if (typeof name !== "string") return name;
|
|
151
|
+
return name
|
|
152
|
+
// trailing decoration first: closing `##` ATX-style, colons, whitespace
|
|
153
|
+
.replace(/\s*:*\s*$/u, "")
|
|
154
|
+
.replace(/\s*#+\s*$/u, "")
|
|
155
|
+
// surrounding emphasis/backtick runs (any length, must pair; #1877
|
|
156
|
+
// round-7: a run may be single-char italic `*`/`_` as well as bold
|
|
157
|
+
// `**`/`__`, so `## *Acceptance criteria*` and `## _Definition of done_`
|
|
158
|
+
// normalize exactly like their bold forms)
|
|
159
|
+
.replace(/^[*_`]+/u, "")
|
|
160
|
+
.replace(/[*_`]+$/u, "")
|
|
161
|
+
.trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// #1877 alias-precedence: exact canonical headings (the first pattern in each
|
|
165
|
+
// family) must outrank loose aliases (`/^ac\b/`, `/^dod\b/`) so a matrix-shaped
|
|
166
|
+
// heading the refined-issue contract itself produces (`## AC/DoD matrix`,
|
|
167
|
+
// `## AC → DoD mapping`) can never hijack the canonical section read. Split
|
|
168
|
+
// each pattern family into [exact, aliases] by convention: pattern index 0
|
|
169
|
+
// is the exact canonical match, the rest are aliases.
|
|
170
|
+
const EXACT_PATTERN_INDEX = 0;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve the sections matching a heading-pattern family with exact-first
|
|
174
|
+
* precedence (#1877): the first section matching the EXACT canonical pattern
|
|
175
|
+
* (index 0) wins over any earlier section that only matched a loose alias
|
|
176
|
+
* (e.g. `## AC/DoD matrix` before `## Acceptance criteria`). When no exact
|
|
177
|
+
* match exists, the first alias match is returned (alias-only bodies keep
|
|
178
|
+
* working). Returns null when no section matches at all.
|
|
179
|
+
*/
|
|
180
|
+
function findSectionByPatterns(sections, patterns) {
|
|
181
|
+
const exact = patterns[EXACT_PATTERN_INDEX];
|
|
182
|
+
for (const section of sections) {
|
|
183
|
+
if (exact.test(section.name)) {
|
|
184
|
+
return section;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const section of sections) {
|
|
188
|
+
for (let i = 1; i < patterns.length; i += 1) {
|
|
189
|
+
if (patterns[i].test(section.name)) {
|
|
190
|
+
return section;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Collect ALL sections matching a heading-pattern family, exact-first ordered
|
|
199
|
+
* (exact canonical matches before alias-only matches). Shared with
|
|
200
|
+
* `findSectionByPatterns`'s precedence semantics so single-section consumers
|
|
201
|
+
* and union consumers (#1877 PR-body unchecked-box extraction) cannot drift.
|
|
202
|
+
*/
|
|
203
|
+
function findAllSectionsByPatterns(sections, patterns) {
|
|
204
|
+
const exact = patterns[EXACT_PATTERN_INDEX];
|
|
205
|
+
const exactMatches = [];
|
|
206
|
+
const aliasMatches = [];
|
|
207
|
+
for (const section of sections) {
|
|
208
|
+
if (exact.test(section.name)) {
|
|
209
|
+
exactMatches.push(section);
|
|
210
|
+
} else {
|
|
211
|
+
for (let i = 1; i < patterns.length; i += 1) {
|
|
212
|
+
if (patterns[i].test(section.name)) {
|
|
213
|
+
aliasMatches.push(section);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return [...exactMatches, ...aliasMatches];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Flatten a section (heading record) into a body string that extends past
|
|
224
|
+
* `###` sub-headings (#1877): a section's checklist may nest items under
|
|
225
|
+
* deeper sub-headings (`### edge cases` inside `## Acceptance criteria`), so
|
|
226
|
+
* join the section and every following section of a DEEPER heading level up
|
|
227
|
+
* to the next same-or-shallower heading. `parseMarkdownSections` terminates a
|
|
228
|
+
* section's `bodyLines` at ANY heading, which is correct for heading
|
|
229
|
+
* matching but hides unchecked boxes from consumers that must see ALL of a
|
|
230
|
+
* canonical section's boxes.
|
|
231
|
+
*/
|
|
232
|
+
function flattenSectionDeep(sections, startIndex) {
|
|
233
|
+
const start = sections[startIndex];
|
|
234
|
+
// #1877 round-6 heading-name re-injection fix: the raw sub-heading NAME is
|
|
235
|
+
// NEVER re-injected into the text the checklist parser re-parses. A name is
|
|
236
|
+
// a different input class from checklist body text: a fence-opening name
|
|
237
|
+
// (`### ``` `) used to corrupt the parser's fence state and eat every real
|
|
238
|
+
// box after it (fail-open), and a checkbox-shaped name (`### - [ ] fake`)
|
|
239
|
+
// used to be counted as a phantom unchecked item (spurious fail-closed).
|
|
240
|
+
// Only already-classified bodyLines are joined — a heading can never match
|
|
241
|
+
// any line-level grammar, and real boxes under sub-headings stay visible
|
|
242
|
+
// because their bodyLines still join normally. (Keeping a marker line is
|
|
243
|
+
// unnecessary: parseChecklistItems never needed the heading boundary to
|
|
244
|
+
// track fence state — body lines carry their own fences.)
|
|
245
|
+
const parts = [start.bodyLines.join("\n")];
|
|
246
|
+
for (let i = startIndex + 1; i < sections.length; i += 1) {
|
|
247
|
+
if (sections[i].level <= start.level) break;
|
|
248
|
+
parts.push(sections[i].bodyLines.join("\n"));
|
|
249
|
+
}
|
|
250
|
+
return parts.join("\n");
|
|
251
|
+
}
|
|
252
|
+
|
|
75
253
|
/**
|
|
76
254
|
* Fenced-code-span tracker. Given the previous fence state and the current
|
|
77
255
|
* line, returns { fence, insideFence } where:
|
|
@@ -135,7 +313,12 @@ export function parseMarkdownSections(body) {
|
|
|
135
313
|
}
|
|
136
314
|
current = {
|
|
137
315
|
level: match[1].length,
|
|
138
|
-
|
|
316
|
+
// #1877 round-6: normalize the captured name so decorated canonical
|
|
317
|
+
// headings (`## **Acceptance criteria**`) match the section patterns.
|
|
318
|
+
// The RAW name is never re-parsed as body text (flattenSectionDeep no
|
|
319
|
+
// longer re-injects it), so normalization is the only consumer of the
|
|
320
|
+
// capture — the raw form is not retained (no consumer reads it).
|
|
321
|
+
name: normalizeHeadingName(match[2]),
|
|
139
322
|
bodyLines: [],
|
|
140
323
|
};
|
|
141
324
|
continue;
|
|
@@ -152,22 +335,16 @@ export function parseMarkdownSections(body) {
|
|
|
152
335
|
return sections;
|
|
153
336
|
}
|
|
154
337
|
|
|
155
|
-
function findSectionByPatterns(sections, patterns) {
|
|
156
|
-
for (const section of sections) {
|
|
157
|
-
for (const pattern of patterns) {
|
|
158
|
-
if (pattern.test(section.name)) {
|
|
159
|
-
return section;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
338
|
|
|
166
339
|
/**
|
|
167
340
|
* Parse bullet/checkbox items from a section body into item states. Each
|
|
168
|
-
* checkbox item
|
|
169
|
-
*
|
|
170
|
-
*
|
|
341
|
+
* checkbox item — any GFM/CommonMark task-list marker: `-`/`*`/`+` bullets,
|
|
342
|
+
* ordered `N.`/`N)`, and blockquote-nested `> - [ ]` (#1877 round-6 grammar
|
|
343
|
+
* widening; parity with tick-verified-checkboxes.mjs's `[-*+]`) — becomes
|
|
344
|
+
* `{ text, checked }` (`checked` true only for a ticked `[x]`/`[X]` marker,
|
|
345
|
+
* read from the captured marker group, never a whole-line re-test); a
|
|
346
|
+
* top-level plain bullet (`- text`, dash at column 0 so nested/indented
|
|
347
|
+
* sub-bullets are not counted)
|
|
171
348
|
* becomes `{ text, checked: null }` — it has no checkbox to tick. Empty
|
|
172
349
|
* checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
|
|
173
350
|
* not counted, so a section of only unfilled placeholders reports as unrefined.
|
|
@@ -197,17 +374,30 @@ function parseChecklistItems(sectionBody) {
|
|
|
197
374
|
if (step.insideFence) {
|
|
198
375
|
continue;
|
|
199
376
|
}
|
|
200
|
-
// Checklist item:
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
377
|
+
// Checklist item: GFM/CommonMark task-list markers (#1877 round-6 parser
|
|
378
|
+
// hardening): `-`/`*`/`+` bullets, ordered `N.`/`N)`, and blockquote-nested
|
|
379
|
+
// `> - [ ]` — the forms GitHub itself renders as interactive checkboxes.
|
|
380
|
+
// Grammar parity with tick-verified-checkboxes.mjs's CHECKBOX_RE (same
|
|
381
|
+
// #1877 round-1 widening): both accept bullets, ordered markers, and
|
|
382
|
+
// blockquote-nested forms, so every form this extractor surfaces as
|
|
383
|
+
// unchecked is flippable by the tick tool. Consume ANY checkbox-marker line
|
|
384
|
+
// here; push only when it carries text, so empty placeholders (`- [ ]`) are
|
|
385
|
+
// skipped rather than counted. #1877 round-7: the tick state comes from the
|
|
386
|
+
// CAPTURED marker group of this single match — never a second whole-line
|
|
387
|
+
// re-test. An unanchored `/\[(?:[xX])\]/u.test(line)` reads an UNCHECKED box
|
|
388
|
+
// whose label text merely mentions `[x]` (e.g. `- [ ] verify [x] flags`) as
|
|
389
|
+
// checked, silently disarming the deterministic block — the exact
|
|
390
|
+
// fail-open class the marker-anchored pre-#1877 read could not produce.
|
|
391
|
+
const checkboxMatch =
|
|
392
|
+
/^\s*(?:>|\s)*(?:[-*+]|\d+[.)])\s+\[([ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
|
|
204
393
|
if (checkboxMatch) {
|
|
205
|
-
const text = (checkboxMatch[
|
|
394
|
+
const text = (checkboxMatch[2] ?? "").trim();
|
|
206
395
|
if (text.length > 0) {
|
|
207
|
-
// `checked` is true only for a ticked
|
|
396
|
+
// `checked` is true only for a ticked marker (`[x]`/`[X]`); a space
|
|
397
|
+
// marker (`[ ]`) is false — regardless of what the label text says.
|
|
208
398
|
// A plain bullet has no checkbox, so it stays `null` below — it is
|
|
209
399
|
// neither ticked nor unticked and does not count as an unticked AC.
|
|
210
|
-
items.push({ text, checked:
|
|
400
|
+
items.push({ text, checked: checkboxMatch[1] !== " " });
|
|
211
401
|
}
|
|
212
402
|
continue;
|
|
213
403
|
}
|
|
@@ -251,6 +441,214 @@ export function extractUncheckedChecklistItems(sectionBody) {
|
|
|
251
441
|
.map((item) => item.text);
|
|
252
442
|
}
|
|
253
443
|
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// AC→DoD mapping matrix detection (#1951)
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// The authoritative refined-issue artifact is a semantic AC→DoD mapping table:
|
|
448
|
+
// a GFM pipe table whose rows map each acceptance-criterion outcome to its
|
|
449
|
+
// required completion evidence. This is the "matrix on the issue" half of
|
|
450
|
+
// "matrix on the issue, checklist on the PR". Detection validates the table's
|
|
451
|
+
// PRESENCE and SHAPE only — its semantic truthfulness stays a reviewer duty.
|
|
452
|
+
|
|
453
|
+
// Heading families that name the mapping-matrix section. A qualifying table
|
|
454
|
+
// under one of these headings is treated as the matrix even when its column
|
|
455
|
+
// headers do not name criterion/evidence explicitly.
|
|
456
|
+
const MATRIX_SECTION_PATTERNS = Object.freeze([
|
|
457
|
+
/\bac\b.*\bdod\b.*\b(matrix|mapping|map)\b/i,
|
|
458
|
+
/\b(acceptance|criteri\w*)\b.*\b(matrix|mapping|map)\b/i,
|
|
459
|
+
/\bmapping (matrix|table)\b/i,
|
|
460
|
+
/\bac\s*(?:\/|→|->|to)\s*dod\b/i,
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
// Header column families: col0 names the criterion side, col1 the evidence
|
|
464
|
+
// side. Used to recognize an unheaded (not under a matrix heading) but clearly
|
|
465
|
+
// criterion→evidence table anywhere in the body. Kept STRONG on purpose (#1951
|
|
466
|
+
// draft_gate correctness review): a generic status table like `| Outcome |
|
|
467
|
+
// Done |` must NOT be mistaken for the refinement matrix — only headers that
|
|
468
|
+
// explicitly name acceptance criteria AND completion evidence / DoD qualify
|
|
469
|
+
// without a matrix heading. A matrix under a weaker header still qualifies via
|
|
470
|
+
// its `## AC / DoD matrix` heading (MATRIX_SECTION_PATTERNS), which is what the
|
|
471
|
+
// loop-grill synthesis and the epic procedure both write.
|
|
472
|
+
const MATRIX_CRITERION_HEADER = /\b(criteri\w*|acceptance|ac)\b/i;
|
|
473
|
+
const MATRIX_EVIDENCE_HEADER = /\b(evidence|dod|definition of done)\b/i;
|
|
474
|
+
|
|
475
|
+
const TABLE_DELIMITER_RE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/u;
|
|
476
|
+
|
|
477
|
+
/** Split one GFM table row into trimmed cell strings (drops leading/trailing pipes). */
|
|
478
|
+
function splitTableRow(line) {
|
|
479
|
+
let s = line.trim();
|
|
480
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
481
|
+
if (s.endsWith("|")) s = s.slice(0, -1);
|
|
482
|
+
// ponytail: no escaped-pipe (`\|`) handling — refined-issue matrix cells are
|
|
483
|
+
// short prose, not pipe-bearing code. Add a split-on-unescaped-pipe pass only
|
|
484
|
+
// if a real matrix cell ever needs a literal `|`.
|
|
485
|
+
return s.split("|").map((c) => c.trim());
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Count real prose words in a matrix cell: runs of >=3 letters that are not the
|
|
490
|
+
* `dod` identifier token. Bare identifiers (`AC1`, `D1`, `DoD`), arrows, and
|
|
491
|
+
* digits contribute nothing, so a tautological/identifier-only cell scores 0.
|
|
492
|
+
*/
|
|
493
|
+
function cellProseWordCount(cell) {
|
|
494
|
+
if (typeof cell !== "string") return 0;
|
|
495
|
+
const stripped = cell.replace(/[*_`]+/gu, " ");
|
|
496
|
+
const runs = stripped.match(/[A-Za-z]{3,}/gu) ?? [];
|
|
497
|
+
return runs.filter((w) => w.toLowerCase() !== "dod").length;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// A matrix data row is semantic when BOTH mapped cells carry at least one real
|
|
501
|
+
// prose word (a letter-run of >=3 chars, excluding the `dod` token). This
|
|
502
|
+
// rejects the identifier-only/tautological rows the contract names — `AC1 | D1`,
|
|
503
|
+
// `AC1 → D1`, `DoD`, and empty cells (all 0 prose words) — WITHOUT false-
|
|
504
|
+
// rejecting a legitimately terse-but-real mapping (e.g. `Feature works |
|
|
505
|
+
// Regression test added`). The threshold is deliberately >=1, not >=2: the goal
|
|
506
|
+
// is to reject bare identifiers, not to mandate a minimum verbosity (#1951
|
|
507
|
+
// draft_gate/Copilot review).
|
|
508
|
+
function rowIsSemantic(criterion, evidence) {
|
|
509
|
+
return cellProseWordCount(criterion) >= 1 && cellProseWordCount(evidence) >= 1;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Parse every GFM pipe table in a Markdown body (skipping fenced code spans via
|
|
514
|
+
* the shared `stepFence`). Returns an array of
|
|
515
|
+
* `{ heading, headerCells, rows }` where `rows` is the list of data rows (each
|
|
516
|
+
* an array of trimmed cell strings). A table is a header line containing `|`,
|
|
517
|
+
* a delimiter row (`|---|---|`), and >=0 data rows.
|
|
518
|
+
*/
|
|
519
|
+
function parseMarkdownTables(body) {
|
|
520
|
+
if (typeof body !== "string" || body.length === 0) return [];
|
|
521
|
+
const lines = body.split(/\r?\n/u);
|
|
522
|
+
const tables = [];
|
|
523
|
+
let fence = null;
|
|
524
|
+
let heading = null;
|
|
525
|
+
let i = 0;
|
|
526
|
+
while (i < lines.length) {
|
|
527
|
+
const step = stepFence(fence, lines[i]);
|
|
528
|
+
fence = step.fence;
|
|
529
|
+
if (step.insideFence) {
|
|
530
|
+
i += 1;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const headingMatch = /^(#{1,6})\s+(.+?)\s*$/u.exec(lines[i]);
|
|
534
|
+
if (headingMatch) {
|
|
535
|
+
heading = normalizeHeadingName(headingMatch[2]);
|
|
536
|
+
i += 1;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const header = lines[i];
|
|
540
|
+
const delim = lines[i + 1];
|
|
541
|
+
if (header.includes("|") && typeof delim === "string" && TABLE_DELIMITER_RE.test(delim)) {
|
|
542
|
+
const headerCells = splitTableRow(header);
|
|
543
|
+
const rows = [];
|
|
544
|
+
let j = i + 2;
|
|
545
|
+
while (j < lines.length) {
|
|
546
|
+
const rowStep = stepFence(fence, lines[j]);
|
|
547
|
+
// A table ends at the first non-fence line without a pipe, or a heading.
|
|
548
|
+
if (rowStep.insideFence) break;
|
|
549
|
+
if (!lines[j].includes("|") || /^#{1,6}\s+/u.test(lines[j])) break;
|
|
550
|
+
rows.push(splitTableRow(lines[j]));
|
|
551
|
+
j += 1;
|
|
552
|
+
}
|
|
553
|
+
tables.push({ heading, headerCells, rows });
|
|
554
|
+
i = j;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
i += 1;
|
|
558
|
+
}
|
|
559
|
+
return tables;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Detect the authoritative AC→DoD mapping matrix in an issue body.
|
|
564
|
+
*
|
|
565
|
+
* A qualifying table has >=2 columns and EITHER sits under a matrix-named
|
|
566
|
+
* heading ({@link MATRIX_SECTION_PATTERNS}) OR names criterion/evidence-like
|
|
567
|
+
* columns in its header. The matrix is VALID when it carries at least one
|
|
568
|
+
* SEMANTIC data row (both mapped cells carry real prose — see
|
|
569
|
+
* {@link rowIsSemantic}); a header/separator-only table (no data rows) or a
|
|
570
|
+
* table whose rows are all identifier-only/tautological (`AC1 → D1`) is
|
|
571
|
+
* malformed.
|
|
572
|
+
*
|
|
573
|
+
* @param {string} [body]
|
|
574
|
+
* @returns {{ found: boolean, valid: boolean, rowCount: number, rows: { criterion: string, evidence: string }[], reason: string }}
|
|
575
|
+
*/
|
|
576
|
+
export function detectAcDodMatrix(body = "") {
|
|
577
|
+
const tables = parseMarkdownTables(body);
|
|
578
|
+
const candidates = tables.filter((t) => {
|
|
579
|
+
if (!Array.isArray(t.headerCells) || t.headerCells.length < 2) return false;
|
|
580
|
+
const underHeading = typeof t.heading === "string" &&
|
|
581
|
+
MATRIX_SECTION_PATTERNS.some((p) => p.test(t.heading));
|
|
582
|
+
const headerNamesMap =
|
|
583
|
+
MATRIX_CRITERION_HEADER.test(t.headerCells[0] ?? "") &&
|
|
584
|
+
MATRIX_EVIDENCE_HEADER.test(t.headerCells[1] ?? "");
|
|
585
|
+
return underHeading || headerNamesMap;
|
|
586
|
+
});
|
|
587
|
+
if (candidates.length === 0) {
|
|
588
|
+
return { found: false, valid: false, rowCount: 0, rows: [], reason: "No AC→DoD mapping matrix table found." };
|
|
589
|
+
}
|
|
590
|
+
// Prefer the first candidate that has >=1 semantic row; otherwise report the
|
|
591
|
+
// first candidate as malformed.
|
|
592
|
+
for (const table of candidates) {
|
|
593
|
+
const semanticRows = [];
|
|
594
|
+
for (const cells of table.rows) {
|
|
595
|
+
if (cells.length < 2) continue;
|
|
596
|
+
const criterion = cells[0] ?? "";
|
|
597
|
+
const evidence = cells[1] ?? "";
|
|
598
|
+
if (rowIsSemantic(criterion, evidence)) {
|
|
599
|
+
semanticRows.push({ criterion, evidence });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (semanticRows.length > 0) {
|
|
603
|
+
return {
|
|
604
|
+
found: true,
|
|
605
|
+
valid: true,
|
|
606
|
+
rowCount: semanticRows.length,
|
|
607
|
+
rows: semanticRows,
|
|
608
|
+
reason: `Found an AC→DoD mapping matrix with ${semanticRows.length} semantic row(s).`,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const dataRowCount = candidates[0].rows.length;
|
|
613
|
+
return {
|
|
614
|
+
found: true,
|
|
615
|
+
valid: false,
|
|
616
|
+
rowCount: 0,
|
|
617
|
+
rows: [],
|
|
618
|
+
reason: dataRowCount === 0
|
|
619
|
+
? "AC→DoD mapping matrix table is empty (header/separator only, no data rows)."
|
|
620
|
+
: "AC→DoD mapping matrix table is identifier-only/tautological (no row maps a concrete criterion to concrete completion evidence).",
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Project an issue's AC→DoD mapping matrix into self-contained list-form PR
|
|
626
|
+
* checklists (#1951 AC4): the PR carries list-form Acceptance criteria and
|
|
627
|
+
* Definition of done checkboxes derived from the matrix — never a matrix/table,
|
|
628
|
+
* never checkboxes inside table cells. Accepts a pre-parsed `matrix` (from
|
|
629
|
+
* {@link detectAcDodMatrix}) or a raw `body` to parse. Fails closed on a
|
|
630
|
+
* missing/malformed matrix rather than emitting empty checklists.
|
|
631
|
+
*
|
|
632
|
+
* @param {{ matrix?: ReturnType<typeof detectAcDodMatrix>, body?: string }} input
|
|
633
|
+
* @returns {{ acChecklist: string[], dodChecklist: string[], markdown: string }}
|
|
634
|
+
*/
|
|
635
|
+
export function derivePrChecklistsFromIssueMatrix({ matrix = null, body = "" } = {}) {
|
|
636
|
+
const m = matrix ?? detectAcDodMatrix(body);
|
|
637
|
+
if (!m || !m.found || !m.valid || !Array.isArray(m.rows) || m.rows.length === 0) {
|
|
638
|
+
throw Object.assign(
|
|
639
|
+
new Error(`derivePrChecklistsFromIssueMatrix: ${m?.reason ?? "no valid AC→DoD mapping matrix to project"}`),
|
|
640
|
+
{ code: "MALFORMED_MATRIX_SOURCE" },
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
const dedupe = (items) => [...new Set(items.map((s) => s.trim()).filter((s) => s.length > 0))];
|
|
644
|
+
const acChecklist = dedupe(m.rows.map((r) => r.criterion));
|
|
645
|
+
const dodChecklist = dedupe(m.rows.map((r) => r.evidence));
|
|
646
|
+
const render = (heading, items) =>
|
|
647
|
+
`## ${heading}\n\n${items.map((t) => `- [ ] ${t}`).join("\n")}\n`;
|
|
648
|
+
const markdown = `${render("Acceptance criteria", acChecklist)}\n${render("Definition of done", dodChecklist)}`;
|
|
649
|
+
return { acChecklist, dodChecklist, markdown };
|
|
650
|
+
}
|
|
651
|
+
|
|
254
652
|
/**
|
|
255
653
|
* Detect a linked refinement doc path from the issue body.
|
|
256
654
|
* Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
|
|
@@ -308,9 +706,12 @@ export function detectLinkedRefinementDoc(body) {
|
|
|
308
706
|
* `validatePrBodySpec` (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns`),
|
|
309
707
|
* so the two spec surfaces cannot drift on what counts as an explicit
|
|
310
708
|
* Non-goals section. `hasACs` keeps its caller-facing meaning: true only when
|
|
311
|
-
* the FULL check passes
|
|
312
|
-
*
|
|
313
|
-
*
|
|
709
|
+
* the FULL check passes (#1951: a valid AC→DoD mapping matrix plus an explicit
|
|
710
|
+
* Non-goals section, or a resolvable linked refinement doc plus Non-goals), so
|
|
711
|
+
* every `.hasACs` consumer (enqueue gate, draft gate, parked-items discovery,
|
|
712
|
+
* gate context) fails closed with no call-site change. `acItems`/`dodItems`
|
|
713
|
+
* stay populated for downstream consumers: from the issue's own checklist
|
|
714
|
+
* sections when present, otherwise projected from the matrix rows.
|
|
314
715
|
*
|
|
315
716
|
* `resolveLinkedDoc` (optional, #1866): a `(path) => boolean` callback used to
|
|
316
717
|
* verify that a linked `tmp/refinement/*.md` doc actually resolves (e.g.
|
|
@@ -340,6 +741,7 @@ export function detectLinkedRefinementDoc(body) {
|
|
|
340
741
|
* dodItems: string[],
|
|
341
742
|
* sections: string[],
|
|
342
743
|
* linkedDoc: { found: boolean, path: string|null, reason: string, resolves?: boolean },
|
|
744
|
+
* matrix: { found: boolean, valid: boolean, rowCount: number, rows: { criterion: string, evidence: string }[], reason: string },
|
|
343
745
|
* reason: string,
|
|
344
746
|
* finding: string|null,
|
|
345
747
|
* }}
|
|
@@ -355,7 +757,8 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
355
757
|
dodItems: [],
|
|
356
758
|
sections: [],
|
|
357
759
|
linkedDoc: { found: false, path: null, reason: "empty-body" },
|
|
358
|
-
|
|
760
|
+
matrix: { found: false, valid: false, rowCount: 0, rows: [], reason: "empty-body" },
|
|
761
|
+
reason: "Issue body is empty; no matrix/ACs/DoD/linked-doc can be detected.",
|
|
359
762
|
finding: REFINEMENT_ARTIFACT_FINDING,
|
|
360
763
|
};
|
|
361
764
|
}
|
|
@@ -366,6 +769,18 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
366
769
|
const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
|
|
367
770
|
const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
|
|
368
771
|
|
|
772
|
+
// CONSUMER-CONTRACT BOUNDARY (#1877, intentional asymmetry): the issue-side
|
|
773
|
+
// reads above are strict — ONE exact-first section, NO deep flattening —
|
|
774
|
+
// while extractPrBodyUncheckedChecklistItems (PR side) unions ALL matching
|
|
775
|
+
// sections and deep-flattens past ### sub-headings. The issue side is a
|
|
776
|
+
// presence check of the refinement matrix: a checklist hidden entirely
|
|
777
|
+
// under a ### sub-heading fails CLOSED (reported missing, the issue stays
|
|
778
|
+
// parked for human refinement). The PR side enforces a hard gate over the
|
|
779
|
+
// derived checklist: it must NEVER miss an unchecked box, so it fails open
|
|
780
|
+
// on nothing — it unions and deep-flattens. Do not "unify" these reads: the
|
|
781
|
+
// two failure directions are both deliberate (issue side = safe direction,
|
|
782
|
+
// PR side = fail-closed gate).
|
|
783
|
+
|
|
369
784
|
const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
|
|
370
785
|
// Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
|
|
371
786
|
// ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
|
|
@@ -389,13 +804,15 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
389
804
|
findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
|
|
390
805
|
);
|
|
391
806
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
807
|
+
// #1951: the authoritative issue artifact is the AC→DoD mapping MATRIX, not
|
|
808
|
+
// duplicate interactive issue-side checklists. Detect its presence + shape.
|
|
809
|
+
const matrix = detectAcDodMatrix(body);
|
|
810
|
+
|
|
811
|
+
// Keep acItems/dodItems populated for downstream consumers (gate context,
|
|
812
|
+
// coordination state) even when the issue carries only the matrix and no
|
|
813
|
+
// interactive checklists: project the matrix rows into AC/DoD items.
|
|
814
|
+
const effectiveAcItems = acItems.length > 0 ? acItems : matrix.rows.map((r) => r.criterion.trim()).filter(Boolean);
|
|
815
|
+
const effectiveDodItems = dodItems.length > 0 ? dodItems : matrix.rows.map((r) => r.evidence.trim()).filter(Boolean);
|
|
399
816
|
|
|
400
817
|
const base = {
|
|
401
818
|
hasNonGoals,
|
|
@@ -404,39 +821,24 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
404
821
|
dodItems,
|
|
405
822
|
sections: sectionNames,
|
|
406
823
|
linkedDoc,
|
|
824
|
+
matrix,
|
|
407
825
|
};
|
|
408
826
|
|
|
409
|
-
|
|
827
|
+
// A linked refinement doc remains a complete artifact on its own (the doc
|
|
828
|
+
// carries the matrix). It still requires an explicit Non-goals section.
|
|
829
|
+
if (linkedDocResolves) {
|
|
410
830
|
if (!hasNonGoals) {
|
|
411
831
|
return {
|
|
412
832
|
...base,
|
|
413
833
|
hasACs: false,
|
|
414
|
-
source:
|
|
834
|
+
source: REFINEMENT_SOURCE.LINKED_DOC,
|
|
415
835
|
reason:
|
|
416
|
-
|
|
836
|
+
"Issue body links a refinement doc but has no explicit Non-goals section; " +
|
|
417
837
|
"the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
|
|
418
838
|
"e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
|
|
419
839
|
finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
|
|
420
840
|
};
|
|
421
841
|
}
|
|
422
|
-
if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_AC) {
|
|
423
|
-
return {
|
|
424
|
-
...base,
|
|
425
|
-
hasACs: true,
|
|
426
|
-
source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
|
|
427
|
-
reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
|
|
428
|
-
finding: null,
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_DOD) {
|
|
432
|
-
return {
|
|
433
|
-
...base,
|
|
434
|
-
hasACs: true,
|
|
435
|
-
source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
|
|
436
|
-
reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
|
|
437
|
-
finding: null,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
842
|
return {
|
|
441
843
|
...base,
|
|
442
844
|
hasACs: true,
|
|
@@ -449,6 +851,67 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
449
851
|
};
|
|
450
852
|
}
|
|
451
853
|
|
|
854
|
+
// Matrix present but empty/malformed/identifier-only: fail closed (#1951 AC2).
|
|
855
|
+
if (matrix.found && !matrix.valid) {
|
|
856
|
+
return {
|
|
857
|
+
...base,
|
|
858
|
+
hasACs: false,
|
|
859
|
+
source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
|
|
860
|
+
reason:
|
|
861
|
+
`Issue body carries an AC→DoD mapping matrix but it is not a valid semantic mapping (${matrix.reason}); ` +
|
|
862
|
+
"the refinement contract requires a real criterion→completion-evidence mapping " +
|
|
863
|
+
"(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run the loop-grill synthesis). " +
|
|
864
|
+
"Refusing: the refinement check fails closed on a malformed/identifier-only matrix.",
|
|
865
|
+
finding: MALFORMED_AC_DOD_MATRIX_FINDING,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Matrix present and valid: the refinement floor is the matrix + explicit
|
|
870
|
+
// Non-goals. Interactive issue-side AC/DoD checklists are NOT required.
|
|
871
|
+
if (matrix.found && matrix.valid) {
|
|
872
|
+
if (!hasNonGoals) {
|
|
873
|
+
return {
|
|
874
|
+
...base,
|
|
875
|
+
hasACs: false,
|
|
876
|
+
source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
|
|
877
|
+
reason:
|
|
878
|
+
"Issue body carries a valid AC→DoD mapping matrix but no explicit Non-goals section; " +
|
|
879
|
+
"the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
|
|
880
|
+
"e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
|
|
881
|
+
finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
...base,
|
|
886
|
+
hasACs: true,
|
|
887
|
+
source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
|
|
888
|
+
acItems: effectiveAcItems,
|
|
889
|
+
dodItems: effectiveDodItems,
|
|
890
|
+
reason: `Found a valid AC→DoD mapping matrix with ${matrix.rowCount} semantic row(s) and an explicit Non-goals section.`,
|
|
891
|
+
finding: null,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Matrix absent. If the body carries AC/DoD checklist content it is a
|
|
896
|
+
// checklist-bearing issue missing the authoritative matrix (#1951 AC2 /
|
|
897
|
+
// migration): fail closed on the missing matrix so it is re-grilled. A body
|
|
898
|
+
// with no matrix and no AC/DoD content (prose-only, or only a Non-goals
|
|
899
|
+
// section / an unresolved linked-doc mention) stays the pre-existing
|
|
900
|
+
// missing_refinement_artifact.
|
|
901
|
+
if (acItems.length > 0 || dodItems.length > 0) {
|
|
902
|
+
return {
|
|
903
|
+
...base,
|
|
904
|
+
hasACs: false,
|
|
905
|
+
source: REFINEMENT_SOURCE.MISSING,
|
|
906
|
+
reason:
|
|
907
|
+
"Issue body carries Acceptance criteria / Definition of done content but no authoritative AC→DoD mapping matrix table; " +
|
|
908
|
+
"under matrix-on-issue/checklist-on-PR the mapping table is the authoritative issue artifact " +
|
|
909
|
+
"(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run the loop-grill synthesis). " +
|
|
910
|
+
"Refusing: the refinement check fails closed without the mapping matrix.",
|
|
911
|
+
finding: MISSING_AC_DOD_MATRIX_FINDING,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
452
915
|
return {
|
|
453
916
|
...base,
|
|
454
917
|
hasACs: false,
|
|
@@ -456,7 +919,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
456
919
|
acItems: [],
|
|
457
920
|
uncheckedAcItems: [],
|
|
458
921
|
dodItems: [],
|
|
459
|
-
reason: "Issue body has no
|
|
922
|
+
reason: "Issue body has no AC→DoD mapping matrix, no Acceptance criteria/DoD content, and no linked refinement doc.",
|
|
460
923
|
finding: REFINEMENT_ARTIFACT_FINDING,
|
|
461
924
|
};
|
|
462
925
|
}
|
|
@@ -730,6 +1193,57 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
|
|
|
730
1193
|
return validatePrBodySpec({ body, expectedIssue, requireOpenQuestions: false });
|
|
731
1194
|
}
|
|
732
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* #1877: extract the UNCHECKED AC/DoD checkbox items from a PR body's own
|
|
1198
|
+
* Acceptance criteria / Definition of done checklists — the derived,
|
|
1199
|
+
* self-contained checklist that mirrors the linked issue's AC/DoD/Non-goals
|
|
1200
|
+
* matrix. Any unchecked `- [ ]` in those sections means an acceptance
|
|
1201
|
+
* criterion or definition-of-done item is still open, and the deterministic
|
|
1202
|
+
* pre-approval block (`upsert-checkpoint-verdict.mjs`) fails the gate closed:
|
|
1203
|
+
* the round is `blocked` and the PR cannot reach approval with an open
|
|
1204
|
+
* acceptance criterion. This enforces COMPLETENESS (nothing left
|
|
1205
|
+
* unchecked/forgotten), not truthfulness — a dishonestly-ticked `[x]` passes
|
|
1206
|
+
* this mechanical check and remains the reviewer/judge's responsibility
|
|
1207
|
+
* (ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Composes with
|
|
1208
|
+
* `tick-verified-checkboxes.mjs`: a box the gate could not verify stays
|
|
1209
|
+
* unchecked and therefore blocks.
|
|
1210
|
+
*
|
|
1211
|
+
* Pure; no I/O. Reuses the shared section patterns and checklist parser
|
|
1212
|
+
* (same `parseMarkdownSections` + `extractUncheckedChecklistItems` seams as
|
|
1213
|
+
* `detectIssueRefinementArtifact` / `validatePrBodySpec`) so no parallel
|
|
1214
|
+
* parser can drift. Sections absent from the body contribute no items — the
|
|
1215
|
+
* draft-exit `validateTrackerBackedPrBodySpec` check (#1863) already owns
|
|
1216
|
+
* requiring the sections to EXIST.
|
|
1217
|
+
*
|
|
1218
|
+
* @param {{ body?: string }} input
|
|
1219
|
+
* @returns {{ uncheckedAcItems: string[], uncheckedDodItems: string[] }}
|
|
1220
|
+
*/
|
|
1221
|
+
export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
|
|
1222
|
+
if (typeof body !== "string" || body.length === 0) {
|
|
1223
|
+
return { uncheckedAcItems: [], uncheckedDodItems: [] };
|
|
1224
|
+
}
|
|
1225
|
+
const sections = parseMarkdownSections(body);
|
|
1226
|
+
// Union the unchecked boxes across ALL sections matching each pattern
|
|
1227
|
+
// family (exact-first ordered), flattening each section past its deeper
|
|
1228
|
+
// sub-headings (#1877): a body nesting ACs under `###` subsections, or
|
|
1229
|
+
// repeating an AC/DoD heading, must not hide unchecked boxes from the
|
|
1230
|
+
// deterministic completeness block. Deduped by text (same box re-read in a
|
|
1231
|
+
// duplicate section is the same box).
|
|
1232
|
+
const collect = (patterns) => {
|
|
1233
|
+
const matched = findAllSectionsByPatterns(sections, patterns);
|
|
1234
|
+
const items = [];
|
|
1235
|
+
for (let i = 0; i < sections.length; i += 1) {
|
|
1236
|
+
if (!matched.includes(sections[i])) continue;
|
|
1237
|
+
items.push(...extractUncheckedChecklistItems(flattenSectionDeep(sections, i)));
|
|
1238
|
+
}
|
|
1239
|
+
return [...new Set(items)];
|
|
1240
|
+
};
|
|
1241
|
+
return {
|
|
1242
|
+
uncheckedAcItems: collect(ACCEPTANCE_SECTION_PATTERNS),
|
|
1243
|
+
uncheckedDodItems: collect(DOD_SECTION_PATTERNS),
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
733
1247
|
/**
|
|
734
1248
|
* Decide what an enqueue caller should do with a refinement-artifact result,
|
|
735
1249
|
* so an un-refined item never lands in the Next Up pickup column in the first
|
|
@@ -748,9 +1262,9 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
|
|
|
748
1262
|
*/
|
|
749
1263
|
export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
|
|
750
1264
|
// `artifact.finding === null` is the explicit "passes the full refinement
|
|
751
|
-
// check" signal (
|
|
752
|
-
// section
|
|
753
|
-
// covers.
|
|
1265
|
+
// check" signal (a valid AC→DoD mapping matrix + an explicit Non-goals
|
|
1266
|
+
// section, or a resolvable linked refinement doc + Non-goals), clearer than
|
|
1267
|
+
// reading `hasACs`, whose name understates what it covers.
|
|
754
1268
|
if (!targetIsPickup || artifact.finding === null) {
|
|
755
1269
|
return { action: "enqueue" };
|
|
756
1270
|
}
|
|
@@ -763,10 +1277,29 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
|
|
|
763
1277
|
"(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself)) — refusing to enqueue without an explicit Non-goals section.";
|
|
764
1278
|
return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
|
|
765
1279
|
}
|
|
1280
|
+
// #1951: matrix present but empty/malformed/identifier-only — name the shape
|
|
1281
|
+
// defect so the fix targets the mapping table, not a missing section.
|
|
1282
|
+
if (artifact.finding === MALFORMED_AC_DOD_MATRIX_FINDING) {
|
|
1283
|
+
const reason =
|
|
1284
|
+
"Issue carries an AC→DoD mapping matrix but it is empty, malformed, or identifier-only/tautological (e.g. `AC1 → D1`). " +
|
|
1285
|
+
"Rewrite the mapping table so each row maps a concrete acceptance-criterion outcome to concrete completion evidence " +
|
|
1286
|
+
"(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself)) — refusing to enqueue on a malformed matrix.";
|
|
1287
|
+
return { action: auto ? "divert" : "block", reason, missing: ["valid AC→DoD mapping matrix"] };
|
|
1288
|
+
}
|
|
1289
|
+
// #1951: matrix absent (whether or not the body carries duplicate issue-side
|
|
1290
|
+
// checklists) — the mapping table is the authoritative issue artifact.
|
|
1291
|
+
if (artifact.finding === MISSING_AC_DOD_MATRIX_FINDING) {
|
|
1292
|
+
const reason =
|
|
1293
|
+
"Issue carries Acceptance criteria / Definition of done content but no authoritative AC→DoD mapping matrix — under matrix-on-issue/checklist-on-PR the mapping table is the authoritative issue artifact (#1951). " +
|
|
1294
|
+
"Add a semantic AC→DoD mapping table to the issue body (each acceptance-criterion outcome mapped to its required completion evidence), and an explicit Non-goals section if one is not already present " +
|
|
1295
|
+
"(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself)) — refusing to enqueue without the mapping matrix.";
|
|
1296
|
+
return { action: auto ? "divert" : "block", reason, missing: ["AC→DoD mapping matrix"] };
|
|
1297
|
+
}
|
|
766
1298
|
const missing = [...REFINEMENT_ARTIFACT_SOURCES];
|
|
767
1299
|
const reason =
|
|
768
1300
|
`Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
|
|
769
|
-
"
|
|
1301
|
+
"Refine the issue to the authoritative AC→DoD mapping matrix (a two-column table mapping each acceptance-criterion outcome to its required completion evidence) plus an explicit Non-goals section — " +
|
|
1302
|
+
"or link a refinement doc (tmp/refinement/*.md), which is a complete artifact on its own " +
|
|
770
1303
|
"(e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself), or the refiner) — before it enters the pickup queue.";
|
|
771
1304
|
return { action: auto ? "divert" : "block", reason, missing };
|
|
772
1305
|
}
|