@dev-loops/core 1.0.1 → 1.0.2-slim.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 +5 -2
- package/src/analysis/diff-analyzer.mjs +85 -137
- package/src/claude/asset-generation.mjs +7 -7
- package/src/claude/hook-decisions.mjs +167 -50
- package/src/config/config.mjs +388 -787
- package/src/config/extension-defaults.yaml +14 -9
- package/src/github/comment-id-guard.mjs +39 -1
- package/src/github/copilot-helpers.mjs +90 -158
- package/src/github/gh.mjs +49 -0
- package/src/loop/bash-command-classify.mjs +34 -49
- package/src/loop/commit-msg-guard.mjs +1 -1
- package/src/loop/conductor-routing.mjs +15 -23
- package/src/loop/copilot-loop-state.mjs +46 -94
- package/src/loop/gate-carry-forward.mjs +46 -22
- package/src/loop/gate-evidence-reconcile.mjs +75 -0
- package/src/loop/gate-fanin.mjs +266 -435
- package/src/loop/handoff-envelope.mjs +21 -21
- package/src/loop/issue-refinement-artifact.mjs +449 -284
- package/src/loop/lifecycle-state.mjs +10 -21
- package/src/loop/pr-gate-coordination.mjs +49 -49
- package/src/loop/queue-board-sync.mjs +16 -82
- package/src/loop/review-dispatch-plan.mjs +61 -122
- package/src/loop/review-lineage.mjs +19 -44
- package/src/loop/spec-authority.mjs +729 -0
- package/src/loop/steering.mjs +16 -68
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/worktree-guard.mjs +55 -0
- package/src/projects/list-queue-items.mjs +16 -175
- package/src/projects/move-queue-item.mjs +16 -171
- package/src/projects/projects-access.mjs +202 -0
- package/src/security/secret-scan.mjs +13 -1
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic issue refinement-artifact detection.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
4
|
+
* The authoritative refined-issue artifact is the semantic AC→DoD mapping
|
|
5
|
+
* MATRIX (a two-column table mapping each acceptance-criterion outcome to its
|
|
6
|
+
* required completion evidence) plus an explicit Non-goals section — or a
|
|
7
|
+
* linked refinement doc that is a complete artifact on its own (the doc carries
|
|
8
|
+
* the matrix). Interactive issue-side AC/DoD checklists are NOT a substitute
|
|
9
|
+
* for the matrix; the PR carries the derived self-contained list-form
|
|
10
|
+
* checklists (`derivePrChecklistsFromIssueMatrix`; the PR body is validated by
|
|
11
|
+
* `validateTrackerBackedPrBodySpec`, never this predicate).
|
|
12
|
+
*
|
|
13
|
+
* Detection validates the structural PRESENCE and SHAPE of the mapping table,
|
|
14
|
+
* not its semantic truthfulness (a reviewer responsibility). A matrix that is
|
|
15
|
+
* absent, empty, malformed, or identifier-only fails closed with the matching
|
|
16
|
+
* finding (`missing_ac_dod_matrix`, `malformed_ac_dod_matrix`,
|
|
17
|
+
* `missing_explicit_non_goals`, or `missing_refinement_artifact`). A body
|
|
18
|
+
* carrying only checklists and no matrix fails closed and is re-grilled; no
|
|
19
|
+
* compatibility alias is retained.
|
|
15
20
|
*/
|
|
16
21
|
import { existsSync } from "node:fs";
|
|
17
22
|
import path from "node:path";
|
|
@@ -29,6 +34,7 @@ import path from "node:path";
|
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
export const REFINEMENT_SOURCE = Object.freeze({
|
|
37
|
+
ISSUE_BODY_MATRIX: "issue-body-matrix",
|
|
32
38
|
ISSUE_BODY_AC: "issue-body-ac",
|
|
33
39
|
ISSUE_BODY_DOD: "issue-body-dod",
|
|
34
40
|
LINKED_DOC: "linked-doc",
|
|
@@ -37,42 +43,37 @@ export const REFINEMENT_SOURCE = Object.freeze({
|
|
|
37
43
|
|
|
38
44
|
const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
|
|
39
45
|
|
|
40
|
-
// REFINEMENT_ARTIFACT_SOURCES: the
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// COMPLETE artifact, not a menu where any one entry suffices.
|
|
46
|
+
// REFINEMENT_ARTIFACT_SOURCES: the shape of a COMPLETE refinement artifact,
|
|
47
|
+
// not a menu where any one entry suffices. A linked refinement doc
|
|
48
|
+
// remains a complete artifact on its own.
|
|
44
49
|
export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
|
|
45
|
-
"
|
|
46
|
-
"
|
|
50
|
+
"AC→DoD mapping matrix (a two-column table)",
|
|
51
|
+
"explicit Non-goals section",
|
|
47
52
|
"linked refinement doc",
|
|
48
53
|
]);
|
|
49
54
|
|
|
50
55
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.code`) so both spec surfaces
|
|
55
|
-
* name the missing invariant identically.
|
|
56
|
+
* Finding: refinement artifact present but no explicit Non-goals section.
|
|
57
|
+
* Mirrors the PR-path code (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.code`)
|
|
58
|
+
* so both spec surfaces name the missing invariant identically.
|
|
56
59
|
*/
|
|
57
60
|
export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* epic-only matrix requirement (epic-tree-refinement-procedure.md) into the
|
|
65
|
-
* general refinement predicate, reconciled with #1866's Non-goals parity.
|
|
63
|
+
* Finding: refinement content present but NO authoritative AC→DoD mapping
|
|
64
|
+
* matrix table. The mapping table is the authoritative issue artifact;
|
|
65
|
+
* issue-side checklists are not a substitute. Fails closed so the issue is
|
|
66
|
+
* re-grilled to add the matrix.
|
|
66
67
|
*/
|
|
67
|
-
export const
|
|
68
|
+
export const MISSING_AC_DOD_MATRIX_FINDING = "missing_ac_dod_matrix";
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
* Finding: an AC→DoD mapping table is present but empty (header/separator only)
|
|
72
|
+
* or identifier-only/tautological (cells such as `AC1 → D1` with no concrete
|
|
73
|
+
* criterion or evidence prose). Structural shape validation only — semantic
|
|
74
|
+
* truthfulness stays a reviewer responsibility.
|
|
74
75
|
*/
|
|
75
|
-
export const
|
|
76
|
+
export const MALFORMED_AC_DOD_MATRIX_FINDING = "malformed_ac_dod_matrix";
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
79
|
* Canonical list of section headings that satisfy the refinement check.
|
|
@@ -82,47 +83,35 @@ export const MISSING_AC_CHECKLIST_FINDING = "missing_ac_checklist";
|
|
|
82
83
|
* - one DoD-style section (DoD or Definition of Done)
|
|
83
84
|
*/
|
|
84
85
|
const ACCEPTANCE_SECTION_PATTERNS = Object.freeze([
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// (
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// spelled-out phrase). The anchor stays distinct from the alias family below
|
|
91
|
-
// (`/^ac\b/`), so the precedence contract is unchanged: a spelled-out
|
|
92
|
-
// canonical heading always outranks an abbreviation-shaped alias heading.
|
|
86
|
+
// Index 0 is the exact-canonical ANCHOR (`^acceptance criteria\b`); the rest
|
|
87
|
+
// are aliases. A decorated-variant canonical heading (`## Acceptance criteria
|
|
88
|
+
// (v2)`) still lands in the exact bucket rather than matching no pattern. The
|
|
89
|
+
// anchor stays distinct from the `/^ac\b/` alias, so a spelled-out canonical
|
|
90
|
+
// heading always outranks an abbreviation-shaped alias heading.
|
|
93
91
|
/^acceptance criteria\b.*$/i,
|
|
94
92
|
/^ac\b.*$/i,
|
|
95
93
|
]);
|
|
96
94
|
|
|
97
95
|
const DOD_SECTION_PATTERNS = Object.freeze([
|
|
98
|
-
// Same anchor-family widening as the AC family
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// disarms the PR-side DoD read and false-blocks the issue side).
|
|
96
|
+
// Same anchor-family widening as the AC family. A decorated-variant alias
|
|
97
|
+
// heading (`## DoD (v2)`) must land in the alias bucket, not in no bucket: a
|
|
98
|
+
// `$`-anchored alias silently disarms the PR-side DoD read and false-blocks
|
|
99
|
+
// the issue side.
|
|
103
100
|
/^definition of done\b.*$/i,
|
|
104
101
|
/^done\b.*$/i,
|
|
105
102
|
/^dod\b.*$/i,
|
|
106
103
|
]);
|
|
107
104
|
|
|
108
105
|
/**
|
|
109
|
-
* Normalize a heading name before section-pattern matching
|
|
110
|
-
*
|
|
111
|
-
* headings
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* still matches its alias family. Strips: surrounding emphasis runs of any
|
|
119
|
-
* of `*`/`_` (bold `**`/`__` and single-char italic `*`/`_` alike, #1877
|
|
120
|
-
* round-7), surrounding backtick runs, trailing `:` and surrounding
|
|
121
|
-
* whitespace.
|
|
122
|
-
* Deliberately NOT touched: interior text (a real `AC (v2) - final` name keeps
|
|
123
|
-
* its interior), leading `#` (ATX markers never reach `match[2]`), and any
|
|
124
|
-
* decoration a section pattern itself could rely on (none does — every family
|
|
125
|
-
* anchors at the name's start).
|
|
106
|
+
* Normalize a heading name before section-pattern matching. Strip
|
|
107
|
+
* harmless decoration once, at the parse boundary, so decorated canonical
|
|
108
|
+
* headings (`## **Acceptance criteria**`, `## Acceptance criteria:`) still
|
|
109
|
+
* match their pattern family instead of silently disarming the AC/DoD reads.
|
|
110
|
+
* Exact-vs-alias precedence stays intact: a normalized `Acceptance criteria`
|
|
111
|
+
* still matches the exact pattern, a decorated alias still matches its alias
|
|
112
|
+
* family. Strips surrounding emphasis/backtick runs (bold and single-char
|
|
113
|
+
* italic), trailing `:`, closing ATX `#`, and surrounding whitespace.
|
|
114
|
+
* NOT touched: interior text, and leading `#` (never reaches `match[2]`).
|
|
126
115
|
*/
|
|
127
116
|
function normalizeHeadingName(name) {
|
|
128
117
|
if (typeof name !== "string") return name;
|
|
@@ -130,30 +119,23 @@ function normalizeHeadingName(name) {
|
|
|
130
119
|
// trailing decoration first: closing `##` ATX-style, colons, whitespace
|
|
131
120
|
.replace(/\s*:*\s*$/u, "")
|
|
132
121
|
.replace(/\s*#+\s*$/u, "")
|
|
133
|
-
// surrounding emphasis/backtick runs (any length, must pair;
|
|
134
|
-
//
|
|
135
|
-
// `**`/`__`, so `## *Acceptance criteria*` and `## _Definition of done_`
|
|
136
|
-
// normalize exactly like their bold forms)
|
|
122
|
+
// surrounding emphasis/backtick runs (any length, must pair; a run may be
|
|
123
|
+
// single-char italic `*`/`_` as well as bold `**`/`__`)
|
|
137
124
|
.replace(/^[*_`]+/u, "")
|
|
138
125
|
.replace(/[*_`]+$/u, "")
|
|
139
126
|
.trim();
|
|
140
127
|
}
|
|
141
128
|
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
// each pattern family into [exact, aliases] by convention: pattern index 0
|
|
147
|
-
// is the exact canonical match, the rest are aliases.
|
|
129
|
+
// Exact canonical headings (pattern index 0 in each family) must outrank loose
|
|
130
|
+
// aliases (`/^ac\b/`, `/^dod\b/`) so a matrix-shaped heading (`## AC/DoD
|
|
131
|
+
// matrix`) can never hijack the canonical section read. Index 0 is the exact
|
|
132
|
+
// canonical match, the rest are aliases.
|
|
148
133
|
const EXACT_PATTERN_INDEX = 0;
|
|
149
134
|
|
|
150
135
|
/**
|
|
151
|
-
* Resolve the
|
|
152
|
-
* precedence
|
|
153
|
-
*
|
|
154
|
-
* (e.g. `## AC/DoD matrix` before `## Acceptance criteria`). When no exact
|
|
155
|
-
* match exists, the first alias match is returned (alias-only bodies keep
|
|
156
|
-
* working). Returns null when no section matches at all.
|
|
136
|
+
* Resolve the section matching a heading-pattern family with exact-first
|
|
137
|
+
* precedence: the first EXACT canonical match (index 0) wins over any
|
|
138
|
+
* earlier alias-only match. Falls back to the first alias match, else null.
|
|
157
139
|
*/
|
|
158
140
|
function findSectionByPatterns(sections, patterns) {
|
|
159
141
|
const exact = patterns[EXACT_PATTERN_INDEX];
|
|
@@ -173,10 +155,9 @@ function findSectionByPatterns(sections, patterns) {
|
|
|
173
155
|
}
|
|
174
156
|
|
|
175
157
|
/**
|
|
176
|
-
* Collect ALL sections matching a heading-pattern family, exact-first ordered
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* and union consumers (#1877 PR-body unchecked-box extraction) cannot drift.
|
|
158
|
+
* Collect ALL sections matching a heading-pattern family, exact-first ordered.
|
|
159
|
+
* Shares `findSectionByPatterns`'s precedence semantics so single-section and
|
|
160
|
+
* union consumers (PR-body unchecked-box extraction) cannot drift.
|
|
180
161
|
*/
|
|
181
162
|
function findAllSectionsByPatterns(sections, patterns) {
|
|
182
163
|
const exact = patterns[EXACT_PATTERN_INDEX];
|
|
@@ -198,28 +179,19 @@ function findAllSectionsByPatterns(sections, patterns) {
|
|
|
198
179
|
}
|
|
199
180
|
|
|
200
181
|
/**
|
|
201
|
-
* Flatten a section
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* to the next same-or-shallower heading. `parseMarkdownSections` terminates a
|
|
206
|
-
* section's `bodyLines` at ANY heading, which is correct for heading
|
|
207
|
-
* matching but hides unchecked boxes from consumers that must see ALL of a
|
|
208
|
-
* canonical section's boxes.
|
|
182
|
+
* Flatten a section into a body string that extends past `###` sub-headings
|
|
183
|
+
* by joining the section and every following DEEPER-level section up to the
|
|
184
|
+
* next same-or-shallower heading, so nested checklist items stay visible to
|
|
185
|
+
* consumers that must see ALL of a canonical section's boxes.
|
|
209
186
|
*/
|
|
210
187
|
function flattenSectionDeep(sections, startIndex) {
|
|
211
188
|
const start = sections[startIndex];
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// (`###
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// Only already-classified bodyLines are joined — a heading can never match
|
|
219
|
-
// any line-level grammar, and real boxes under sub-headings stay visible
|
|
220
|
-
// because their bodyLines still join normally. (Keeping a marker line is
|
|
221
|
-
// unnecessary: parseChecklistItems never needed the heading boundary to
|
|
222
|
-
// track fence state — body lines carry their own fences.)
|
|
189
|
+
// Anti-spoof: the raw sub-heading NAME is NEVER re-injected into the text the
|
|
190
|
+
// checklist parser re-parses. A fence-opening name (`### ``` `) would corrupt
|
|
191
|
+
// fence state and eat real boxes (fail-open); a checkbox-shaped name
|
|
192
|
+
// (`### - [ ] fake`) would count as a phantom unchecked item (fail-closed).
|
|
193
|
+
// Only already-classified bodyLines are joined; real boxes under sub-headings
|
|
194
|
+
// stay visible because their bodyLines still join normally.
|
|
223
195
|
const parts = [start.bodyLines.join("\n")];
|
|
224
196
|
for (let i = startIndex + 1; i < sections.length; i += 1) {
|
|
225
197
|
if (sections[i].level <= start.level) break;
|
|
@@ -238,7 +210,7 @@ function flattenSectionDeep(sections, startIndex) {
|
|
|
238
210
|
* CommonMark: an N-marker fence (``` or ~~~) closes only on a line of >= N
|
|
239
211
|
* markers of the SAME char with no info string. This is the single source of
|
|
240
212
|
* truth shared by parseMarkdownSections (headings) and extractChecklistItems
|
|
241
|
-
* (checkboxes) so the two anti-spoof layers cannot drift
|
|
213
|
+
* (checkboxes) so the two anti-spoof layers cannot drift.
|
|
242
214
|
*/
|
|
243
215
|
function stepFence(fence, line) {
|
|
244
216
|
const openMatch = /^\s*(`{3,}|~{3,})/u.exec(line);
|
|
@@ -265,7 +237,7 @@ function stepFence(fence, line) {
|
|
|
265
237
|
*
|
|
266
238
|
* Headings inside a fenced code span (``` or ~~~) are NOT treated as headings —
|
|
267
239
|
* otherwise a body could spoof the refinement/spec gate with real-looking
|
|
268
|
-
* headings that carry no real spec (gate integrity
|
|
240
|
+
* headings that carry no real spec (gate integrity).
|
|
269
241
|
*/
|
|
270
242
|
export function parseMarkdownSections(body) {
|
|
271
243
|
if (typeof body !== "string" || body.length === 0) {
|
|
@@ -291,11 +263,9 @@ export function parseMarkdownSections(body) {
|
|
|
291
263
|
}
|
|
292
264
|
current = {
|
|
293
265
|
level: match[1].length,
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// longer re-injects it), so normalization is the only consumer of the
|
|
298
|
-
// capture — the raw form is not retained (no consumer reads it).
|
|
266
|
+
// Normalize the captured name so decorated canonical headings
|
|
267
|
+
// (`## **Acceptance criteria**`) match the section patterns. The raw
|
|
268
|
+
// form is never re-parsed as body text, so it is not retained.
|
|
299
269
|
name: normalizeHeadingName(match[2]),
|
|
300
270
|
bodyLines: [],
|
|
301
271
|
};
|
|
@@ -315,27 +285,21 @@ export function parseMarkdownSections(body) {
|
|
|
315
285
|
|
|
316
286
|
|
|
317
287
|
/**
|
|
318
|
-
* Parse bullet/checkbox items from a section body into item states.
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* not counted, so a section of only unfilled placeholders reports as unrefined.
|
|
329
|
-
* Code-fenced lines are skipped (same fence logic as parseMarkdownSections,
|
|
330
|
-
* issue #1025) so a body cannot spoof the AC/DoD gate with code-fenced
|
|
331
|
-
* checkboxes.
|
|
288
|
+
* Parse bullet/checkbox items from a section body into item states. A checkbox
|
|
289
|
+
* item — any GFM/CommonMark task-list marker (`-`/`*`/`+` bullets, ordered
|
|
290
|
+
* `N.`/`N)`, blockquote-nested `> - [ ]`; parity with
|
|
291
|
+
* tick-verified-checkboxes.mjs) — becomes `{ text, checked }`, where `checked`
|
|
292
|
+
* is true only for a ticked `[x]`/`[X]` marker read from the captured marker
|
|
293
|
+
* group, never a whole-line re-test. A top-level plain bullet (`- text`, dash
|
|
294
|
+
* at column 0) becomes `{ text, checked: null }` — it has no checkbox. Empty
|
|
295
|
+
* placeholders (`- [ ]` with no text) are skipped. Code-fenced lines are
|
|
296
|
+
* skipped (same fence logic as parseMarkdownSections) so a body cannot spoof
|
|
297
|
+
* the AC/DoD gate with code-fenced checkboxes.
|
|
332
298
|
*
|
|
333
|
-
* Shared by `extractChecklistItems`
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* `detectIssueRefinementArtifact`), so counting plain bullets is scoped to
|
|
338
|
-
* those sections and never affects prose sections.
|
|
299
|
+
* Shared by `extractChecklistItems` and `extractUncheckedChecklistItems` so the
|
|
300
|
+
* two never drift on what counts as an item or on the checkbox-state read.
|
|
301
|
+
* Only called on an already-recognized AC/DoD section, so counting
|
|
302
|
+
* plain bullets never affects prose sections.
|
|
339
303
|
*/
|
|
340
304
|
function parseChecklistItems(sectionBody) {
|
|
341
305
|
if (typeof sectionBody !== "string" || sectionBody.length === 0) {
|
|
@@ -352,20 +316,15 @@ function parseChecklistItems(sectionBody) {
|
|
|
352
316
|
if (step.insideFence) {
|
|
353
317
|
continue;
|
|
354
318
|
}
|
|
355
|
-
// Checklist item: GFM/CommonMark task-list markers (
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// CAPTURED marker group of this single match — never a second whole-line
|
|
365
|
-
// re-test. An unanchored `/\[(?:[xX])\]/u.test(line)` reads an UNCHECKED box
|
|
366
|
-
// whose label text merely mentions `[x]` (e.g. `- [ ] verify [x] flags`) as
|
|
367
|
-
// checked, silently disarming the deterministic block — the exact
|
|
368
|
-
// fail-open class the marker-anchored pre-#1877 read could not produce.
|
|
319
|
+
// Checklist item: GFM/CommonMark task-list markers (`-`/`*`/`+` bullets,
|
|
320
|
+
// ordered `N.`/`N)`, blockquote-nested `> - [ ]`) — grammar parity with
|
|
321
|
+
// tick-verified-checkboxes.mjs so every form surfaced as unchecked is
|
|
322
|
+
// flippable by the tick tool. Push only when the line carries text, so
|
|
323
|
+
// empty placeholders are skipped. The tick state comes from the CAPTURED
|
|
324
|
+
// marker group, never a second whole-line re-test: an unanchored
|
|
325
|
+
// `/\[[xX]\]/.test(line)` would read an unchecked box whose label merely
|
|
326
|
+
// mentions `[x]` (`- [ ] verify [x] flags`) as checked, a fail-open the
|
|
327
|
+
// marker-anchored read cannot produce.
|
|
369
328
|
const checkboxMatch =
|
|
370
329
|
/^\s*(?:>|\s)*(?:[-*+]|\d+[.)])\s+\[([ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
|
|
371
330
|
if (checkboxMatch) {
|
|
@@ -399,7 +358,7 @@ function parseChecklistItems(sectionBody) {
|
|
|
399
358
|
* are skipped. Returns the trimmed item text for each matching line; the
|
|
400
359
|
* checkbox state is not preserved (use `extractUncheckedChecklistItems` for
|
|
401
360
|
* that). Thin wrapper over `parseChecklistItems` so the text-only contract
|
|
402
|
-
* stays byte-identical to its
|
|
361
|
+
* stays byte-identical to its original shape.
|
|
403
362
|
*/
|
|
404
363
|
export function extractChecklistItems(sectionBody) {
|
|
405
364
|
return parseChecklistItems(sectionBody).map((item) => item.text);
|
|
@@ -409,7 +368,7 @@ export function extractChecklistItems(sectionBody) {
|
|
|
409
368
|
* Extract the text of UNCHECKED checkbox items (`- [ ]`) from a section body.
|
|
410
369
|
* A ticked box (`- [x]`/`- [X]`) and a plain bullet (no checkbox) are both
|
|
411
370
|
* excluded — only an actual unticked checkbox is an "unticked AC item"
|
|
412
|
-
* (
|
|
371
|
+
* (ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped.
|
|
413
372
|
* Thin wrapper over `parseChecklistItems` so the unticked read never drifts
|
|
414
373
|
* from `extractChecklistItems` on what counts as a checklist item.
|
|
415
374
|
*/
|
|
@@ -419,6 +378,210 @@ export function extractUncheckedChecklistItems(sectionBody) {
|
|
|
419
378
|
.map((item) => item.text);
|
|
420
379
|
}
|
|
421
380
|
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// AC→DoD mapping matrix detection
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// The authoritative refined-issue artifact is a semantic AC→DoD mapping table:
|
|
385
|
+
// a GFM pipe table whose rows map each acceptance-criterion outcome to its
|
|
386
|
+
// required completion evidence. This is the "matrix on the issue" half of
|
|
387
|
+
// "matrix on the issue, checklist on the PR". Detection validates the table's
|
|
388
|
+
// PRESENCE and SHAPE only — its semantic truthfulness stays a reviewer duty.
|
|
389
|
+
|
|
390
|
+
// Heading families that name the mapping-matrix section. A qualifying table
|
|
391
|
+
// under one of these headings is treated as the matrix even when its column
|
|
392
|
+
// headers do not name criterion/evidence explicitly.
|
|
393
|
+
const MATRIX_SECTION_PATTERNS = Object.freeze([
|
|
394
|
+
/\bac\b.*\bdod\b.*\b(matrix|mapping|map)\b/i,
|
|
395
|
+
/\b(acceptance|criteri\w*)\b.*\b(matrix|mapping|map)\b/i,
|
|
396
|
+
/\bmapping (matrix|table)\b/i,
|
|
397
|
+
/\bac\s*(?:\/|→|->|to)\s*dod\b/i,
|
|
398
|
+
]);
|
|
399
|
+
|
|
400
|
+
// Header column families: col0 names the criterion side, col1 the evidence
|
|
401
|
+
// side. Recognizes an unheaded but clearly criterion→evidence table anywhere in
|
|
402
|
+
// the body. Kept STRONG on purpose: a generic status table like
|
|
403
|
+
// `| Outcome | Done |` must NOT be mistaken for the refinement matrix — only
|
|
404
|
+
// headers naming acceptance criteria AND completion evidence / DoD qualify
|
|
405
|
+
// without a matrix heading. A matrix under a weaker header still qualifies via
|
|
406
|
+
// its `## AC / DoD matrix` heading (MATRIX_SECTION_PATTERNS).
|
|
407
|
+
const MATRIX_CRITERION_HEADER = /\b(criteri\w*|acceptance|ac)\b/i;
|
|
408
|
+
const MATRIX_EVIDENCE_HEADER = /\b(evidence|dod|definition of done)\b/i;
|
|
409
|
+
|
|
410
|
+
const TABLE_DELIMITER_RE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/u;
|
|
411
|
+
|
|
412
|
+
/** Split one GFM table row into trimmed cell strings (drops leading/trailing pipes). */
|
|
413
|
+
function splitTableRow(line) {
|
|
414
|
+
let s = line.trim();
|
|
415
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
416
|
+
if (s.endsWith("|")) s = s.slice(0, -1);
|
|
417
|
+
// ponytail: no escaped-pipe (`\|`) handling — refined-issue matrix cells are
|
|
418
|
+
// short prose, not pipe-bearing code. Add a split-on-unescaped-pipe pass only
|
|
419
|
+
// if a real matrix cell ever needs a literal `|`.
|
|
420
|
+
return s.split("|").map((c) => c.trim());
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Count real prose words in a matrix cell: runs of >=3 letters that are not the
|
|
425
|
+
* `dod` identifier token. Bare identifiers (`AC1`, `D1`, `DoD`), arrows, and
|
|
426
|
+
* digits contribute nothing, so a tautological/identifier-only cell scores 0.
|
|
427
|
+
*/
|
|
428
|
+
function cellProseWordCount(cell) {
|
|
429
|
+
if (typeof cell !== "string") return 0;
|
|
430
|
+
const stripped = cell.replace(/[*_`]+/gu, " ");
|
|
431
|
+
const runs = stripped.match(/[A-Za-z]{3,}/gu) ?? [];
|
|
432
|
+
return runs.filter((w) => w.toLowerCase() !== "dod").length;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// A matrix data row is semantic when BOTH mapped cells carry at least one real
|
|
436
|
+
// prose word. This rejects identifier-only/tautological rows (`AC1 | D1`,
|
|
437
|
+
// `AC1 → D1`, `DoD`, empty cells) WITHOUT false-rejecting a terse-but-real
|
|
438
|
+
// mapping (`Feature works | Regression test added`). The threshold is
|
|
439
|
+
// deliberately >=1, not >=2: reject bare identifiers, do not mandate a minimum
|
|
440
|
+
// verbosity.
|
|
441
|
+
function rowIsSemantic(criterion, evidence) {
|
|
442
|
+
return cellProseWordCount(criterion) >= 1 && cellProseWordCount(evidence) >= 1;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Parse every GFM pipe table in a Markdown body (skipping fenced code spans via
|
|
447
|
+
* the shared `stepFence`). Returns an array of
|
|
448
|
+
* `{ heading, headerCells, rows }` where `rows` is the list of data rows (each
|
|
449
|
+
* an array of trimmed cell strings). A table is a header line containing `|`,
|
|
450
|
+
* a delimiter row (`|---|---|`), and >=0 data rows.
|
|
451
|
+
*/
|
|
452
|
+
function parseMarkdownTables(body) {
|
|
453
|
+
if (typeof body !== "string" || body.length === 0) return [];
|
|
454
|
+
const lines = body.split(/\r?\n/u);
|
|
455
|
+
const tables = [];
|
|
456
|
+
let fence = null;
|
|
457
|
+
let heading = null;
|
|
458
|
+
let i = 0;
|
|
459
|
+
while (i < lines.length) {
|
|
460
|
+
const step = stepFence(fence, lines[i]);
|
|
461
|
+
fence = step.fence;
|
|
462
|
+
if (step.insideFence) {
|
|
463
|
+
i += 1;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const headingMatch = /^(#{1,6})\s+(.+?)\s*$/u.exec(lines[i]);
|
|
467
|
+
if (headingMatch) {
|
|
468
|
+
heading = normalizeHeadingName(headingMatch[2]);
|
|
469
|
+
i += 1;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const header = lines[i];
|
|
473
|
+
const delim = lines[i + 1];
|
|
474
|
+
if (header.includes("|") && typeof delim === "string" && TABLE_DELIMITER_RE.test(delim)) {
|
|
475
|
+
const headerCells = splitTableRow(header);
|
|
476
|
+
const rows = [];
|
|
477
|
+
let j = i + 2;
|
|
478
|
+
while (j < lines.length) {
|
|
479
|
+
const rowStep = stepFence(fence, lines[j]);
|
|
480
|
+
// A table ends at the first non-fence line without a pipe, or a heading.
|
|
481
|
+
if (rowStep.insideFence) break;
|
|
482
|
+
if (!lines[j].includes("|") || /^#{1,6}\s+/u.test(lines[j])) break;
|
|
483
|
+
rows.push(splitTableRow(lines[j]));
|
|
484
|
+
j += 1;
|
|
485
|
+
}
|
|
486
|
+
tables.push({ heading, headerCells, rows });
|
|
487
|
+
i = j;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
i += 1;
|
|
491
|
+
}
|
|
492
|
+
return tables;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Detect the authoritative AC→DoD mapping matrix in an issue body.
|
|
497
|
+
*
|
|
498
|
+
* A qualifying table has >=2 columns and EITHER sits under a matrix-named
|
|
499
|
+
* heading ({@link MATRIX_SECTION_PATTERNS}) OR names criterion/evidence-like
|
|
500
|
+
* columns in its header. The matrix is VALID when it carries at least one
|
|
501
|
+
* SEMANTIC data row (both mapped cells carry real prose — see
|
|
502
|
+
* {@link rowIsSemantic}); a header/separator-only table (no data rows) or a
|
|
503
|
+
* table whose rows are all identifier-only/tautological (`AC1 → D1`) is
|
|
504
|
+
* malformed.
|
|
505
|
+
*
|
|
506
|
+
* @param {string} [body]
|
|
507
|
+
* @returns {{ found: boolean, valid: boolean, rowCount: number, rows: { criterion: string, evidence: string }[], reason: string }}
|
|
508
|
+
*/
|
|
509
|
+
export function detectAcDodMatrix(body = "") {
|
|
510
|
+
const tables = parseMarkdownTables(body);
|
|
511
|
+
const candidates = tables.filter((t) => {
|
|
512
|
+
if (!Array.isArray(t.headerCells) || t.headerCells.length < 2) return false;
|
|
513
|
+
const underHeading = typeof t.heading === "string" &&
|
|
514
|
+
MATRIX_SECTION_PATTERNS.some((p) => p.test(t.heading));
|
|
515
|
+
const headerNamesMap =
|
|
516
|
+
MATRIX_CRITERION_HEADER.test(t.headerCells[0] ?? "") &&
|
|
517
|
+
MATRIX_EVIDENCE_HEADER.test(t.headerCells[1] ?? "");
|
|
518
|
+
return underHeading || headerNamesMap;
|
|
519
|
+
});
|
|
520
|
+
if (candidates.length === 0) {
|
|
521
|
+
return { found: false, valid: false, rowCount: 0, rows: [], reason: "No AC→DoD mapping matrix table found." };
|
|
522
|
+
}
|
|
523
|
+
// Prefer the first candidate that has >=1 semantic row; otherwise report the
|
|
524
|
+
// first candidate as malformed.
|
|
525
|
+
for (const table of candidates) {
|
|
526
|
+
const semanticRows = [];
|
|
527
|
+
for (const cells of table.rows) {
|
|
528
|
+
if (cells.length < 2) continue;
|
|
529
|
+
const criterion = cells[0] ?? "";
|
|
530
|
+
const evidence = cells[1] ?? "";
|
|
531
|
+
if (rowIsSemantic(criterion, evidence)) {
|
|
532
|
+
semanticRows.push({ criterion, evidence });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (semanticRows.length > 0) {
|
|
536
|
+
return {
|
|
537
|
+
found: true,
|
|
538
|
+
valid: true,
|
|
539
|
+
rowCount: semanticRows.length,
|
|
540
|
+
rows: semanticRows,
|
|
541
|
+
reason: `Found an AC→DoD mapping matrix with ${semanticRows.length} semantic row(s).`,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const dataRowCount = candidates[0].rows.length;
|
|
546
|
+
return {
|
|
547
|
+
found: true,
|
|
548
|
+
valid: false,
|
|
549
|
+
rowCount: 0,
|
|
550
|
+
rows: [],
|
|
551
|
+
reason: dataRowCount === 0
|
|
552
|
+
? "AC→DoD mapping matrix table is empty (header/separator only, no data rows)."
|
|
553
|
+
: "AC→DoD mapping matrix table is identifier-only/tautological (no row maps a concrete criterion to concrete completion evidence).",
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Project an issue's AC→DoD mapping matrix into self-contained list-form PR
|
|
559
|
+
* checklists: the PR carries list-form Acceptance criteria and
|
|
560
|
+
* Definition of done checkboxes derived from the matrix — never a matrix/table,
|
|
561
|
+
* never checkboxes inside table cells. Accepts a pre-parsed `matrix` (from
|
|
562
|
+
* {@link detectAcDodMatrix}) or a raw `body` to parse. Fails closed on a
|
|
563
|
+
* missing/malformed matrix rather than emitting empty checklists.
|
|
564
|
+
*
|
|
565
|
+
* @param {{ matrix?: ReturnType<typeof detectAcDodMatrix>, body?: string }} input
|
|
566
|
+
* @returns {{ acChecklist: string[], dodChecklist: string[], markdown: string }}
|
|
567
|
+
*/
|
|
568
|
+
export function derivePrChecklistsFromIssueMatrix({ matrix = null, body = "" } = {}) {
|
|
569
|
+
const m = matrix ?? detectAcDodMatrix(body);
|
|
570
|
+
if (!m || !m.found || !m.valid || !Array.isArray(m.rows) || m.rows.length === 0) {
|
|
571
|
+
throw Object.assign(
|
|
572
|
+
new Error(`derivePrChecklistsFromIssueMatrix: ${m?.reason ?? "no valid AC→DoD mapping matrix to project"}`),
|
|
573
|
+
{ code: "MALFORMED_MATRIX_SOURCE" },
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
const dedupe = (items) => [...new Set(items.map((s) => s.trim()).filter((s) => s.length > 0))];
|
|
577
|
+
const acChecklist = dedupe(m.rows.map((r) => r.criterion));
|
|
578
|
+
const dodChecklist = dedupe(m.rows.map((r) => r.evidence));
|
|
579
|
+
const render = (heading, items) =>
|
|
580
|
+
`## ${heading}\n\n${items.map((t) => `- [ ] ${t}`).join("\n")}\n`;
|
|
581
|
+
const markdown = `${render("Acceptance criteria", acChecklist)}\n${render("Definition of done", dodChecklist)}`;
|
|
582
|
+
return { acChecklist, dodChecklist, markdown };
|
|
583
|
+
}
|
|
584
|
+
|
|
422
585
|
/**
|
|
423
586
|
* Detect a linked refinement doc path from the issue body.
|
|
424
587
|
* Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
|
|
@@ -467,33 +630,21 @@ export function detectLinkedRefinementDoc(body) {
|
|
|
467
630
|
/**
|
|
468
631
|
* Detect the refinement artifact on a parsed issue body.
|
|
469
632
|
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* `
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
* the FULL check passes, so every `.hasACs` consumer (enqueue gate, draft
|
|
480
|
-
* gate, parked-items discovery, gate context) fails closed with no call-site
|
|
481
|
-
* change.
|
|
482
|
-
*
|
|
483
|
-
* `resolveLinkedDoc` (optional, #1866): a `(path) => boolean` callback used to
|
|
484
|
-
* verify that a linked `tmp/refinement/*.md` doc actually resolves (e.g.
|
|
485
|
-
* `existsSync`). Enforcement-point callers (enqueue gate, draft-gate
|
|
486
|
-
* linked-issue path) supply it; a linked doc found in the body then satisfies
|
|
487
|
-
* the artifact check only when the callback returns true. When the callback is
|
|
488
|
-
* not supplied the predicate stays pure/no-I/O and behavior is unchanged, and
|
|
489
|
-
* the `linkedDoc` result carries no `resolves` field. When supplied and the
|
|
490
|
-
* doc does not resolve, `linkedDoc.resolves === false` and the linked doc does
|
|
491
|
-
* not satisfy the artifact check (other artifact sources still count).
|
|
633
|
+
* The floor is a valid AC→DoD mapping matrix (or a resolvable linked refinement
|
|
634
|
+
* doc) AND an explicit, non-empty Non-goals section. A missing Non-goals
|
|
635
|
+
* section fails closed with the distinct finding
|
|
636
|
+
* `MISSING_EXPLICIT_NON_GOALS_FINDING`; its matcher is shared with
|
|
637
|
+
* `validatePrBodySpec` so the two spec surfaces cannot drift. `hasACs` is true
|
|
638
|
+
* only when the FULL check passes, so every `.hasACs` consumer fails closed with
|
|
639
|
+
* no call-site change. `acItems`/`dodItems` stay populated for downstream
|
|
640
|
+
* consumers: from the issue's own checklist sections when present, otherwise
|
|
641
|
+
* projected from the matrix rows.
|
|
492
642
|
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
643
|
+
* `resolveLinkedDoc` (optional): a `(path) => boolean` callback verifying that a
|
|
644
|
+
* linked `tmp/refinement/*.md` doc actually resolves (e.g. `existsSync`). When
|
|
645
|
+
* not supplied the predicate stays pure/no-I/O and `linkedDoc` carries no
|
|
646
|
+
* `resolves` field; when supplied and the doc does not resolve, the linked doc
|
|
647
|
+
* does not satisfy the check (other artifact sources still count).
|
|
497
648
|
*
|
|
498
649
|
* @param {object} input
|
|
499
650
|
* @param {string} [input.body] Raw issue body Markdown.
|
|
@@ -508,6 +659,7 @@ export function detectLinkedRefinementDoc(body) {
|
|
|
508
659
|
* dodItems: string[],
|
|
509
660
|
* sections: string[],
|
|
510
661
|
* linkedDoc: { found: boolean, path: string|null, reason: string, resolves?: boolean },
|
|
662
|
+
* matrix: { found: boolean, valid: boolean, rowCount: number, rows: { criterion: string, evidence: string }[], reason: string },
|
|
511
663
|
* reason: string,
|
|
512
664
|
* finding: string|null,
|
|
513
665
|
* }}
|
|
@@ -523,7 +675,8 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
523
675
|
dodItems: [],
|
|
524
676
|
sections: [],
|
|
525
677
|
linkedDoc: { found: false, path: null, reason: "empty-body" },
|
|
526
|
-
|
|
678
|
+
matrix: { found: false, valid: false, rowCount: 0, rows: [], reason: "empty-body" },
|
|
679
|
+
reason: "Issue body is empty; no matrix/ACs/DoD/linked-doc can be detected.",
|
|
527
680
|
finding: REFINEMENT_ARTIFACT_FINDING,
|
|
528
681
|
};
|
|
529
682
|
}
|
|
@@ -534,7 +687,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
534
687
|
const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
|
|
535
688
|
const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
|
|
536
689
|
|
|
537
|
-
// CONSUMER-CONTRACT BOUNDARY (
|
|
690
|
+
// CONSUMER-CONTRACT BOUNDARY (intentional asymmetry): the issue-side
|
|
538
691
|
// reads above are strict — ONE exact-first section, NO deep flattening —
|
|
539
692
|
// while extractPrBodyUncheckedChecklistItems (PR side) unions ALL matching
|
|
540
693
|
// sections and deep-flattens past ### sub-headings. The issue side is a
|
|
@@ -549,7 +702,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
549
702
|
const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
|
|
550
703
|
// Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
|
|
551
704
|
// ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
|
|
552
|
-
// must refuse on
|
|
705
|
+
// must refuse on. Only actual unticked checkboxes count; a ticked
|
|
553
706
|
// box and a plain bullet (no checkbox) are both excluded.
|
|
554
707
|
const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
|
|
555
708
|
const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
|
|
@@ -561,7 +714,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
561
714
|
linkedDoc = { ...linkedDoc, resolves: linkedDocResolves };
|
|
562
715
|
}
|
|
563
716
|
|
|
564
|
-
//
|
|
717
|
+
// explicit Non-goals section required on a refined tracker-backed
|
|
565
718
|
// issue body — same matcher the PR-body spec path uses, so the two cannot
|
|
566
719
|
// drift. A heading-only or fenced-only section does not count
|
|
567
720
|
// (sectionHasBody anti-spoof).
|
|
@@ -569,13 +722,15 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
569
722
|
findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
|
|
570
723
|
);
|
|
571
724
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
725
|
+
// the authoritative issue artifact is the AC→DoD mapping MATRIX, not
|
|
726
|
+
// duplicate interactive issue-side checklists. Detect its presence + shape.
|
|
727
|
+
const matrix = detectAcDodMatrix(body);
|
|
728
|
+
|
|
729
|
+
// Keep acItems/dodItems populated for downstream consumers (gate context,
|
|
730
|
+
// coordination state) even when the issue carries only the matrix and no
|
|
731
|
+
// interactive checklists: project the matrix rows into AC/DoD items.
|
|
732
|
+
const effectiveAcItems = acItems.length > 0 ? acItems : matrix.rows.map((r) => r.criterion.trim()).filter(Boolean);
|
|
733
|
+
const effectiveDodItems = dodItems.length > 0 ? dodItems : matrix.rows.map((r) => r.evidence.trim()).filter(Boolean);
|
|
579
734
|
|
|
580
735
|
const base = {
|
|
581
736
|
hasNonGoals,
|
|
@@ -584,76 +739,97 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
584
739
|
dodItems,
|
|
585
740
|
sections: sectionNames,
|
|
586
741
|
linkedDoc,
|
|
742
|
+
matrix,
|
|
587
743
|
};
|
|
588
744
|
|
|
589
|
-
|
|
745
|
+
// A linked refinement doc remains a complete artifact on its own (the doc
|
|
746
|
+
// carries the matrix). It still requires an explicit Non-goals section.
|
|
747
|
+
if (linkedDocResolves) {
|
|
590
748
|
if (!hasNonGoals) {
|
|
591
749
|
return {
|
|
592
750
|
...base,
|
|
593
751
|
hasACs: false,
|
|
594
|
-
source:
|
|
752
|
+
source: REFINEMENT_SOURCE.LINKED_DOC,
|
|
595
753
|
reason:
|
|
596
|
-
|
|
754
|
+
"Issue body links a refinement doc but has no explicit Non-goals section; " +
|
|
597
755
|
"the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
|
|
598
756
|
"e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
|
|
599
757
|
finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
|
|
600
758
|
};
|
|
601
759
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
760
|
+
return {
|
|
761
|
+
...base,
|
|
762
|
+
hasACs: true,
|
|
763
|
+
source: REFINEMENT_SOURCE.LINKED_DOC,
|
|
764
|
+
acItems: [],
|
|
765
|
+
uncheckedAcItems: [],
|
|
766
|
+
dodItems: [],
|
|
767
|
+
reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
|
|
768
|
+
finding: null,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Matrix present but empty/malformed/identifier-only: fail closed.
|
|
773
|
+
if (matrix.found && !matrix.valid) {
|
|
774
|
+
return {
|
|
775
|
+
...base,
|
|
776
|
+
hasACs: false,
|
|
777
|
+
source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
|
|
778
|
+
reason:
|
|
779
|
+
`Issue body carries an AC→DoD mapping matrix but it is not a valid semantic mapping (${matrix.reason}); ` +
|
|
780
|
+
"the refinement contract requires a real criterion→completion-evidence mapping " +
|
|
781
|
+
"(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run the loop-grill synthesis). " +
|
|
782
|
+
"Refusing: the refinement check fails closed on a malformed/identifier-only matrix.",
|
|
783
|
+
finding: MALFORMED_AC_DOD_MATRIX_FINDING,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Matrix present and valid: the refinement floor is the matrix + explicit
|
|
788
|
+
// Non-goals. Interactive issue-side AC/DoD checklists are NOT required.
|
|
789
|
+
if (matrix.found && matrix.valid) {
|
|
790
|
+
if (!hasNonGoals) {
|
|
633
791
|
return {
|
|
634
792
|
...base,
|
|
635
793
|
hasACs: false,
|
|
636
|
-
source: REFINEMENT_SOURCE.
|
|
794
|
+
source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
|
|
637
795
|
reason:
|
|
638
|
-
"Issue body carries a
|
|
639
|
-
"the tracker-backed refinement contract requires
|
|
640
|
-
"
|
|
641
|
-
|
|
642
|
-
finding: MISSING_AC_CHECKLIST_FINDING,
|
|
796
|
+
"Issue body carries a valid AC→DoD mapping matrix but no explicit Non-goals section; " +
|
|
797
|
+
"the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
|
|
798
|
+
"e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
|
|
799
|
+
finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
|
|
643
800
|
};
|
|
644
801
|
}
|
|
645
802
|
return {
|
|
646
803
|
...base,
|
|
647
804
|
hasACs: true,
|
|
648
|
-
source: REFINEMENT_SOURCE.
|
|
649
|
-
acItems:
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
|
|
805
|
+
source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
|
|
806
|
+
acItems: effectiveAcItems,
|
|
807
|
+
dodItems: effectiveDodItems,
|
|
808
|
+
reason: `Found a valid AC→DoD mapping matrix with ${matrix.rowCount} semantic row(s) and an explicit Non-goals section.`,
|
|
653
809
|
finding: null,
|
|
654
810
|
};
|
|
655
811
|
}
|
|
656
812
|
|
|
813
|
+
// Matrix absent. If the body carries AC/DoD checklist content it is a
|
|
814
|
+
// checklist-bearing issue missing the authoritative matrix during
|
|
815
|
+
// migration: fail closed on the missing matrix so it is re-grilled. A body
|
|
816
|
+
// with no matrix and no AC/DoD content (prose-only, or only a Non-goals
|
|
817
|
+
// section / an unresolved linked-doc mention) stays the pre-existing
|
|
818
|
+
// missing_refinement_artifact.
|
|
819
|
+
if (acItems.length > 0 || dodItems.length > 0) {
|
|
820
|
+
return {
|
|
821
|
+
...base,
|
|
822
|
+
hasACs: false,
|
|
823
|
+
source: REFINEMENT_SOURCE.MISSING,
|
|
824
|
+
reason:
|
|
825
|
+
"Issue body carries Acceptance criteria / Definition of done content but no authoritative AC→DoD mapping matrix table; " +
|
|
826
|
+
"under matrix-on-issue/checklist-on-PR the mapping table is the authoritative issue artifact " +
|
|
827
|
+
"(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run the loop-grill synthesis). " +
|
|
828
|
+
"Refusing: the refinement check fails closed without the mapping matrix.",
|
|
829
|
+
finding: MISSING_AC_DOD_MATRIX_FINDING,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
657
833
|
return {
|
|
658
834
|
...base,
|
|
659
835
|
hasACs: false,
|
|
@@ -661,13 +837,13 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
661
837
|
acItems: [],
|
|
662
838
|
uncheckedAcItems: [],
|
|
663
839
|
dodItems: [],
|
|
664
|
-
reason: "Issue body has no
|
|
840
|
+
reason: "Issue body has no AC→DoD mapping matrix, no Acceptance criteria/DoD content, and no linked refinement doc.",
|
|
665
841
|
finding: REFINEMENT_ARTIFACT_FINDING,
|
|
666
842
|
};
|
|
667
843
|
}
|
|
668
844
|
|
|
669
845
|
/**
|
|
670
|
-
* PR-body-as-spec invariant sections (
|
|
846
|
+
* PR-body-as-spec invariant sections (lightweight path).
|
|
671
847
|
*
|
|
672
848
|
* When a lightweight session uses the PR description itself as the
|
|
673
849
|
* spec-of-record (no committed phase/plan doc), the PR body must still carry
|
|
@@ -702,9 +878,8 @@ export const PR_BODY_SPEC_NARRATIVE_SECTIONS = Object.freeze({
|
|
|
702
878
|
/**
|
|
703
879
|
* GitHub's accepted closing-keyword issue references (close/closes/closed,
|
|
704
880
|
* fix/fixes/fixed, resolve/resolves/resolved), case-insensitive, followed by
|
|
705
|
-
* `#N` or the cross-repo `owner/repo#N` form.
|
|
706
|
-
* lightweight
|
|
707
|
-
* lightweight PRs merged without this and none auto-closed their issue).
|
|
881
|
+
* `#N` or the cross-repo `owner/repo#N` form. Required linkage on the PR body:
|
|
882
|
+
* five lightweight PRs merged without this and none auto-closed their issue.
|
|
708
883
|
*/
|
|
709
884
|
const CLOSING_ISSUE_REFERENCE_PATTERN =
|
|
710
885
|
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+\/[\w.-]+)?#(\d+)/giu;
|
|
@@ -742,7 +917,7 @@ function extractClosingIssueNumbers(body) {
|
|
|
742
917
|
function sectionHasBody(section) {
|
|
743
918
|
// A real body needs >=1 non-whitespace line OUTSIDE any fenced code span —
|
|
744
919
|
// a section whose only content is a ```fenced``` block is treated as empty so
|
|
745
|
-
// it cannot spoof the narrative-invariant gate (
|
|
920
|
+
// it cannot spoof the narrative-invariant gate (same stepFence as
|
|
746
921
|
// parseMarkdownSections + extractChecklistItems).
|
|
747
922
|
if (!section) return false;
|
|
748
923
|
let fence = null;
|
|
@@ -759,40 +934,29 @@ function sectionHasBody(section) {
|
|
|
759
934
|
* Validate that a PR body carries every invariant required to serve as the
|
|
760
935
|
* lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
|
|
761
936
|
* testable Acceptance criteria (>=1 checklist item), Definition of done
|
|
762
|
-
* (>=1 checklist item), Open questions/risks, and — unless
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* (parseMarkdownSections / AC + DoD patterns / extractChecklistItems) so
|
|
767
|
-
* there is no parallel validator. Fails closed: every missing invariant is
|
|
768
|
-
* reported under its distinct `missing_*` code. Pure; no side effects.
|
|
937
|
+
* (>=1 checklist item), Open questions/risks, and — unless issue-less mode is
|
|
938
|
+
* requested — a GitHub closing-keyword issue reference. Reuses the generic
|
|
939
|
+
* markdown logic so there is no parallel validator. Fails closed: every missing
|
|
940
|
+
* invariant is reported under its distinct `missing_*` code. Pure; no I/O.
|
|
769
941
|
*
|
|
770
|
-
* Issue-less mode (`issueLess: true
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
* so callers can tell "no issue expected" apart from "issue expected but
|
|
777
|
-
* absent". `expectedIssue` and `issueLess` are mutually exclusive; callers
|
|
778
|
-
* pick exactly one mode (tracker-backed, with or without a specific
|
|
779
|
-
* expected issue) or issue-less — never both.
|
|
942
|
+
* Issue-less mode (`issueLess: true`): the closing-issue linkage flips from
|
|
943
|
+
* REQUIRED to FORBIDDEN (the PR is the sole artifact), failing closed under
|
|
944
|
+
* `unexpected_closing_issue_reference` — distinct from
|
|
945
|
+
* `missing_closing_issue_reference` (tracker-backed, the default).
|
|
946
|
+
* `expectedIssue` and `issueLess` are mutually exclusive; callers pick exactly
|
|
947
|
+
* one mode.
|
|
780
948
|
*
|
|
781
|
-
* `requireOpenQuestions` (default `true
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
* contract") does not name one. Pass `false` (see
|
|
786
|
-
* `validateTrackerBackedPrBodySpec` below) to skip the `missing_open_questions`
|
|
787
|
-
* check without touching any other invariant — the lightweight caller's
|
|
788
|
-
* default stays byte-identical.
|
|
949
|
+
* `requireOpenQuestions` (default `true`): the tracker-backed PR-description
|
|
950
|
+
* contract does not name an Open questions/risks section; pass `false` (see
|
|
951
|
+
* `validateTrackerBackedPrBodySpec`) to skip that check without touching any
|
|
952
|
+
* other invariant.
|
|
789
953
|
*
|
|
790
954
|
* @param {{ body?: string, expectedIssue?: number, issueLess?: boolean, requireOpenQuestions?: boolean }} input
|
|
791
955
|
* @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
|
|
792
956
|
*/
|
|
793
957
|
|
|
794
958
|
// ---------------------------------------------------------------------------
|
|
795
|
-
// Grill sub-loop body predicates (GRILL-SUBLOOP
|
|
959
|
+
// Grill sub-loop body predicates (GRILL-SUBLOOP-*)
|
|
796
960
|
// ---------------------------------------------------------------------------
|
|
797
961
|
// The loop-grill skill writes its raw Q&A transcript and synthesis to an
|
|
798
962
|
// ephemeral tmp artifact and keeps only the canonical synthesized sections
|
|
@@ -912,8 +1076,8 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
|
|
|
912
1076
|
|
|
913
1077
|
/**
|
|
914
1078
|
* Validate a TRACKER-BACKED PR's own body against the PR-description contract
|
|
915
|
-
* (skills/docs/copilot-loop-operations.md "PR description contract"
|
|
916
|
-
*
|
|
1079
|
+
* (skills/docs/copilot-loop-operations.md "PR description contract"):
|
|
1080
|
+
* Acceptance criteria + Definition of done checklists, an explicit
|
|
917
1081
|
* Non-goals section, and a `Closes #N`/`Fixes #N` reference — regardless of
|
|
918
1082
|
* whether the linked issue itself already carries a refinement artifact. A
|
|
919
1083
|
* linked issue with real ACs is necessary but not sufficient: the PR body is
|
|
@@ -936,7 +1100,7 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
|
|
|
936
1100
|
}
|
|
937
1101
|
|
|
938
1102
|
/**
|
|
939
|
-
*
|
|
1103
|
+
* Extract the UNCHECKED AC/DoD checkbox items from a PR body's own
|
|
940
1104
|
* Acceptance criteria / Definition of done checklists — the derived,
|
|
941
1105
|
* self-contained checklist that mirrors the linked issue's AC/DoD/Non-goals
|
|
942
1106
|
* matrix. Any unchecked `- [ ]` in those sections means an acceptance
|
|
@@ -954,7 +1118,7 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
|
|
|
954
1118
|
* (same `parseMarkdownSections` + `extractUncheckedChecklistItems` seams as
|
|
955
1119
|
* `detectIssueRefinementArtifact` / `validatePrBodySpec`) so no parallel
|
|
956
1120
|
* parser can drift. Sections absent from the body contribute no items — the
|
|
957
|
-
* draft-exit `validateTrackerBackedPrBodySpec` check
|
|
1121
|
+
* draft-exit `validateTrackerBackedPrBodySpec` check already owns
|
|
958
1122
|
* requiring the sections to EXIST.
|
|
959
1123
|
*
|
|
960
1124
|
* @param {{ body?: string }} input
|
|
@@ -967,7 +1131,7 @@ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
|
|
|
967
1131
|
const sections = parseMarkdownSections(body);
|
|
968
1132
|
// Union the unchecked boxes across ALL sections matching each pattern
|
|
969
1133
|
// family (exact-first ordered), flattening each section past its deeper
|
|
970
|
-
// sub-headings
|
|
1134
|
+
// sub-headings: a body nesting ACs under `###` subsections, or
|
|
971
1135
|
// repeating an AC/DoD heading, must not hide unchecked boxes from the
|
|
972
1136
|
// deterministic completeness block. Deduped by text (same box re-read in a
|
|
973
1137
|
// duplicate section is the same box).
|
|
@@ -1004,13 +1168,13 @@ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
|
|
|
1004
1168
|
*/
|
|
1005
1169
|
export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
|
|
1006
1170
|
// `artifact.finding === null` is the explicit "passes the full refinement
|
|
1007
|
-
// check" signal (
|
|
1008
|
-
// section
|
|
1009
|
-
//
|
|
1171
|
+
// check" signal (a valid AC→DoD mapping matrix + an explicit Non-goals
|
|
1172
|
+
// section, or a resolvable linked refinement doc + Non-goals), clearer than
|
|
1173
|
+
// reading `hasACs`, whose name understates what it covers.
|
|
1010
1174
|
if (!targetIsPickup || artifact.finding === null) {
|
|
1011
1175
|
return { action: "enqueue" };
|
|
1012
1176
|
}
|
|
1013
|
-
//
|
|
1177
|
+
// artifact present but the contract-mandated Non-goals section is
|
|
1014
1178
|
// absent/empty — a distinct failure with its own guidance.
|
|
1015
1179
|
if (artifact.finding === MISSING_EXPLICIT_NON_GOALS_FINDING) {
|
|
1016
1180
|
const reason =
|
|
@@ -1019,27 +1183,28 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
|
|
|
1019
1183
|
"(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.";
|
|
1020
1184
|
return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
|
|
1021
1185
|
}
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
|
|
1025
|
-
if (artifact.finding === MISSING_DOD_CHECKLIST_FINDING) {
|
|
1186
|
+
// matrix present but empty/malformed/identifier-only — name the shape
|
|
1187
|
+
// defect so the fix targets the mapping table, not a missing section.
|
|
1188
|
+
if (artifact.finding === MALFORMED_AC_DOD_MATRIX_FINDING) {
|
|
1026
1189
|
const reason =
|
|
1027
|
-
"Issue carries an
|
|
1028
|
-
"
|
|
1029
|
-
"(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
|
|
1030
|
-
return { action: auto ? "divert" : "block", reason, missing: ["
|
|
1190
|
+
"Issue carries an AC→DoD mapping matrix but it is empty, malformed, or identifier-only/tautological (e.g. `AC1 → D1`). " +
|
|
1191
|
+
"Rewrite the mapping table so each row maps a concrete acceptance-criterion outcome to concrete completion evidence " +
|
|
1192
|
+
"(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.";
|
|
1193
|
+
return { action: auto ? "divert" : "block", reason, missing: ["valid AC→DoD mapping matrix"] };
|
|
1031
1194
|
}
|
|
1032
|
-
|
|
1195
|
+
// matrix absent (whether or not the body carries duplicate issue-side
|
|
1196
|
+
// checklists) — the mapping table is the authoritative issue artifact.
|
|
1197
|
+
if (artifact.finding === MISSING_AC_DOD_MATRIX_FINDING) {
|
|
1033
1198
|
const reason =
|
|
1034
|
-
"Issue carries
|
|
1035
|
-
"Add
|
|
1036
|
-
"(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
|
|
1037
|
-
return { action: auto ? "divert" : "block", reason, missing: ["
|
|
1199
|
+
"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). " +
|
|
1200
|
+
"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 " +
|
|
1201
|
+
"(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.";
|
|
1202
|
+
return { action: auto ? "divert" : "block", reason, missing: ["AC→DoD mapping matrix"] };
|
|
1038
1203
|
}
|
|
1039
1204
|
const missing = [...REFINEMENT_ARTIFACT_SOURCES];
|
|
1040
1205
|
const reason =
|
|
1041
1206
|
`Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
|
|
1042
|
-
"Refine the issue to the
|
|
1207
|
+
"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 — " +
|
|
1043
1208
|
"or link a refinement doc (tmp/refinement/*.md), which is a complete artifact on its own " +
|
|
1044
1209
|
"(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.";
|
|
1045
1210
|
return { action: auto ? "divert" : "block", reason, missing };
|
|
@@ -1074,7 +1239,7 @@ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild
|
|
|
1074
1239
|
throw new Error("Invalid JSON input");
|
|
1075
1240
|
}
|
|
1076
1241
|
const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
|
|
1077
|
-
//
|
|
1242
|
+
// a linked refinement doc satisfies the gate only when it actually
|
|
1078
1243
|
// resolves. Paths follow the `tmp/refinement/*.md` convention and are
|
|
1079
1244
|
// anchored to the caller's repo root (`repoRoot` option, falling back to
|
|
1080
1245
|
// process.cwd()) — never the ambient cwd of whichever subdirectory the
|