@dev-loops/core 1.0.1 → 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/extension-defaults.yaml +14 -9
- 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 +34 -13
- package/src/loop/handoff-envelope.mjs +2 -2
- package/src/loop/issue-refinement-artifact.mjs +358 -99
- package/src/loop/pr-gate-coordination.mjs +13 -13
- package/src/loop/queue-board-sync.mjs +1 -28
- 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/security/secret-scan.mjs +13 -1
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spec-authority.mjs — the canonical shared contract surface for IMMUTABLE SPEC
|
|
3
|
+
* AUTHORITY across the review / judge / fixer / gate / re-entry pipeline.
|
|
4
|
+
*
|
|
5
|
+
* This module is PURE and side-effect free. It owns, in one place (so no harness
|
|
6
|
+
* prompt has to re-state normative rules that could drift):
|
|
7
|
+
*
|
|
8
|
+
* 1. The two INDEPENDENT revision identities every decision must pin:
|
|
9
|
+
* - `specDigest` — a deterministic digest of the normalized canonical
|
|
10
|
+
* tracker AC / DoD / Non-goals. It identifies what the work is REQUIRED
|
|
11
|
+
* and FORBIDDEN to do. It is NEVER derived from a head SHA.
|
|
12
|
+
* - reviewed implementation revision — `headSha` plus a `contentDigest`.
|
|
13
|
+
* It identifies what the reviewer actually evaluated. A new head/content
|
|
14
|
+
* digest NEVER masquerades as a spec change.
|
|
15
|
+
*
|
|
16
|
+
* 2. The four named, machine-readable judge disposition outcomes. For every
|
|
17
|
+
* finding the judge evaluates the finding AND each proposed remediation
|
|
18
|
+
* against the COMPLETE spec (not one supportive criterion) and selects
|
|
19
|
+
* exactly one outcome.
|
|
20
|
+
*
|
|
21
|
+
* 3. Autonomous vs last-resort escalation: only a `spec_cannot_decide` outcome
|
|
22
|
+
* routes to a human-spec-decision state. A finding/remediation conflict
|
|
23
|
+
* resolves autonomously (reject the finding, or reject the remedy and route
|
|
24
|
+
* to a compliant alternative).
|
|
25
|
+
*
|
|
26
|
+
* 4. Human-only spec change: a material spec change/reinterpretation produces a
|
|
27
|
+
* NEW `specDigest`; every approval/disposition/gate result derived from the
|
|
28
|
+
* prior digest is stale and must be re-established.
|
|
29
|
+
*
|
|
30
|
+
* 5. Criterion-scoped invalidation: a fixer push stales only the approvals for
|
|
31
|
+
* the criteria whose covered content it changed; unaffected criteria carry
|
|
32
|
+
* forward ONLY with positive deterministic proof that both their governing
|
|
33
|
+
* spec text and their covered surface are unchanged. Unknown impact fails
|
|
34
|
+
* closed to fresh review.
|
|
35
|
+
*
|
|
36
|
+
* Persistence is the caller's job; this module validates and decides, it never
|
|
37
|
+
* reads or writes files.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { sha256Hex } from "./review-dispatch-plan.mjs";
|
|
41
|
+
import { extractSection } from "./markdown-sections.mjs";
|
|
42
|
+
import { extractChecklistItems, detectAcDodMatrix } from "./issue-refinement-artifact.mjs";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Canonical spec category keys, in the fixed order the digest serializes them.
|
|
46
|
+
* A criterion's identity is (category, source-index) under one `specDigest`; the
|
|
47
|
+
* order here is therefore part of the contract, not incidental.
|
|
48
|
+
*/
|
|
49
|
+
export const SPEC_CATEGORIES = Object.freeze([
|
|
50
|
+
"acceptanceCriteria",
|
|
51
|
+
"definitionOfDone",
|
|
52
|
+
"nonGoals",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Short stable id prefix per category. A criterion id is `<prefix>:<index>`
|
|
57
|
+
* (0-based within its category, source order), e.g. `ac:0`, `dod:2`, `ng:1`.
|
|
58
|
+
* Index-based ids are stable UNDER ONE `specDigest`: any insertion/removal that
|
|
59
|
+
* would shift an index also changes the normalized spec text and therefore the
|
|
60
|
+
* digest, which stales every derived approval anyway.
|
|
61
|
+
*/
|
|
62
|
+
export const CATEGORY_ID_PREFIX = Object.freeze({
|
|
63
|
+
acceptanceCriteria: "ac",
|
|
64
|
+
definitionOfDone: "dod",
|
|
65
|
+
nonGoals: "ng",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Digest domain separators so the same bytes can never collide across the two
|
|
69
|
+
* identity namespaces (a spec that happens to equal reviewed content still
|
|
70
|
+
* produces two distinct digests). Carried as a field of the hashed object. */
|
|
71
|
+
const SPEC_DIGEST_DOMAIN = "spec-authority:spec:v1";
|
|
72
|
+
const CONTENT_DIGEST_DOMAIN = "spec-authority:content:v1";
|
|
73
|
+
|
|
74
|
+
/** Self-describing digest prefix. A `specDigest`/`contentDigest` is always
|
|
75
|
+
* `sha256:<64 hex>`; a `headSha` is bare hex. The prefix makes it structurally
|
|
76
|
+
* impossible to pass a head SHA where a spec digest is required (and vice
|
|
77
|
+
* versa), which is the mechanical half of "specDigest is never derived from
|
|
78
|
+
* headSha". */
|
|
79
|
+
export const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
80
|
+
|
|
81
|
+
/** A git head SHA: bare hex, 7-64 chars (matches judge-pass's own --head-sha
|
|
82
|
+
* validation), compared trim+lowercase everywhere. */
|
|
83
|
+
export const HEAD_SHA_RE = /^[0-9a-f]{7,64}$/;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The four named judge disposition outcomes. Exactly one is selected per
|
|
87
|
+
* finding after evaluating the finding AND each proposed remediation against
|
|
88
|
+
* the COMPLETE spec.
|
|
89
|
+
*/
|
|
90
|
+
export const SPEC_AUTHORITY_OUTCOMES = Object.freeze({
|
|
91
|
+
/** Finding valid and its remediation compliant with the whole spec: authorize
|
|
92
|
+
* the compliant remedy (the fixer may choose among compliant alternatives). */
|
|
93
|
+
VALID_COMPLIANT: "valid_compliant",
|
|
94
|
+
/** The finding itself conflicts with an AC/DoD/non-goal: reject it
|
|
95
|
+
* autonomously. It cannot trigger a fix or block a clean verdict by existing. */
|
|
96
|
+
FINDING_CONFLICTS: "finding_conflicts",
|
|
97
|
+
/** Finding valid but its PROPOSED remediation conflicts with the spec: keep
|
|
98
|
+
* the finding, reject that remedy autonomously, route to a compliant
|
|
99
|
+
* alternative. */
|
|
100
|
+
REMEDIATION_CONFLICTS: "remediation_conflicts",
|
|
101
|
+
/** The spec cannot decide the work (materially ambiguous, internally
|
|
102
|
+
* contradictory, or progress requires changing/reinterpreting AC/DoD/non-goals):
|
|
103
|
+
* escalate to an explicit human-spec-decision state. LAST RESORT only. */
|
|
104
|
+
SPEC_CANNOT_DECIDE: "spec_cannot_decide",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const SPEC_AUTHORITY_OUTCOME_VALUES = Object.freeze(
|
|
108
|
+
Object.values(SPEC_AUTHORITY_OUTCOMES),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/** The ONLY outcome that escalates to a human-spec-decision state. The other
|
|
112
|
+
* three resolve autonomously. Exported so no consumer re-hardcodes the set. */
|
|
113
|
+
export const HUMAN_SPEC_DECISION_OUTCOME = SPEC_AUTHORITY_OUTCOMES.SPEC_CANNOT_DECIDE;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Does an outcome require the loop to stop at the human-spec-decision state?
|
|
117
|
+
* Only `spec_cannot_decide` does — a finding/remediation conflict alone never
|
|
118
|
+
* justifies escalation (the judge rejects it or routes to a compliant remedy).
|
|
119
|
+
* @param {string} outcome
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function outcomeRequiresHumanDecision(outcome) {
|
|
123
|
+
return outcome === HUMAN_SPEC_DECISION_OUTCOME;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalize one criterion line into stable digest/compare text: strip a leading
|
|
128
|
+
* bullet/checkbox marker (defensive — extractChecklistItems already does for
|
|
129
|
+
* body-parsed specs, but a structured caller may pass raw lines), collapse
|
|
130
|
+
* internal whitespace, and trim. Case is PRESERVED (it is semantic).
|
|
131
|
+
* @param {unknown} text
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function normalizeCriterionText(text) {
|
|
135
|
+
return String(text ?? "")
|
|
136
|
+
.replace(/^\s*(?:>|\s)*(?:[-*+]|\d+[.)])\s+/u, "")
|
|
137
|
+
.replace(/^\[[ xX]\]\s+/u, "")
|
|
138
|
+
.replace(/\s+/gu, " ")
|
|
139
|
+
.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Normalize a spec into the canonical shape the digest serializes. Accepts a
|
|
144
|
+
* `{ acceptanceCriteria, definitionOfDone, nonGoals }` object of string arrays.
|
|
145
|
+
* Each item is normalized via {@link normalizeCriterionText}; empties are
|
|
146
|
+
* dropped; SOURCE ORDER is preserved (order is criterion identity).
|
|
147
|
+
*
|
|
148
|
+
* Fail-closed: a spec with no acceptance criteria is not an authoritative spec
|
|
149
|
+
* (there is nothing the work is required to do), so it throws rather than
|
|
150
|
+
* digesting an empty authority.
|
|
151
|
+
*
|
|
152
|
+
* @param {{ acceptanceCriteria?: unknown, definitionOfDone?: unknown, nonGoals?: unknown }} spec
|
|
153
|
+
* @returns {{ acceptanceCriteria: string[], definitionOfDone: string[], nonGoals: string[] }}
|
|
154
|
+
*/
|
|
155
|
+
export function normalizeSpec(spec) {
|
|
156
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
|
|
157
|
+
throw new Error("spec must be an object with acceptanceCriteria/definitionOfDone/nonGoals arrays");
|
|
158
|
+
}
|
|
159
|
+
const normalizeList = (value, label) => {
|
|
160
|
+
if (value === undefined || value === null) return [];
|
|
161
|
+
if (!Array.isArray(value)) {
|
|
162
|
+
throw new Error(`spec.${label} must be an array of strings`);
|
|
163
|
+
}
|
|
164
|
+
return value
|
|
165
|
+
.map((item) => normalizeCriterionText(item))
|
|
166
|
+
.filter((item) => item.length > 0);
|
|
167
|
+
};
|
|
168
|
+
const normalized = {
|
|
169
|
+
acceptanceCriteria: normalizeList(spec.acceptanceCriteria, "acceptanceCriteria"),
|
|
170
|
+
definitionOfDone: normalizeList(spec.definitionOfDone, "definitionOfDone"),
|
|
171
|
+
nonGoals: normalizeList(spec.nonGoals, "nonGoals"),
|
|
172
|
+
};
|
|
173
|
+
if (normalized.acceptanceCriteria.length === 0) {
|
|
174
|
+
throw new Error("spec has no acceptance criteria — not an authoritative spec (fail closed)");
|
|
175
|
+
}
|
|
176
|
+
return normalized;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The ordered criterion id list for a spec — the COMPLETE authoritative set the
|
|
181
|
+
* judge must evaluate every finding against. `<prefix>:<index>` per item, in
|
|
182
|
+
* SPEC_CATEGORIES order. Accepts either a raw or already-normalized spec.
|
|
183
|
+
* @param {object} spec
|
|
184
|
+
* @returns {string[]}
|
|
185
|
+
*/
|
|
186
|
+
export function specCriterionIds(spec) {
|
|
187
|
+
// ALWAYS normalize — never trust the input to be pre-normalized. normalizeSpec
|
|
188
|
+
// is idempotent on already-normalized text, and normalizing here is what keeps
|
|
189
|
+
// the criterion id set byte-aligned with computeSpecDigest's own normalized
|
|
190
|
+
// view: a raw spec carrying an empty/whitespace-only criterion would otherwise
|
|
191
|
+
// yield a phantom id (and shift every later index) that the digested set does
|
|
192
|
+
// not, so the two revision-identity views would disagree on the same input.
|
|
193
|
+
const normalized = normalizeSpec(spec);
|
|
194
|
+
const ids = [];
|
|
195
|
+
for (const category of SPEC_CATEGORIES) {
|
|
196
|
+
normalized[category].forEach((_item, index) => {
|
|
197
|
+
ids.push(`${CATEGORY_ID_PREFIX[category]}:${index}`);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return ids;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Deterministic digest of the normalized AC/DoD/Non-goals. Identifies the spec
|
|
205
|
+
* authority. Never takes or derives from a head SHA. Same normalized spec always
|
|
206
|
+
* yields the same digest; any AC/DoD/non-goal text change yields a new one.
|
|
207
|
+
* @param {object} spec
|
|
208
|
+
* @returns {string} `sha256:<hex>`
|
|
209
|
+
*/
|
|
210
|
+
export function computeSpecDigest(spec) {
|
|
211
|
+
const normalized = normalizeSpec(spec);
|
|
212
|
+
return sha256Hex({
|
|
213
|
+
domain: SPEC_DIGEST_DOMAIN,
|
|
214
|
+
v: 1,
|
|
215
|
+
acceptanceCriteria: normalized.acceptanceCriteria,
|
|
216
|
+
definitionOfDone: normalized.definitionOfDone,
|
|
217
|
+
nonGoals: normalized.nonGoals,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Deterministic digest of the reviewed implementation/prose content. A DISTINCT
|
|
223
|
+
* identity from `specDigest` (separate domain tag), so identical bytes reviewed
|
|
224
|
+
* as content never collide with the same bytes read as spec.
|
|
225
|
+
* @param {unknown} content
|
|
226
|
+
* @returns {string} `sha256:<hex>`
|
|
227
|
+
*/
|
|
228
|
+
export function computeContentDigest(content) {
|
|
229
|
+
return sha256Hex({ domain: CONTENT_DIGEST_DOMAIN, v: 1, content: String(content ?? "") });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function assertDigestShape(value, label) {
|
|
233
|
+
if (typeof value !== "string" || !DIGEST_RE.test(value)) {
|
|
234
|
+
throw new Error(`${label} must be a "sha256:<64 hex>" digest`);
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeHeadSha(value, label = "headSha") {
|
|
240
|
+
const sha = String(value ?? "").trim().toLowerCase();
|
|
241
|
+
if (!HEAD_SHA_RE.test(sha)) {
|
|
242
|
+
throw new Error(`${label} must be a 7-64 char hex git SHA`);
|
|
243
|
+
}
|
|
244
|
+
return sha;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build (or validate) the two independent revision identities for a decision.
|
|
249
|
+
* Enforces the core invariant fail-closed: `specDigest` and `contentDigest` are
|
|
250
|
+
* both `sha256:`-prefixed and MUST differ from each other and from the bare
|
|
251
|
+
* `headSha` — a head SHA can never stand in for a spec digest.
|
|
252
|
+
*
|
|
253
|
+
* @param {object} input
|
|
254
|
+
* @param {object} [input.spec] — used to compute specDigest when not supplied
|
|
255
|
+
* @param {string} [input.specDigest]
|
|
256
|
+
* @param {string} input.headSha
|
|
257
|
+
* @param {unknown} [input.content] — used to compute contentDigest when not supplied
|
|
258
|
+
* @param {string} [input.contentDigest]
|
|
259
|
+
* @returns {{ specDigest: string, headSha: string, contentDigest: string }}
|
|
260
|
+
*/
|
|
261
|
+
export function buildRevisionIdentity({ spec, specDigest, headSha, content, contentDigest } = {}) {
|
|
262
|
+
const resolvedSpecDigest = assertDigestShape(
|
|
263
|
+
specDigest ?? computeSpecDigest(spec),
|
|
264
|
+
"specDigest",
|
|
265
|
+
);
|
|
266
|
+
const sha = normalizeHeadSha(headSha);
|
|
267
|
+
const resolvedContentDigest = assertDigestShape(
|
|
268
|
+
contentDigest ?? computeContentDigest(content),
|
|
269
|
+
"contentDigest",
|
|
270
|
+
);
|
|
271
|
+
if (resolvedSpecDigest === resolvedContentDigest) {
|
|
272
|
+
throw new Error("SPEC-AUTHORITY-REVISION-IDENTITIES: specDigest and contentDigest must be distinct identities (fail closed)");
|
|
273
|
+
}
|
|
274
|
+
// The domain separator in computeSpecDigest is the structural guarantee that a
|
|
275
|
+
// specDigest is never derivable from a head SHA. This is the defensive tripwire
|
|
276
|
+
// for an explicitly-SUPPLIED digest: reject when the digest's hex body equals or
|
|
277
|
+
// embeds the head SHA (any length), not only the 64-hex exact case — the
|
|
278
|
+
// exact-equality-only form was a no-op for a normal 40-hex Git SHA. The
|
|
279
|
+
// false-positive probability of a real spec digest incidentally embedding the
|
|
280
|
+
// head SHA is negligible, and the fail-closed direction is safe.
|
|
281
|
+
if (resolvedSpecDigest.slice("sha256:".length).includes(sha)) {
|
|
282
|
+
throw new Error("SPEC-AUTHORITY-REVISION-IDENTITIES: specDigest must not be derived from or embed headSha (fail closed)");
|
|
283
|
+
}
|
|
284
|
+
return { specDigest: resolvedSpecDigest, headSha: sha, contentDigest: resolvedContentDigest };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeIdSet(value, label) {
|
|
288
|
+
if (!Array.isArray(value)) {
|
|
289
|
+
throw new Error(`${label} must be an array of criterion ids`);
|
|
290
|
+
}
|
|
291
|
+
const set = new Set();
|
|
292
|
+
for (const raw of value) {
|
|
293
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
294
|
+
throw new Error(`${label} must contain only non-empty criterion id strings`);
|
|
295
|
+
}
|
|
296
|
+
set.add(raw.trim());
|
|
297
|
+
}
|
|
298
|
+
return set;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Validate ONE judge decision for a single finding against the complete spec.
|
|
303
|
+
* Pure and fail-closed: throws (never returns a partial decision) unless every
|
|
304
|
+
* authority invariant holds. Returns the normalized decision on success.
|
|
305
|
+
*
|
|
306
|
+
* Decision shape:
|
|
307
|
+
* ```
|
|
308
|
+
* {
|
|
309
|
+
* index: <0-based finding index>,
|
|
310
|
+
* outcome: <one of SPEC_AUTHORITY_OUTCOME_VALUES>,
|
|
311
|
+
* specDigest, headSha, contentDigest, // must match the run's current identities
|
|
312
|
+
* checkedCriteria: [<every criterion id>], // WHOLE-spec coverage; a partial /
|
|
313
|
+
* // supportive-only subset fails closed
|
|
314
|
+
* conflictingCriteria: [<ids>], // required + non-empty for the two conflict outcomes
|
|
315
|
+
* rationale: "<non-empty>",
|
|
316
|
+
* authorizedRemediation: "<...>", // required for valid_compliant
|
|
317
|
+
* rejectedRemediations: ["<...>"] // optional audit of rejected options
|
|
318
|
+
* }
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @param {unknown} decision
|
|
322
|
+
* @param {object} context
|
|
323
|
+
* @param {string} context.specDigest — the run's current spec digest
|
|
324
|
+
* @param {string} context.headSha — the run's current reviewed head
|
|
325
|
+
* @param {string} context.contentDigest — the run's current reviewed content digest
|
|
326
|
+
* @param {string[]} context.criterionIds — the COMPLETE ordered criterion id set
|
|
327
|
+
* @returns {object} the normalized decision
|
|
328
|
+
*/
|
|
329
|
+
export function validateSpecAuthorityDecision(decision, { specDigest, headSha, contentDigest, criterionIds } = {}) {
|
|
330
|
+
const currentSpecDigest = assertDigestShape(specDigest, "context.specDigest");
|
|
331
|
+
const currentHead = normalizeHeadSha(headSha, "context.headSha");
|
|
332
|
+
const currentContentDigest = assertDigestShape(contentDigest, "context.contentDigest");
|
|
333
|
+
const fullCriteria = normalizeIdSet(criterionIds, "context.criterionIds");
|
|
334
|
+
if (fullCriteria.size === 0) {
|
|
335
|
+
throw new Error("context.criterionIds must be the non-empty complete criterion set (fail closed)");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!decision || typeof decision !== "object" || Array.isArray(decision)) {
|
|
339
|
+
throw new Error("spec-authority decision must be an object");
|
|
340
|
+
}
|
|
341
|
+
const d = /** @type {Record<string, unknown>} */ (decision);
|
|
342
|
+
if (!Number.isInteger(d.index) || /** @type {number} */ (d.index) < 0) {
|
|
343
|
+
throw new Error("spec-authority decision.index must be a non-negative integer");
|
|
344
|
+
}
|
|
345
|
+
if (!SPEC_AUTHORITY_OUTCOME_VALUES.includes(d.outcome)) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`spec-authority decision.outcome must be one of: ${SPEC_AUTHORITY_OUTCOME_VALUES.join(", ")}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
// Revision identities must be pinned AND current. A stale specDigest/headSha/
|
|
351
|
+
// contentDigest fails closed — a decision made against an old revision must
|
|
352
|
+
// never authorize a fix or a gate transition at the current one.
|
|
353
|
+
if (assertDigestShape(d.specDigest, "decision.specDigest") !== currentSpecDigest) {
|
|
354
|
+
throw new Error("SPEC-AUTHORITY-STALE-REVISION-FAIL-CLOSED: decision.specDigest is stale/mismatched — re-decide against the current spec (fail closed)");
|
|
355
|
+
}
|
|
356
|
+
if (normalizeHeadSha(d.headSha, "decision.headSha") !== currentHead) {
|
|
357
|
+
throw new Error("SPEC-AUTHORITY-STALE-REVISION-FAIL-CLOSED: decision.headSha is stale/mismatched — re-decide against the current head (fail closed)");
|
|
358
|
+
}
|
|
359
|
+
if (assertDigestShape(d.contentDigest, "decision.contentDigest") !== currentContentDigest) {
|
|
360
|
+
throw new Error("SPEC-AUTHORITY-STALE-REVISION-FAIL-CLOSED: decision.contentDigest is stale/mismatched — re-decide against the current content (fail closed)");
|
|
361
|
+
}
|
|
362
|
+
if (typeof d.rationale !== "string" || d.rationale.trim().length === 0) {
|
|
363
|
+
throw new Error("spec-authority decision.rationale must be a non-empty string");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// WHOLE-SPEC evaluation: checkedCriteria must cover EVERY criterion id. A
|
|
367
|
+
// partial / supportive-only subset cannot produce a valid disposition — this
|
|
368
|
+
// is the deterministic enforcement of "a citation to one supportive criterion
|
|
369
|
+
// is insufficient".
|
|
370
|
+
const checked = normalizeIdSet(d.checkedCriteria, "decision.checkedCriteria");
|
|
371
|
+
const foreign = [...checked].filter((id) => !fullCriteria.has(id));
|
|
372
|
+
if (foreign.length > 0) {
|
|
373
|
+
throw new Error(`spec-authority decision.checkedCriteria names unknown criterion id(s): ${foreign.join(", ")}`);
|
|
374
|
+
}
|
|
375
|
+
const uncovered = [...fullCriteria].filter((id) => !checked.has(id));
|
|
376
|
+
if (uncovered.length > 0) {
|
|
377
|
+
throw new Error(`SPEC-AUTHORITY-WHOLE-SPEC-EVAL: decision did not evaluate the whole spec, so it cannot produce a valid disposition — uncovered criteria: ${uncovered.join(", ")} (supportive-only/partial citation is insufficient; fail closed)`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// The two conflict outcomes require explicit conflict evidence: a non-empty
|
|
381
|
+
// conflictingCriteria drawn from the spec. Autonomous rejection is only
|
|
382
|
+
// legitimate when it names what the finding/remedy conflicts with.
|
|
383
|
+
const isConflict =
|
|
384
|
+
d.outcome === SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS ||
|
|
385
|
+
d.outcome === SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS;
|
|
386
|
+
let conflictingCriteria = [];
|
|
387
|
+
if (isConflict) {
|
|
388
|
+
const conflicts = normalizeIdSet(d.conflictingCriteria, "decision.conflictingCriteria");
|
|
389
|
+
if (conflicts.size === 0) {
|
|
390
|
+
throw new Error(`SPEC-AUTHORITY-CONFLICT-EVIDENCE: ${d.outcome} decision requires non-empty conflictingCriteria (explicit conflict evidence; fail closed)`);
|
|
391
|
+
}
|
|
392
|
+
const unknownConflicts = [...conflicts].filter((id) => !fullCriteria.has(id));
|
|
393
|
+
if (unknownConflicts.length > 0) {
|
|
394
|
+
throw new Error(`spec-authority decision.conflictingCriteria names unknown criterion id(s): ${unknownConflicts.join(", ")}`);
|
|
395
|
+
}
|
|
396
|
+
conflictingCriteria = [...conflicts];
|
|
397
|
+
} else if (d.conflictingCriteria !== undefined && d.conflictingCriteria !== null) {
|
|
398
|
+
// A non-conflict outcome must not smuggle a conflict list.
|
|
399
|
+
throw new Error(`spec-authority ${d.outcome} decision must not carry conflictingCriteria`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// valid_compliant must name the authorized (compliant) remediation. The fixer
|
|
403
|
+
// may choose among compliant alternatives, but the judge authorizes a concrete
|
|
404
|
+
// compliant direction, not a blank check.
|
|
405
|
+
let authorizedRemediation;
|
|
406
|
+
if (d.outcome === SPEC_AUTHORITY_OUTCOMES.VALID_COMPLIANT) {
|
|
407
|
+
if (typeof d.authorizedRemediation !== "string" || d.authorizedRemediation.trim().length === 0) {
|
|
408
|
+
throw new Error("spec-authority valid_compliant decision requires a non-empty authorizedRemediation");
|
|
409
|
+
}
|
|
410
|
+
authorizedRemediation = d.authorizedRemediation.trim();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const rejectedRemediations = Array.isArray(d.rejectedRemediations)
|
|
414
|
+
? d.rejectedRemediations.filter((r) => typeof r === "string" && r.trim().length > 0).map((r) => r.trim())
|
|
415
|
+
: [];
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
index: d.index,
|
|
419
|
+
outcome: d.outcome,
|
|
420
|
+
specDigest: currentSpecDigest,
|
|
421
|
+
headSha: currentHead,
|
|
422
|
+
contentDigest: currentContentDigest,
|
|
423
|
+
checkedCriteria: [...checked],
|
|
424
|
+
conflictingCriteria,
|
|
425
|
+
rationale: d.rationale.trim(),
|
|
426
|
+
...(authorizedRemediation ? { authorizedRemediation } : {}),
|
|
427
|
+
...(rejectedRemediations.length > 0 ? { rejectedRemediations } : {}),
|
|
428
|
+
requiresHumanDecision: outcomeRequiresHumanDecision(d.outcome),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Validate a full spec-authority verdict block (the per-run judge record that
|
|
434
|
+
* accompanies the relevance verdict): the pinned run identities plus one
|
|
435
|
+
* validated decision per finding. Fail-closed: every finding index must be
|
|
436
|
+
* disposed exactly once, and any `spec_cannot_decide` decision surfaces
|
|
437
|
+
* `humanDecisionRequired` so the caller stops at the human-spec-decision state.
|
|
438
|
+
*
|
|
439
|
+
* @param {unknown} verdict — { specDigest, headSha, contentDigest, decisions: [...] }
|
|
440
|
+
* @param {object} context
|
|
441
|
+
* @param {number} context.findingsCount — number of findings the verdict must cover
|
|
442
|
+
* @param {string[]} context.criterionIds — the complete criterion id set
|
|
443
|
+
* @returns {{ specDigest: string, headSha: string, contentDigest: string,
|
|
444
|
+
* decisions: object[], humanDecisionRequired: boolean,
|
|
445
|
+
* humanDecisionIndices: number[], outcomeCounts: Record<string, number> }}
|
|
446
|
+
*/
|
|
447
|
+
export function validateSpecAuthorityVerdict(verdict, { findingsCount, criterionIds } = {}) {
|
|
448
|
+
if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
|
|
449
|
+
throw new Error("spec-authority verdict must be an object");
|
|
450
|
+
}
|
|
451
|
+
const v = /** @type {Record<string, unknown>} */ (verdict);
|
|
452
|
+
const specDigest = assertDigestShape(v.specDigest, "verdict.specDigest");
|
|
453
|
+
const headSha = normalizeHeadSha(v.headSha, "verdict.headSha");
|
|
454
|
+
const contentDigest = assertDigestShape(v.contentDigest, "verdict.contentDigest");
|
|
455
|
+
if (!Number.isInteger(findingsCount) || findingsCount < 0) {
|
|
456
|
+
throw new Error("context.findingsCount must be a non-negative integer");
|
|
457
|
+
}
|
|
458
|
+
if (!Array.isArray(v.decisions)) {
|
|
459
|
+
throw new Error("spec-authority verdict.decisions must be an array");
|
|
460
|
+
}
|
|
461
|
+
const decisions = [];
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
const outcomeCounts = Object.fromEntries(SPEC_AUTHORITY_OUTCOME_VALUES.map((o) => [o, 0]));
|
|
464
|
+
const humanDecisionIndices = [];
|
|
465
|
+
for (const raw of v.decisions) {
|
|
466
|
+
const decision = validateSpecAuthorityDecision(raw, { specDigest, headSha, contentDigest, criterionIds });
|
|
467
|
+
if (decision.index >= findingsCount) {
|
|
468
|
+
throw new Error(`spec-authority decision index ${decision.index} is out of range (findings has ${findingsCount} entries)`);
|
|
469
|
+
}
|
|
470
|
+
if (seen.has(decision.index)) {
|
|
471
|
+
throw new Error(`spec-authority verdict has a duplicate decision for finding index ${decision.index}`);
|
|
472
|
+
}
|
|
473
|
+
seen.add(decision.index);
|
|
474
|
+
outcomeCounts[decision.outcome] += 1;
|
|
475
|
+
if (decision.requiresHumanDecision) humanDecisionIndices.push(decision.index);
|
|
476
|
+
decisions.push(decision);
|
|
477
|
+
}
|
|
478
|
+
const uncovered = [];
|
|
479
|
+
for (let i = 0; i < findingsCount; i += 1) {
|
|
480
|
+
if (!seen.has(i)) uncovered.push(i);
|
|
481
|
+
}
|
|
482
|
+
if (uncovered.length > 0) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`spec-authority verdict does not dispose ${uncovered.length} finding(s) (indexes: ${uncovered.join(", ")}) — every finding needs a whole-spec outcome (fail closed)`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
// Canonical ordering: sort by finding index so a verdict's decisions,
|
|
488
|
+
// humanDecisionIndices, and any downstream join (e.g. the human-decision
|
|
489
|
+
// reason string) are byte-stable regardless of the input's submission order —
|
|
490
|
+
// required for cross-harness determinism.
|
|
491
|
+
decisions.sort((a, b) => a.index - b.index);
|
|
492
|
+
humanDecisionIndices.sort((a, b) => a - b);
|
|
493
|
+
return {
|
|
494
|
+
specDigest,
|
|
495
|
+
headSha,
|
|
496
|
+
contentDigest,
|
|
497
|
+
decisions,
|
|
498
|
+
humanDecisionRequired: humanDecisionIndices.length > 0,
|
|
499
|
+
humanDecisionIndices,
|
|
500
|
+
outcomeCounts,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Resolve which prior criterion approvals survive a revision change. This is the
|
|
506
|
+
* one authority for both invalidation rules:
|
|
507
|
+
*
|
|
508
|
+
* - SPEC CHANGE (currentSpecDigest !== priorSpecDigest): a human-approved spec
|
|
509
|
+
* change stales EVERY prior-derived approval. Nothing carries; all must be
|
|
510
|
+
* re-established against the new spec.
|
|
511
|
+
* - CONTENT CHANGE (same specDigest, a fixer push): only the approvals for the
|
|
512
|
+
* criteria whose covered content the push changed (`affectedCriteria`) are
|
|
513
|
+
* stale. An unaffected criterion carries forward ONLY when `carryForwardProof`
|
|
514
|
+
* positively proves BOTH its governing spec text is unchanged AND its covered
|
|
515
|
+
* surface is unchanged. Missing/incomplete/false proof for an unaffected
|
|
516
|
+
* criterion fails closed to fresh review (stale).
|
|
517
|
+
*
|
|
518
|
+
* Fully deterministic and fail-closed. Unknown impact always stales.
|
|
519
|
+
*
|
|
520
|
+
* @param {object} input
|
|
521
|
+
* @param {string} input.priorSpecDigest
|
|
522
|
+
* @param {string} input.currentSpecDigest
|
|
523
|
+
* @param {string[]} input.priorApprovedCriteria — criterion ids approved under the prior revision
|
|
524
|
+
* @param {string[]} [input.affectedCriteria] — ids whose covered content the push changed
|
|
525
|
+
* @param {Record<string, { specTextUnchanged?: boolean, coveredSurfaceUnchanged?: boolean }>} [input.carryForwardProof]
|
|
526
|
+
* @returns {{ specChanged: boolean, stale: string[], carried: string[], reasons: Record<string, string> }}
|
|
527
|
+
*/
|
|
528
|
+
export function resolveCriterionInvalidation({
|
|
529
|
+
priorSpecDigest,
|
|
530
|
+
currentSpecDigest,
|
|
531
|
+
priorApprovedCriteria,
|
|
532
|
+
affectedCriteria = [],
|
|
533
|
+
carryForwardProof = {},
|
|
534
|
+
} = {}) {
|
|
535
|
+
const prior = assertDigestShape(priorSpecDigest, "priorSpecDigest");
|
|
536
|
+
const current = assertDigestShape(currentSpecDigest, "currentSpecDigest");
|
|
537
|
+
const approved = [...normalizeIdSet(priorApprovedCriteria, "priorApprovedCriteria")];
|
|
538
|
+
const reasons = {};
|
|
539
|
+
|
|
540
|
+
if (prior !== current) {
|
|
541
|
+
// Human-approved spec change: a new specDigest invalidates every derived
|
|
542
|
+
// approval. Nothing carries.
|
|
543
|
+
for (const id of approved) reasons[id] = "spec_digest_changed — prior approval derived from a superseded spec revision";
|
|
544
|
+
return { specChanged: true, stale: approved, carried: [], reasons };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const affected = normalizeIdSet(affectedCriteria, "affectedCriteria");
|
|
548
|
+
const stale = [];
|
|
549
|
+
const carried = [];
|
|
550
|
+
for (const id of approved) {
|
|
551
|
+
if (affected.has(id)) {
|
|
552
|
+
stale.push(id);
|
|
553
|
+
reasons[id] = "fixer push changed content covered by this criterion — approval stale, fresh review required";
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const proof = carryForwardProof[id];
|
|
557
|
+
const proven =
|
|
558
|
+
proof && typeof proof === "object" &&
|
|
559
|
+
proof.specTextUnchanged === true &&
|
|
560
|
+
proof.coveredSurfaceUnchanged === true;
|
|
561
|
+
if (proven) {
|
|
562
|
+
carried.push(id);
|
|
563
|
+
reasons[id] = "carried forward — proven spec text unchanged and covered surface unchanged";
|
|
564
|
+
} else {
|
|
565
|
+
stale.push(id);
|
|
566
|
+
reasons[id] = "unaffected but carry-forward not positively proven (spec text and/or covered surface unproven) — fail closed to fresh review";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return { specChanged: false, stale, carried, reasons };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* AC1 (issue 2008 / ADR 0061): the ONE shared identity-stamp helper every
|
|
574
|
+
* gate/fixer record writer threads its revision identity + checked criteria
|
|
575
|
+
* through, so the writers cannot drift from independently-recomputed fields.
|
|
576
|
+
* Validates the pinned trio (reusing {@link assertDigestShape} /
|
|
577
|
+
* {@link normalizeHeadSha}) plus the checked criteria (reusing
|
|
578
|
+
* {@link normalizeIdSet}, sorted for byte-stable determinism), and returns a
|
|
579
|
+
* NEW object — never mutates `record` — with the stamp nested under a
|
|
580
|
+
* `specAuthority` key so it can never collide with a writer's own fields.
|
|
581
|
+
* Fail-closed: throws on a missing/invalid identity rather than silently
|
|
582
|
+
* stamping a partial/malformed trio.
|
|
583
|
+
*
|
|
584
|
+
* @param {object} record — the record to stamp (spread into the returned copy)
|
|
585
|
+
* @param {object} identity
|
|
586
|
+
* @param {string} identity.specDigest
|
|
587
|
+
* @param {string} identity.headSha
|
|
588
|
+
* @param {string} identity.contentDigest
|
|
589
|
+
* @param {string[]} identity.checkedCriteria
|
|
590
|
+
* @returns {object} `{ ...record, specAuthority: { specDigest, headSha, contentDigest, checkedCriteria } }`
|
|
591
|
+
*/
|
|
592
|
+
export function stampSpecAuthorityIdentity(record, { specDigest, headSha, contentDigest, checkedCriteria } = {}) {
|
|
593
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
594
|
+
throw new Error("stampSpecAuthorityIdentity: record must be an object");
|
|
595
|
+
}
|
|
596
|
+
const stampedSpecDigest = assertDigestShape(specDigest, "specDigest");
|
|
597
|
+
const stampedHeadSha = normalizeHeadSha(headSha);
|
|
598
|
+
const stampedContentDigest = assertDigestShape(contentDigest, "contentDigest");
|
|
599
|
+
const checked = [...normalizeIdSet(checkedCriteria, "checkedCriteria")].sort();
|
|
600
|
+
return {
|
|
601
|
+
...record,
|
|
602
|
+
specAuthority: {
|
|
603
|
+
specDigest: stampedSpecDigest,
|
|
604
|
+
headSha: stampedHeadSha,
|
|
605
|
+
contentDigest: stampedContentDigest,
|
|
606
|
+
checkedCriteria: checked,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// AC7 (issue 2008 / ADR 0061): pure affected-criteria producer
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
// ponytail: minimal glob subset — exact path, a `dir/**` prefix, or a single
|
|
616
|
+
// `*` (matches any run of non-`/` characters) within one path segment. Every
|
|
617
|
+
// OTHER character, including every other regex metacharacter (`?`, `.`, `+`,
|
|
618
|
+
// `(`, `)`, `[`, `]`, `{`, `}`, `^`, `$`, `|`), is matched LITERALLY — a glob
|
|
619
|
+
// is never handed to RegExp unescaped. No glob/minimatch/picomatch util
|
|
620
|
+
// exists in this repo (checked packages/core/src and
|
|
621
|
+
// analysis/change-classifier.mjs); upgrade to picomatch/minimatch if a
|
|
622
|
+
// coverage map ever needs a richer subset (brace expansion, mid-pattern `**`).
|
|
623
|
+
function matchesCoverageGlob(pattern, filePath) {
|
|
624
|
+
if (pattern === filePath) return true;
|
|
625
|
+
if (pattern.endsWith("/**")) {
|
|
626
|
+
const prefix = pattern.slice(0, -3);
|
|
627
|
+
return filePath === prefix || filePath.startsWith(`${prefix}/`);
|
|
628
|
+
}
|
|
629
|
+
if (pattern.includes("*")) {
|
|
630
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\?]/gu, "\\$&").replace(/\*/gu, "[^/]*");
|
|
631
|
+
return new RegExp(`^${escaped}$`, "u").test(filePath);
|
|
632
|
+
}
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Pure, fail-closed producer: map a fixer push's changed paths to the
|
|
638
|
+
* criteria whose declared coverage they touch. A changed path matching >=1
|
|
639
|
+
* criterion's glob(s) marks those criteria affected. A changed path matching
|
|
640
|
+
* ZERO criteria fails closed: it is recorded in `unmatchedPaths` and sets
|
|
641
|
+
* `uncertain: true` so the caller (the judge-pass bridge) treats every
|
|
642
|
+
* prior-approved criterion as affected (the pre-existing all-stale fallback)
|
|
643
|
+
* instead of silently under-staling an unmapped change.
|
|
644
|
+
*
|
|
645
|
+
* @param {object} input
|
|
646
|
+
* @param {string[]} input.changedPaths — repo-relative paths a fixer push changed
|
|
647
|
+
* @param {Record<string, string[]>} input.criterionCoverage — criterionId -> glob pattern array
|
|
648
|
+
* @returns {{ affectedCriteria: string[], uncertain: boolean, unmatchedPaths: string[] }}
|
|
649
|
+
*/
|
|
650
|
+
export function resolveAffectedCriteria({ changedPaths, criterionCoverage } = {}) {
|
|
651
|
+
if (!Array.isArray(changedPaths)) {
|
|
652
|
+
throw new Error("changedPaths must be an array of repo-relative path strings");
|
|
653
|
+
}
|
|
654
|
+
const paths = changedPaths.map((p, i) => {
|
|
655
|
+
if (typeof p !== "string" || p.trim().length === 0) {
|
|
656
|
+
throw new Error(`changedPaths[${i}] must be a non-empty string`);
|
|
657
|
+
}
|
|
658
|
+
return p.trim();
|
|
659
|
+
});
|
|
660
|
+
if (!criterionCoverage || typeof criterionCoverage !== "object" || Array.isArray(criterionCoverage)) {
|
|
661
|
+
throw new Error("criterionCoverage must be an object mapping criterionId -> glob pattern array");
|
|
662
|
+
}
|
|
663
|
+
const coverageEntries = Object.entries(criterionCoverage).map(([rawCriterionId, globs]) => {
|
|
664
|
+
const criterionId = rawCriterionId.trim();
|
|
665
|
+
if (criterionId.length === 0) {
|
|
666
|
+
throw new Error("criterionCoverage keys must be non-empty criterion id strings");
|
|
667
|
+
}
|
|
668
|
+
if (!Array.isArray(globs)) {
|
|
669
|
+
throw new Error(`criterionCoverage[${JSON.stringify(rawCriterionId)}] must be an array of glob strings`);
|
|
670
|
+
}
|
|
671
|
+
const patterns = globs.map((g, i) => {
|
|
672
|
+
if (typeof g !== "string" || g.trim().length === 0) {
|
|
673
|
+
throw new Error(`criterionCoverage[${JSON.stringify(rawCriterionId)}][${i}] must be a non-empty glob string`);
|
|
674
|
+
}
|
|
675
|
+
return g.trim();
|
|
676
|
+
});
|
|
677
|
+
return [criterionId, patterns];
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const affected = new Set();
|
|
681
|
+
const unmatchedPaths = [];
|
|
682
|
+
for (const filePath of paths) {
|
|
683
|
+
let matchedAny = false;
|
|
684
|
+
for (const [criterionId, patterns] of coverageEntries) {
|
|
685
|
+
if (patterns.some((pattern) => matchesCoverageGlob(pattern, filePath))) {
|
|
686
|
+
affected.add(criterionId);
|
|
687
|
+
matchedAny = true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (!matchedAny) unmatchedPaths.push(filePath);
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
affectedCriteria: [...affected].sort(),
|
|
694
|
+
uncertain: unmatchedPaths.length > 0,
|
|
695
|
+
unmatchedPaths,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Convenience: extract a `{ acceptanceCriteria, definitionOfDone, nonGoals }`
|
|
701
|
+
* spec from a canonical tracker issue body, reusing the shared section +
|
|
702
|
+
* checklist/matrix parsers so the spec digest is computed from exactly the
|
|
703
|
+
* content the refinement gate already recognizes as AUTHORITATIVE. Returns the
|
|
704
|
+
* raw (un-normalized) lists; pass the result to {@link computeSpecDigest} /
|
|
705
|
+
* {@link normalizeSpec}.
|
|
706
|
+
*
|
|
707
|
+
* ponytail (#2016 root cause fix): the AC/DoD source is the authoritative
|
|
708
|
+
* AC→DoD mapping MATRIX (`detectAcDodMatrix`, reused byte-identical from
|
|
709
|
+
* `issue-refinement-artifact.mjs` — no second matrix parser) when the body
|
|
710
|
+
* carries one that parses as valid; the list-form AC/DoD checklists
|
|
711
|
+
* (`extractChecklistItems`) are a REDUNDANT presentation projection of that
|
|
712
|
+
* same matrix (`derivePrChecklistsFromIssueMatrix` derives the PR-side
|
|
713
|
+
* checklist from it) and are read only as the fail-closed fallback for older
|
|
714
|
+
* issue bodies that carry no matrix at all. This is the fix for the reported
|
|
715
|
+
* bug: adding/removing/re-heading a checklist alias that projects an
|
|
716
|
+
* UNCHANGED matrix no longer touches `specDigest`, because the checklist is
|
|
717
|
+
* no longer part of the hashed input once a valid matrix exists. Any edit
|
|
718
|
+
* that changes the matrix itself — a criterion's text, its completion-
|
|
719
|
+
* evidence cell, or the row set (add/remove) — changes `matrix.rows` and
|
|
720
|
+
* therefore still changes the digest (fail-closed: no genuine spec change is
|
|
721
|
+
* exempted). Non-goals are unaffected by this change: they were never part of
|
|
722
|
+
* the redundant-checklist problem (the matrix does not carry Non-goals), so
|
|
723
|
+
* they stay sourced from the `## Non-goals` section exactly as before.
|
|
724
|
+
*
|
|
725
|
+
* Fail-closed fallback: when `detectAcDodMatrix` reports `found: false` (no
|
|
726
|
+
* matrix at all) or `valid: false` (empty/malformed/identifier-only table —
|
|
727
|
+
* i.e. it cannot be positively parsed as a real semantic mapping), this falls
|
|
728
|
+
* back to the PRE-#2016 behavior of hashing the extracted checklist text
|
|
729
|
+
* verbatim. A body this function cannot prove carries an equivalent matrix
|
|
730
|
+
* must never silently narrow what gets digested.
|
|
731
|
+
*
|
|
732
|
+
* @param {string} body — the tracker issue markdown body
|
|
733
|
+
* @returns {{ acceptanceCriteria: string[], definitionOfDone: string[], nonGoals: string[] }}
|
|
734
|
+
*/
|
|
735
|
+
export function extractSpecFromBody(body) {
|
|
736
|
+
const nonGoalsSection =
|
|
737
|
+
extractSection(body, "Non-goals") ?? extractSection(body, "Non goals");
|
|
738
|
+
const nonGoals = nonGoalsSection ? extractChecklistItems(nonGoalsSection) : [];
|
|
739
|
+
|
|
740
|
+
const matrix = detectAcDodMatrix(body);
|
|
741
|
+
if (matrix.found && matrix.valid && matrix.rows.length > 0) {
|
|
742
|
+
return {
|
|
743
|
+
acceptanceCriteria: matrix.rows.map((row) => row.criterion),
|
|
744
|
+
definitionOfDone: matrix.rows.map((row) => row.evidence),
|
|
745
|
+
nonGoals,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Fallback: no positively-parseable matrix — hash the checklist projection
|
|
750
|
+
// (pre-#2016 behavior) rather than an empty/weaker AC/DoD surface.
|
|
751
|
+
const acSection = extractSection(body, "Acceptance criteria");
|
|
752
|
+
const dodSection =
|
|
753
|
+
extractSection(body, "Definition of done") ?? extractSection(body, "DoD");
|
|
754
|
+
return {
|
|
755
|
+
acceptanceCriteria: acSection ? extractChecklistItems(acSection) : [],
|
|
756
|
+
definitionOfDone: dodSection ? extractChecklistItems(dodSection) : [],
|
|
757
|
+
nonGoals,
|
|
758
|
+
};
|
|
759
|
+
}
|