@dev-loops/core 1.0.0 → 1.0.2-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }