@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
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -1,55 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* gate-fanin.mjs — pure fan-in consolidation + cap/batch planning for the
|
|
3
|
-
* gate-review fork sub-loop
|
|
3
|
+
* gate-review fork sub-loop.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the fan-out, and maps consolidated findings into the `--findings` JSON shape
|
|
11
|
-
* understood by scripts/github/write-gate-findings-log.mjs.
|
|
5
|
+
* PURE: no I/O, never spawns agents. It consolidates the per-angle findings
|
|
6
|
+
* artifacts the fan-out produced, decides the gate verdict, plans the
|
|
7
|
+
* parallel/sequential batching, and maps consolidated findings into the
|
|
8
|
+
* `--findings` JSON shape understood by
|
|
9
|
+
* scripts/github/write-gate-findings-log.mjs.
|
|
12
10
|
*
|
|
13
11
|
* Per-angle review artifact shape (produced by the scoped `review` agent):
|
|
14
12
|
* {
|
|
15
13
|
* angle: string,
|
|
16
14
|
* verdict: "clean" | "findings_present",
|
|
17
|
-
* headSha: string, // reviewed head; consolidate-fanin --head-sha enforces it
|
|
15
|
+
* headSha: string, // reviewed head; consolidate-fanin --head-sha enforces it
|
|
18
16
|
* findings: [{ severity, file?, line?, summary, recommendation? }]
|
|
19
17
|
* }
|
|
20
18
|
*
|
|
21
19
|
* Severity vocabulary (owned here; consumers import SEVERITY_ORDER /
|
|
22
|
-
* VALID_SEVERITIES / normalizeSeverity)
|
|
23
|
-
* severity scale:
|
|
20
|
+
* VALID_SEVERITIES / normalizeSeverity):
|
|
24
21
|
* "high" | "medium" | "low" (defects) | "question" | "nit" (non-defects)
|
|
25
|
-
* Severity is the reviewer's advisory weight
|
|
26
|
-
* (derived at fan-in
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* non-defect categories: a question is answered (never deferred) and an
|
|
32
|
-
* unanswered one blocks gate-close like any unresolved thread; a nit is
|
|
33
|
-
* deferred immediately, with no fixer cycle.
|
|
22
|
+
* Severity is the reviewer's advisory weight. Deferral is a DISPOSITION
|
|
23
|
+
* (derived at fan-in, finalized by the fix cycle / gate close), never a
|
|
24
|
+
* severity; pre-rename spellings are accepted on read and normalized (see
|
|
25
|
+
* LEGACY_SEVERITY_ALIASES / normalizeSeverity). A question is answered (never
|
|
26
|
+
* deferred) and an unanswered one blocks gate-close; a nit defers immediately,
|
|
27
|
+
* with no fixer cycle.
|
|
34
28
|
*/
|
|
35
29
|
|
|
36
30
|
import { scheduleParallelWaves } from "./queue-parallel.mjs";
|
|
37
31
|
import { trimmedOrNull } from "./normalize.mjs";
|
|
38
32
|
|
|
39
33
|
/**
|
|
40
|
-
* Schedule fan-out dispatch units into bounded-concurrency waves
|
|
34
|
+
* Schedule fan-out dispatch units into bounded-concurrency waves: each wave
|
|
35
|
+
* holds at most `maxConcurrent` units, dispatched wave-by-wave (await a free
|
|
36
|
+
* slot before the next) instead of fire-all-then-retry. Replaces the unbounded
|
|
37
|
+
* concurrent fan-out that 429-stormed multi-angle gate rounds.
|
|
41
38
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* conductor dispatches wave-by-wave — awaiting a free slot (wave completion)
|
|
46
|
-
* before launching the next — instead of fire-all-then-retry. This replaces
|
|
47
|
-
* the unbounded concurrent fan-out that 429-stormed multi-angle gate rounds
|
|
48
|
-
* (issue #1588 drive: 5–6 reviewers 429'd per round).
|
|
49
|
-
*
|
|
50
|
-
* Pure: same input always yields the same wave plan (deterministic order, so
|
|
51
|
-
* the wave plan a reviewer's gate-context artifact records is byte-stable
|
|
52
|
-
* across fresh reviewer spawns for the same head+config).
|
|
39
|
+
* Pure and deterministic: same input yields the same wave plan (stable order),
|
|
40
|
+
* so a reviewer's recorded wave plan is byte-stable across fresh spawns for the
|
|
41
|
+
* same head+config.
|
|
53
42
|
*
|
|
54
43
|
* @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output
|
|
55
44
|
* @param {number} [maxConcurrent] — `gates.fanout.maxConcurrent` (default 4, min 1)
|
|
@@ -63,19 +52,14 @@ export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
|
|
|
63
52
|
}
|
|
64
53
|
|
|
65
54
|
/**
|
|
66
|
-
* Adaptive concurrency backoff
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* foreground (one-at-a-time) dispatch. This "retry the unit before reducing
|
|
75
|
-
* concurrency" ordering is owned by GATE-EXEC-DISPATCH-RETRY-BACKOFF in
|
|
76
|
-
* skills/docs/gate-review-sub-loop-contract.md; the backoff is recorded in the
|
|
77
|
-
* round's provenance. Pure; never returns 0 (a backoff from 1 stays 1 →
|
|
78
|
-
* foreground fallback owns that path).
|
|
55
|
+
* Adaptive concurrency backoff: halve the active batch before escalating to
|
|
56
|
+
* foreground one-at-a-time fallback. A transient dispatch failure is first
|
|
57
|
+
* retried on the SAME unit (idempotent single-write artifact); concurrency
|
|
58
|
+
* drops ONLY after that unit's retries are exhausted. This "retry the unit
|
|
59
|
+
* before reducing concurrency" ordering is owned by
|
|
60
|
+
* GATE-EXEC-DISPATCH-RETRY-BACKOFF in
|
|
61
|
+
* skills/docs/gate-review-sub-loop-contract.md. Pure; never returns 0 (a backoff
|
|
62
|
+
* from 1 stays 1 → foreground fallback owns that path).
|
|
79
63
|
* @param {number} maxConcurrent
|
|
80
64
|
* @returns {number}
|
|
81
65
|
*/
|
|
@@ -85,45 +69,29 @@ export function backoffMaxConcurrent(maxConcurrent) {
|
|
|
85
69
|
}
|
|
86
70
|
|
|
87
71
|
/**
|
|
88
|
-
* Reviewer-budget preflight for a gate fan-out
|
|
89
|
-
*
|
|
90
|
-
* Before the conductor dispatches any reviewer, it derives how many reviewers
|
|
91
|
-
* the round needs (one per dispatch unit — fresh angles + re-verifications) and
|
|
92
|
-
* compares against the harness's remaining reviewer budget. When the budget
|
|
93
|
-
* cannot cover the dispatch, the preflight reports the shortfall BEFORE any
|
|
94
|
-
* reviewer spawns, naming the shortfall; the shortfall is a recorded, resumable
|
|
95
|
-
* state (completed per-angle artifacts stay valid for their head, so a later
|
|
96
|
-
* session resumes the fan-out instead of restarting it). A budget shortfall
|
|
97
|
-
* NEVER downgrades a required gate to `inline_single_agent` and NEVER produces a
|
|
98
|
-
* clean verdict — no new gate-exemption path (#1507 AC4).
|
|
72
|
+
* Reviewer-budget preflight for a gate fan-out.
|
|
99
73
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* preflight proceeds (today's behavior); it only blocks on a PROVEN shortfall.
|
|
74
|
+
* Derives how many reviewers the round needs (one per dispatch unit) and
|
|
75
|
+
* compares against the harness's remaining budget. On a PROVEN shortfall it
|
|
76
|
+
* reports the shortfall BEFORE any reviewer spawns; the shortfall is a recorded,
|
|
77
|
+
* resumable state (completed per-angle artifacts stay valid, so a later session
|
|
78
|
+
* resumes rather than restarts). A shortfall NEVER downgrades a required gate to
|
|
79
|
+
* `inline_single_agent` and NEVER produces a clean verdict.
|
|
107
80
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* or inline verdict (#1507 DoD).
|
|
81
|
+
* Pure. `availableReviewers` null = harness exposes no budget → no shortfall
|
|
82
|
+
* provable → preflight proceeds; it blocks only on a proven shortfall. The
|
|
83
|
+
* returned `verdict` and `executionMode` are ALWAYS null: a shortfall is not a
|
|
84
|
+
* verdict, and it fails closed at merge rather than yielding a clean/inline one.
|
|
113
85
|
*
|
|
114
86
|
* @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output (fresh angles + re-verifications)
|
|
115
87
|
* @param {number|null} [availableReviewers] — harness remaining reviewer budget; null/non-finite = unknown/unexposed
|
|
116
|
-
* @param {{ completedAngles?: Iterable<string>, carriedAngles?: Iterable<string> }} [options] —
|
|
117
|
-
*
|
|
118
|
-
* `carriedAngles
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* fan-out instead of restarting it: it re-runs the preflight and dispatches only the groups not
|
|
124
|
-
* already resolved at this head. Membership is matched trim+lowercase (mirrors
|
|
125
|
-
* consolidate-fanin.mjs's own carried-key normalization) so a config/plan case difference in an
|
|
126
|
-
* angle name still excludes the right group instead of silently spending a reviewer on it.
|
|
88
|
+
* @param {{ completedAngles?: Iterable<string>, carriedAngles?: Iterable<string> }} [options] — angle names
|
|
89
|
+
* already resolved for THIS head: `completedAngles` have a clean per-angle artifact stamped for it,
|
|
90
|
+
* `carriedAngles` are proven carried forward from a prior clean head (that carry-forward runs AFTER this
|
|
91
|
+
* preflight, so a head-bump re-gate must feed its result back in). A group whose angles are ALL
|
|
92
|
+
* complete-or-carried is excluded from the required count and `pendingGroups`, so a later session
|
|
93
|
+
* dispatches only unresolved groups. Membership is matched trim+lowercase (mirrors consolidate-fanin.mjs's
|
|
94
|
+
* carried-key normalization) so a case difference still excludes the right group.
|
|
127
95
|
* @returns {{ ok: boolean, dispatch: boolean, requiredReviewers: number, availableReviewers: number|null, shortfall: number|null, reason: string, verdict: null, executionMode: null, pendingGroups: { name: string, angles: string[] }[], skippedGroups: { name: string, angles: string[] }[], completedAngles: string[], carriedAngles: string[] }}
|
|
128
96
|
*/
|
|
129
97
|
export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles, carriedAngles } = {}) {
|
|
@@ -132,17 +100,13 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
|
|
|
132
100
|
new Set(Array.isArray(iterable) ? iterable : iterable == null ? [] : [...iterable]);
|
|
133
101
|
const completedSet = toSet(completedAngles);
|
|
134
102
|
const carriedSet = toSet(carriedAngles);
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// Membership is matched trim+lowercase (`normalizeAngleKey`) — the same
|
|
143
|
-
// normalization consolidate-fanin.mjs applies to its own carried keys — so a
|
|
144
|
-
// config/plan case difference in an angle name still excludes the group
|
|
145
|
-
// instead of leaving it (and its exempted sibling) silently disagreeing.
|
|
103
|
+
// A group already RESOLVED for this round — every angle either clean-stamped
|
|
104
|
+
// for this head or proven carried forward — needs no reviewer and drops from
|
|
105
|
+
// the required count and the pending plan (the conductor dispatches only
|
|
106
|
+
// `pendingGroups`). Membership is matched trim+lowercase (`normalizeAngleKey`),
|
|
107
|
+
// the same normalization consolidate-fanin.mjs applies to its carried keys, so
|
|
108
|
+
// a case difference still excludes the group instead of leaving it and its
|
|
109
|
+
// exempted sibling silently disagreeing.
|
|
146
110
|
const normalizeAngleKey = (a) => String(a).trim().toLowerCase();
|
|
147
111
|
const completedKeys = new Set([...completedSet].map(normalizeAngleKey));
|
|
148
112
|
const carriedKeys = new Set([...carriedSet].map(normalizeAngleKey));
|
|
@@ -165,8 +129,7 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
|
|
|
165
129
|
if (typeof availableReviewers !== "number" || !Number.isFinite(availableReviewers)) {
|
|
166
130
|
return { ok: true, dispatch: true, requiredReviewers, availableReviewers: null, shortfall: null, reason: "budget_unknown", verdict, executionMode, ...resume };
|
|
167
131
|
}
|
|
168
|
-
// A negative/over-spent budget clamps to 0
|
|
169
|
-
// any non-empty round); a fractional budget truncates to the integer floor.
|
|
132
|
+
// A negative/over-spent budget clamps to 0; a fractional budget truncates.
|
|
170
133
|
const available = Math.max(0, Math.trunc(availableReviewers));
|
|
171
134
|
if (requiredReviewers === 0) {
|
|
172
135
|
return { ok: true, dispatch: true, requiredReviewers: 0, availableReviewers: available, shortfall: null, reason: "no_reviewers_needed", verdict, executionMode, ...resume };
|
|
@@ -187,55 +150,35 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
|
|
|
187
150
|
};
|
|
188
151
|
}
|
|
189
152
|
|
|
190
|
-
// Exported
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// Ranked by gate-close urgency, not just defect-severity: "question" sits
|
|
197
|
-
// right after "high" because BOTH force gate-close to stay blocked (a high
|
|
198
|
-
// finding via the fix loop, a question via never being auto-deferred) — it
|
|
199
|
-
// outranks "medium"/"low", which both eventually defer. "nit" trails last:
|
|
200
|
-
// it defers immediately, with no fixer cycle at all.
|
|
153
|
+
// Exported as the single ordered copy of the severity vocabulary so consumers
|
|
154
|
+
// (consolidate-fanin.mjs, upsert-checkpoint-verdict.mjs) rank against it rather
|
|
155
|
+
// than each re-listing — ORDER is part of the contract, not just membership.
|
|
156
|
+
// Ranked by gate-close urgency: "question" sits right after "high" because BOTH
|
|
157
|
+
// force gate-close to stay blocked; "medium"/"low" eventually defer. "nit"
|
|
158
|
+
// trails last: it defers immediately, with no fixer cycle.
|
|
201
159
|
export const SEVERITY_ORDER = Object.freeze(["high", "question", "medium", "low", "nit"]);
|
|
202
160
|
|
|
203
|
-
// The non-defect subset of SEVERITY_ORDER: a "question" is answered (
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// Object.freeze on a Set only locks its OWN properties, not the add/delete
|
|
212
|
-
// methods that mutate its internal collection — freezing is still applied
|
|
213
|
-
// here for consistency with SEVERITY_ORDER and this file's other frozen
|
|
214
|
-
// exports (GATE_CONFIG_KEY, LEGACY_SEVERITY_ALIASES, etc.), and it does stop
|
|
215
|
-
// a caller from attaching a stray own property to the Set object itself.
|
|
161
|
+
// The non-defect subset of SEVERITY_ORDER: a "question" is answered (see
|
|
162
|
+
// deriveDisposition) and a "nit" always defers regardless of any gate's
|
|
163
|
+
// blockCleanOnFindingSeverities (see isDefaultDeferrableSeverity) — neither
|
|
164
|
+
// belongs in a defect-only blocking vocabulary. The single source for that
|
|
165
|
+
// partition, so a consumer derives "defect severities" as SEVERITY_ORDER minus
|
|
166
|
+
// this set. Object.freeze on a Set locks only its OWN properties, not the
|
|
167
|
+
// add/delete that mutate its collection; it is applied for consistency with the
|
|
168
|
+
// other frozen exports and does stop a stray own property on the Set object.
|
|
216
169
|
export const NON_DEFECT_SEVERITIES = Object.freeze(new Set(["question", "nit"]));
|
|
217
170
|
|
|
218
171
|
// Marker gate name → gates.<key> config key. Owned here so every caller of
|
|
219
172
|
// resolveFanoutGroups maps the same way; passing the marker name verbatim
|
|
220
173
|
// resolves no groups and silently downgrades pairing enforcement.
|
|
221
174
|
export const GATE_CONFIG_KEY = Object.freeze({ draft_gate: "draft", pre_approval_gate: "preApproval" });
|
|
222
|
-
// Object.freeze on a Set only locks its OWN properties, not the add/delete
|
|
223
|
-
// methods that mutate its internal collection — freezing is still applied
|
|
224
|
-
// here for consistency with SEVERITY_ORDER and this file's other frozen
|
|
225
|
-
// exports (GATE_CONFIG_KEY, LEGACY_SEVERITY_ALIASES, etc.), and it does stop
|
|
226
|
-
// a caller from attaching a stray own property to the Set object itself.
|
|
227
175
|
export const VALID_SEVERITIES = Object.freeze(new Set(SEVERITY_ORDER));
|
|
228
176
|
|
|
229
177
|
// Pre-rename spellings. Old ledgers, markers, and configs still carry them;
|
|
230
|
-
// every read boundary normalizes through this map.
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// read-side normalizer, not a write-side enforcement boundary:
|
|
235
|
-
// buildFindingMarker (_gate-finding-surface.mjs) is a thin text builder that
|
|
236
|
-
// emits whatever severity string it is given, verbatim (a legacy-spelled
|
|
237
|
-
// marker built directly, e.g. for round-trip test fixtures, still parses
|
|
238
|
-
// correctly via normalizeSeverity on read).
|
|
178
|
+
// every read boundary normalizes through this map. This is a read-side
|
|
179
|
+
// normalizer, not a write-side enforcement boundary: buildFindingMarker emits
|
|
180
|
+
// whatever severity string it is given verbatim, so a legacy-spelled marker
|
|
181
|
+
// built directly still parses correctly via normalizeSeverity on read.
|
|
239
182
|
export const LEGACY_SEVERITY_ALIASES = Object.freeze({
|
|
240
183
|
"must-fix": "high",
|
|
241
184
|
"worth-fixing-now": "medium",
|
|
@@ -245,19 +188,12 @@ export const LEGACY_SEVERITY_ALIASES = Object.freeze({
|
|
|
245
188
|
|
|
246
189
|
/**
|
|
247
190
|
* Map a legacy severity spelling to its canonical name; unknown values pass
|
|
248
|
-
* through trimmed (the caller's validation still rejects them)
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* does not — two call sites trimming inconsistently is exactly how an
|
|
255
|
-
* untrimmed "high " passed one gate's validation and then failed the
|
|
256
|
-
* other's. Deliberately case-SENSITIVE (no lowercasing): every sanctioned
|
|
257
|
-
* writer (slugForMarker, config authoring, this module's own producers)
|
|
258
|
-
* already emits lowercase, so a forged/hand-edited mixed-case value (e.g.
|
|
259
|
-
* "NIT") must fail VALID_SEVERITIES validation and dangle fail-closed rather
|
|
260
|
-
* than being silently coerced into a real severity that then auto-defers.
|
|
191
|
+
* through trimmed (the caller's validation still rejects them), a non-string
|
|
192
|
+
* unchanged. Trims BEFORE the alias lookup so every call site agrees on the
|
|
193
|
+
* same value for a whitespace-varied input. Deliberately case-SENSITIVE: every
|
|
194
|
+
* sanctioned writer emits lowercase, so a forged mixed-case value (e.g. "NIT")
|
|
195
|
+
* must fail VALID_SEVERITIES validation and dangle fail-closed rather than be
|
|
196
|
+
* silently coerced into a real severity that then auto-defers.
|
|
261
197
|
* @param {unknown} severity
|
|
262
198
|
* @returns {unknown}
|
|
263
199
|
*/
|
|
@@ -269,13 +205,9 @@ export function normalizeSeverity(severity) {
|
|
|
269
205
|
|
|
270
206
|
/**
|
|
271
207
|
* Map a (possibly legacy-spelled/untrimmed) severity to its SEVERITY_ORDER
|
|
272
|
-
* index — the
|
|
273
|
-
* (
|
|
274
|
-
*
|
|
275
|
-
* two can never drift on how an unknown severity ranks. An unrecognized
|
|
276
|
-
* severity (after normalization) ranks LAST (`SEVERITY_ORDER.length`, never
|
|
277
|
-
* -1) so it always sorts after every known severity instead of floating
|
|
278
|
-
* above "high" the way a raw, unmapped `indexOf` would.
|
|
208
|
+
* index — the one rank rule every sort/ordering consumer shares. An
|
|
209
|
+
* unrecognized severity ranks LAST (`SEVERITY_ORDER.length`, never -1) so it
|
|
210
|
+
* sorts after every known severity instead of floating above "high".
|
|
279
211
|
* @param {unknown} severity
|
|
280
212
|
* @returns {number}
|
|
281
213
|
*/
|
|
@@ -285,13 +217,9 @@ export function severityRank(severity) {
|
|
|
285
217
|
}
|
|
286
218
|
|
|
287
219
|
/**
|
|
288
|
-
* A zero-initialized severity→count map, one key per SEVERITY_ORDER entry
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* `buildAngleMarker`, reconcile-draft-gate.mjs's no-findings placeholder) used
|
|
292
|
-
* to hand-roll separately via `Object.fromEntries(SEVERITY_ORDER.map((s) =>
|
|
293
|
-
* [s, 0]))` — one copy here means a severity added to SEVERITY_ORDER is
|
|
294
|
-
* zero-initialized everywhere at once.
|
|
220
|
+
* A zero-initialized severity→count map, one key per SEVERITY_ORDER entry in
|
|
221
|
+
* order. Single source so a severity added to SEVERITY_ORDER is zero-initialized
|
|
222
|
+
* everywhere at once.
|
|
295
223
|
* @returns {Record<string, number>}
|
|
296
224
|
*/
|
|
297
225
|
export function zeroSeverityCounts() {
|
|
@@ -299,20 +227,11 @@ export function zeroSeverityCounts() {
|
|
|
299
227
|
}
|
|
300
228
|
|
|
301
229
|
/**
|
|
302
|
-
* Tally `findings` by
|
|
303
|
-
* map.
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* every routed call site here counts already-validated findings in practice
|
|
308
|
-
* (consolidateFanin validates every result's severity before this runs;
|
|
309
|
-
* buildAngleMarker tallies consolidateFanin's own output), so this guard is a
|
|
310
|
-
* defensive floor against future drift, not an escape hatch for accepting
|
|
311
|
-
* unvalidated severities. `findings` and its entries are NOT nullish-tolerant:
|
|
312
|
-
* a nullish `findings` argument throws (not iterable), and a nullish
|
|
313
|
-
* individual entry throws reading `.severity` — no routed caller passes
|
|
314
|
-
* either shape, so a caller that does gets a loud failure instead of a
|
|
315
|
-
* silently wrong all-zero tally.
|
|
230
|
+
* Tally `findings` by normalized severity into a {@link zeroSeverityCounts}
|
|
231
|
+
* map. A finding whose normalized severity is not a SEVERITY_ORDER member is
|
|
232
|
+
* excluded (defensive floor; consolidateFanin validates before this runs). NOT
|
|
233
|
+
* nullish-tolerant: a nullish `findings` or a nullish entry throws — fail-loud
|
|
234
|
+
* rather than a silently wrong all-zero tally.
|
|
316
235
|
* @param {Iterable<{severity: unknown}>} findings
|
|
317
236
|
* @returns {Record<string, number>}
|
|
318
237
|
*/
|
|
@@ -327,9 +246,8 @@ export function tallySeverities(findings) {
|
|
|
327
246
|
|
|
328
247
|
/**
|
|
329
248
|
* Merge a severity→count map's legacy-spelled keys into their canonical keys
|
|
330
|
-
* (summing
|
|
331
|
-
*
|
|
332
|
-
* the caller keeps its own integer/shape checks.
|
|
249
|
+
* (summing) so CLI and programmatic callers share one merge rule. Values pass
|
|
250
|
+
* through unvalidated.
|
|
333
251
|
* @param {Record<string, number>} counts
|
|
334
252
|
* @returns {Record<string, number>} null-prototype object with canonical keys
|
|
335
253
|
*/
|
|
@@ -343,43 +261,46 @@ export function normalizeSeverityCounts(counts) {
|
|
|
343
261
|
}
|
|
344
262
|
|
|
345
263
|
/**
|
|
346
|
-
*
|
|
347
|
-
* `files[0]`
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
264
|
+
* Resolve a finding's file path from either shape the floor accepts: `file`
|
|
265
|
+
* (singular string) or `files[0]` (array shape). The value is TRIMMED and a
|
|
266
|
+
* whitespace-only `file` is treated as ABSENT (falls back to `files[0]`), so the
|
|
267
|
+
* path matches the trimmed commentable-line keys and is a valid GitHub
|
|
268
|
+
* review-comment `path`. Returns `undefined` when neither shape names a
|
|
269
|
+
* non-blank string path.
|
|
270
|
+
* @param {{ file?: unknown, files?: unknown }} finding
|
|
271
|
+
* @returns {string|undefined}
|
|
272
|
+
*/
|
|
273
|
+
export function resolveFindingFile(finding) {
|
|
274
|
+
if (typeof finding?.file === "string" && finding.file.trim().length > 0) return finding.file.trim();
|
|
275
|
+
if (Array.isArray(finding?.files) && typeof finding.files[0] === "string" && finding.files[0].trim().length > 0) {
|
|
276
|
+
return finding.files[0].trim();
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A finding is LOCATABLE-SHAPED when it names a real file (via
|
|
283
|
+
* {@link resolveFindingFile}) and a positive-integer `line`. NECESSARY but not
|
|
284
|
+
* SUFFICIENT for a thread-locatable finding: `isLocatableFinding`
|
|
285
|
+
* (scripts/github/_gate-finding-surface.mjs) additionally requires the file:line
|
|
286
|
+
* to fall inside the reviewed diff, which only that caller can check.
|
|
357
287
|
* @param {{ file?: unknown, files?: unknown, line?: unknown }} finding
|
|
358
288
|
* @returns {boolean}
|
|
359
289
|
*/
|
|
360
290
|
export function hasLocatableShape(finding) {
|
|
361
|
-
const file =
|
|
362
|
-
? finding.file
|
|
363
|
-
: (Array.isArray(finding?.files) ? finding.files[0] : undefined);
|
|
291
|
+
const file = resolveFindingFile(finding);
|
|
364
292
|
return typeof file === "string" && file.trim().length > 0
|
|
365
293
|
&& Number.isInteger(finding?.line) && /** @type {number} */ (finding.line) >= 1;
|
|
366
294
|
}
|
|
367
295
|
|
|
368
296
|
/**
|
|
369
|
-
* Derive the ledger disposition for a finding at `severity`
|
|
370
|
-
* every producer
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* (
|
|
375
|
-
*
|
|
376
|
-
* defect severities — but this stays severity-first rather than
|
|
377
|
-
* isBlocking-first so that invariant is enforced here too, not just at the
|
|
378
|
-
* config boundary). A NON-LOCATABLE question has no resolvable thread to
|
|
379
|
-
* answer through — it is body-filed and deferred by construction, exactly
|
|
380
|
-
* like every other non-`high` body-filed finding
|
|
381
|
-
* (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`
|
|
382
|
-
* entirely: `isBlocking` alone decides accepted-for-fix vs deferred.
|
|
297
|
+
* Derive the ledger disposition for a finding at `severity` (already
|
|
298
|
+
* normalized) — the one rule every producer shares. A LOCATABLE `question` is
|
|
299
|
+
* answered ("needs-answer") regardless of `isBlocking`; severity-first so that
|
|
300
|
+
* invariant holds here, not just at the config boundary. A NON-LOCATABLE
|
|
301
|
+
* question is body-filed and deferred, like every other non-`high` body-filed
|
|
302
|
+
* finding (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`:
|
|
303
|
+
* `isBlocking` alone decides accepted-for-fix vs deferred.
|
|
383
304
|
* @param {string} severity — already normalized
|
|
384
305
|
* @param {{ isBlocking?: boolean, locatable?: boolean }} [options]
|
|
385
306
|
* @returns {"accepted-for-fix"|"deferred"|"needs-answer"}
|
|
@@ -390,19 +311,13 @@ export function deriveDisposition(severity, { isBlocking = false, locatable = fa
|
|
|
390
311
|
}
|
|
391
312
|
|
|
392
313
|
/**
|
|
393
|
-
* Does `severity` (already normalized) have a default disposition
|
|
394
|
-
* `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
* these three, when the caller left it unset. "high" and "medium" are
|
|
401
|
-
* excluded: whether either blocks a clean verdict depends on config, which
|
|
402
|
-
* only a caller holding `blockCleanOnFindingSeverities` can know — guessing
|
|
403
|
-
* "deferred" for one of those here would be wrong for a repo that configures
|
|
404
|
-
* it as blocking. Shared by both CLI validators (see `deriveDisposition`) so
|
|
405
|
-
* the two can never restate this guard out of sync.
|
|
314
|
+
* Does `severity` (already normalized) have a default disposition
|
|
315
|
+
* `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and "nit"
|
|
316
|
+
* always defer, and "question" resolves off `locatable` alone — so a CLI
|
|
317
|
+
* validator with no config in scope can fill a default for these three only.
|
|
318
|
+
* "high" and "medium" are excluded: whether either blocks a clean verdict
|
|
319
|
+
* depends on config, so guessing "deferred" here would be wrong for a repo that
|
|
320
|
+
* configures it as blocking.
|
|
406
321
|
* @param {string} severity — already normalized
|
|
407
322
|
* @returns {boolean}
|
|
408
323
|
*/
|
|
@@ -414,21 +329,19 @@ const VALID_VERDICTS = new Set(["clean", "findings_present"]);
|
|
|
414
329
|
|
|
415
330
|
/**
|
|
416
331
|
* Canonical fail-closed signal for when a child/agent cannot perform real
|
|
417
|
-
* parallel fan-out
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* review (which requireFanoutProvenance is designed to reject). Documented as a
|
|
421
|
-
* contract in skills/docs/gate-review-sub-loop-contract.md.
|
|
332
|
+
* parallel fan-out. The flow MUST fail closed with this message and route the
|
|
333
|
+
* gate review to the conductor rather than silently degrading to a single-agent
|
|
334
|
+
* inline review. Contract: skills/docs/gate-review-sub-loop-contract.md.
|
|
422
335
|
*/
|
|
423
336
|
export const FANOUT_UNAVAILABLE_MESSAGE = "fan-out unavailable — route to conductor";
|
|
424
337
|
|
|
425
338
|
/**
|
|
426
|
-
* Build a fail-closed Error carrying the route-to-conductor
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
339
|
+
* Build a fail-closed Error carrying the route-to-conductor signal. Callers
|
|
340
|
+
* throw it (or check `.routeToConductor === true`) when real fan-out cannot be
|
|
341
|
+
* performed. `detail` is appended for diagnostics; the matchable prefix is
|
|
342
|
+
* always {@link FANOUT_UNAVAILABLE_MESSAGE}.
|
|
430
343
|
*
|
|
431
|
-
* @param {string} [detail] — optional diagnostic suffix
|
|
344
|
+
* @param {string} [detail] — optional diagnostic suffix
|
|
432
345
|
* @returns {Error & { routeToConductor: true, code: "FANOUT_UNAVAILABLE" }}
|
|
433
346
|
*/
|
|
434
347
|
export function fanoutUnavailableError(detail) {
|
|
@@ -438,10 +351,8 @@ export function fanoutUnavailableError(detail) {
|
|
|
438
351
|
}
|
|
439
352
|
|
|
440
353
|
/**
|
|
441
|
-
* Count DISTINCT reviewer identities
|
|
442
|
-
*
|
|
443
|
-
* entries carrying neither are not countable reviewers (a bare `{angle}` proves
|
|
444
|
-
* nothing about who reviewed it). Pure.
|
|
354
|
+
* Count DISTINCT reviewer identities recorded in a `perAngle` array (identity
|
|
355
|
+
* via {@link reviewerIdentity}); a bare `{angle}` contributes none. Pure.
|
|
445
356
|
*
|
|
446
357
|
* @param {unknown} perAngle
|
|
447
358
|
* @returns {number}
|
|
@@ -460,9 +371,9 @@ export function countDistinctReviewers(perAngle) {
|
|
|
460
371
|
/**
|
|
461
372
|
* The single identity-selection rule for a perAngle entry: a non-empty
|
|
462
373
|
* `reviewer` wins, else a non-empty `dispatchId`, else no identity. Returns
|
|
463
|
-
* `{ id, label }` (label
|
|
464
|
-
*
|
|
465
|
-
*
|
|
374
|
+
* `{ id, label }` (label names the carrying field, for error messages) or null.
|
|
375
|
+
* Shared by countDistinctReviewers and fanoutReviewerPairingError so the two
|
|
376
|
+
* never diverge.
|
|
466
377
|
*
|
|
467
378
|
* @param {object} entry — a perAngle entry
|
|
468
379
|
* @returns {{ id: string, label: "reviewer"|"dispatchId" }|null}
|
|
@@ -478,22 +389,18 @@ function reviewerIdentity(entry) {
|
|
|
478
389
|
}
|
|
479
390
|
|
|
480
391
|
/**
|
|
481
|
-
* Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an
|
|
482
|
-
* string when
|
|
483
|
-
*
|
|
484
|
-
* and the enforcement read path (buildPreMergeGateCheck) so both agree.
|
|
392
|
+
* Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an
|
|
393
|
+
* error string when malformed/self-inconsistent, else null. Shared by the write
|
|
394
|
+
* path and the enforcement read path (buildPreMergeGateCheck) so both agree.
|
|
485
395
|
*
|
|
486
|
-
* Consistency
|
|
487
|
-
* - `distinctReviewers`
|
|
488
|
-
* - `perAngle`
|
|
489
|
-
* - `distinctReviewers`
|
|
490
|
-
* actually recorded in `perAngle` — you cannot claim more reviewers than you
|
|
491
|
-
* recorded dispatch entries for.
|
|
396
|
+
* Consistency rules (skills/docs/gate-review-sub-loop-contract.md):
|
|
397
|
+
* - `distinctReviewers` a non-negative integer.
|
|
398
|
+
* - `perAngle` an array, non-empty when `distinctReviewers > 0`.
|
|
399
|
+
* - `distinctReviewers` <= distinct reviewer identities recorded in `perAngle`.
|
|
492
400
|
*
|
|
493
|
-
* HONEST CAVEAT: this
|
|
494
|
-
* the
|
|
495
|
-
*
|
|
496
|
-
* Un-forgeable recording is the Pi-harness bridge (subagent tool at child depth).
|
|
401
|
+
* HONEST CAVEAT: this raises the bar but the provenance is self-reported (written
|
|
402
|
+
* by the same agent whose independence it claims), so it stays forgeable by a
|
|
403
|
+
* determined single agent. Un-forgeable recording is the Pi-harness bridge.
|
|
497
404
|
*
|
|
498
405
|
* @param {unknown} prov
|
|
499
406
|
* @returns {string|null}
|
|
@@ -521,16 +428,13 @@ export function provenanceConsistencyError(prov) {
|
|
|
521
428
|
}
|
|
522
429
|
|
|
523
430
|
/**
|
|
524
|
-
* Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle`
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
* {@link fanoutReviewerPairingError} all derive from it so the write-time
|
|
532
|
-
* floor and the pairing check can never silently drift apart on what either
|
|
533
|
-
* term means. Pure.
|
|
431
|
+
* Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle` array:
|
|
432
|
+
* a valid object naming a non-blank `angle` and carrying no `carriedFromHead`
|
|
433
|
+
* (a carried angle's clean verdict was reused from a prior head, not reviewed
|
|
434
|
+
* here). `group` is the normalized non-blank `group` string or `null`. The ONE
|
|
435
|
+
* definition of "fresh" and "declared group" that {@link freshAngleNames},
|
|
436
|
+
* {@link countFreshDispatchUnits}, and {@link fanoutReviewerPairingError} all
|
|
437
|
+
* derive from, so the write-time floor and pairing check never drift. Pure.
|
|
534
438
|
* @param {unknown} perAngle
|
|
535
439
|
* @returns {Generator<{ entry: object, angle: string, group: string|null }>}
|
|
536
440
|
*/
|
|
@@ -547,10 +451,8 @@ function* freshEntries(perAngle) {
|
|
|
547
451
|
}
|
|
548
452
|
|
|
549
453
|
/**
|
|
550
|
-
* Names of DISTINCT "fresh" angles in a `perAngle` array
|
|
551
|
-
* {@link freshEntries}.
|
|
552
|
-
* resolving this round's dispatch groups via `resolveFanoutGroups` for
|
|
553
|
-
* {@link fanoutReviewerPairingError}'s cross-check). Pure.
|
|
454
|
+
* Names of DISTINCT "fresh" angles in a `perAngle` array (see
|
|
455
|
+
* {@link freshEntries}). Pure.
|
|
554
456
|
*
|
|
555
457
|
* @param {unknown} perAngle
|
|
556
458
|
* @returns {string[]}
|
|
@@ -563,18 +465,11 @@ export function freshAngleNames(perAngle) {
|
|
|
563
465
|
|
|
564
466
|
/**
|
|
565
467
|
* Count distinct FRESH dispatch units in a `perAngle` array: a fresh angle
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
* are identical; for a grouped ledger this is <= the ungrouped count, since
|
|
572
|
-
* one group of N angles is one dispatch unit, not N. Shared by the write
|
|
573
|
-
* path (write-gate-findings-log.mjs) and the
|
|
574
|
-
* requireFanoutProvenance read path (detect-checkpoint-evidence.mjs) so the
|
|
575
|
-
* `distinctReviewers` floor scales with what was actually DISPATCHED, not
|
|
576
|
-
* with the angle count a grouped round deliberately dispatches fewer
|
|
577
|
-
* reviewers than. Pure.
|
|
468
|
+
* declaring a `group` counts once per DISTINCT group name (its group is one
|
|
469
|
+
* reviewer's dispatch), an ungrouped fresh angle counts as its own unit. Shared
|
|
470
|
+
* by the write path and the requireFanoutProvenance read path so the
|
|
471
|
+
* `distinctReviewers` floor scales with what was DISPATCHED, not the angle count
|
|
472
|
+
* a grouped round deliberately dispatches fewer reviewers than. Pure.
|
|
578
473
|
*
|
|
579
474
|
* @param {unknown} perAngle
|
|
580
475
|
* @returns {number}
|
|
@@ -590,49 +485,30 @@ export function countFreshDispatchUnits(perAngle) {
|
|
|
590
485
|
}
|
|
591
486
|
|
|
592
487
|
/**
|
|
593
|
-
* Validate the one-scoped-reviewer-per-fresh-angle contract
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
* recorded `group` is self-attested at write time; when `resolvedGroups` is
|
|
601
|
-
* supplied (both call sites always supply it) it is also checked against
|
|
602
|
-
* the CURRENT `gates.fanout.groups` table, so an edit to that table between
|
|
603
|
-
* the round and a later read (e.g. a merge-evidence check) can invalidate a
|
|
604
|
-
* ledger's group claim that was honest when written — see the
|
|
605
|
-
* `resolvedGroups` paragraph below. Two
|
|
606
|
-
* fresh angles sharing a reviewer with differing or missing `group` values
|
|
607
|
-
* still violate the contract. Carried angles keep their prior reviewer and
|
|
608
|
-
* are exempt. Pure; shared by the write path (write-gate-findings-log.mjs,
|
|
609
|
-
* always-on) and the merge-evidence read path (detect-checkpoint-evidence.mjs,
|
|
610
|
-
* scaling the `requireFanoutProvenance` floor) so both agree.
|
|
488
|
+
* Validate the one-scoped-reviewer-per-fresh-angle contract: no two
|
|
489
|
+
* FRESH angles (see {@link freshEntries}) may share one reviewer identity
|
|
490
|
+
* (matching {@link countDistinctReviewers}'s rule), UNLESS every entry sharing
|
|
491
|
+
* that identity declares the SAME `group` name (grouped fan-out dispatch). Two
|
|
492
|
+
* fresh angles sharing a reviewer with differing or missing `group` still
|
|
493
|
+
* violate; carried angles keep their prior reviewer and are exempt. Pure; shared
|
|
494
|
+
* by the write path and the merge-evidence read path so both agree.
|
|
611
495
|
*
|
|
612
|
-
* Returns an actionable error string naming the offending angle(s)
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
* distinct-reviewer count below the fresh-angle count), or `null` when it
|
|
617
|
-
* holds (including when `perAngle` has no fresh angles).
|
|
496
|
+
* Returns an actionable error string naming the offending angle(s) — an
|
|
497
|
+
* ungrouped reviewer covering >1 fresh angle, inconsistent `group` values, or a
|
|
498
|
+
* fresh angle recording no reviewer identity — or `null` when the contract holds
|
|
499
|
+
* (including when there are no fresh angles).
|
|
618
500
|
*
|
|
619
|
-
* The recorded `group` is self-attested
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
* `
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
* no-op for dispatch shape), so a shared identity within an auto-chunked
|
|
631
|
-
* dispatch unit is honored exactly as for a configured group.
|
|
632
|
-
* Omitting `resolvedGroups` entirely keeps today's fully permissive behavior (any one
|
|
633
|
-
* shared non-null `group` value is accepted, unchecked against config) — both
|
|
634
|
-
* call sites already load config, so they should always supply it; this
|
|
635
|
-
* default only preserves callers (and old ledgers) that don't.
|
|
501
|
+
* The recorded `group` is self-attested, so the grouped exception is only as
|
|
502
|
+
* strong as the caller allows. An optional `resolvedGroups` (the round's
|
|
503
|
+
* `resolveFanoutGroups` output) closes that: a shared identity is honored only
|
|
504
|
+
* when every fresh angle it covers is a member of the SAME configured unit, so a
|
|
505
|
+
* fabricated `group` label spanning angles the table splits apart no longer
|
|
506
|
+
* passes. `resolveFanoutGroups` emits one-angle singletons for
|
|
507
|
+
* `gates.fanout.mode: per-angle`, so passing its output rejects any shared
|
|
508
|
+
* identity in that mode. Per ADR 0048, `gate:full` dispatches GROUPED, so a
|
|
509
|
+
* shared identity within an auto-chunked unit is honored as for a configured
|
|
510
|
+
* group. Omitting `resolvedGroups` keeps the permissive behavior (any one shared
|
|
511
|
+
* non-null `group` accepted) for callers that don't load config.
|
|
636
512
|
*
|
|
637
513
|
* @param {unknown} perAngle
|
|
638
514
|
* @param {{name: string, angles: string[]}[]|null} [resolvedGroups]
|
|
@@ -667,19 +543,17 @@ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
|
|
|
667
543
|
const details = [];
|
|
668
544
|
for (const [id, { angles, label, groups }] of anglesByIdentity) {
|
|
669
545
|
if (angles.size <= 1) continue;
|
|
670
|
-
// One shared, non-null `group` across every entry
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
// back to the one-reviewer-per-angle rule.
|
|
546
|
+
// One shared, non-null `group` across every entry is the grouped-dispatch
|
|
547
|
+
// exception: a single reviewer legitimately covers its whole declared group.
|
|
548
|
+
// Differing or missing `group` falls back to one-reviewer-per-angle.
|
|
674
549
|
const sameGroup = groups.size === 1 && [...groups][0] !== null;
|
|
675
550
|
if (!sameGroup) {
|
|
676
551
|
details.push(`${label} "${id}" is recorded for fresh angles: ${[...angles].join(", ")}`);
|
|
677
552
|
continue;
|
|
678
553
|
}
|
|
679
|
-
// resolvedGroups supplied: the claimed group is only
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
// closed even though the audit record itself is internally consistent.
|
|
554
|
+
// resolvedGroups supplied: the claimed group is honest only when every angle
|
|
555
|
+
// it covers is a member of the SAME configured group — a claimed group
|
|
556
|
+
// spanning angles the table splits apart fails closed.
|
|
683
557
|
if (configuredGroupOf.size > 0) {
|
|
684
558
|
const configuredGroups = new Set([...angles].map((a) => configuredGroupOf.get(a) ?? null));
|
|
685
559
|
if (configuredGroups.size !== 1 || configuredGroups.has(null)) {
|
|
@@ -695,10 +569,9 @@ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
|
|
|
695
569
|
}
|
|
696
570
|
|
|
697
571
|
/**
|
|
698
|
-
* Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
* mandatory-angle coverage and pool-membership checks.
|
|
572
|
+
* Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`):
|
|
573
|
+
* a re-review scoped to only the current head's delta still counts toward its
|
|
574
|
+
* base angle for both mandatory-angle coverage and pool-membership checks.
|
|
702
575
|
*
|
|
703
576
|
* @param {string} angle
|
|
704
577
|
* @returns {string}
|
|
@@ -708,20 +581,16 @@ export function baseAngleName(angle) {
|
|
|
708
581
|
}
|
|
709
582
|
|
|
710
583
|
/**
|
|
711
|
-
* Validate a recorded fan-out angle list against a gate's
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
* path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
|
|
717
|
-
* `--findings-json` per-angle results) and the merge-evidence read path
|
|
718
|
-
* (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
|
|
719
|
-
* so all three enforce identically.
|
|
584
|
+
* Validate a recorded fan-out angle list against a gate's angle contract: every
|
|
585
|
+
* mandatory angle must be represented, and — when a pool is supplied — every
|
|
586
|
+
* recorded angle must be a member of it or of {@link FANIN_SYNTHETIC_ANGLES}
|
|
587
|
+
* (delta-suffixed angles count toward their {@link baseAngleName}). Pure; shared
|
|
588
|
+
* by the write path and the merge-evidence read path so all enforce identically.
|
|
720
589
|
*
|
|
721
|
-
* @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries
|
|
590
|
+
* @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries
|
|
722
591
|
* @param {object} [gateAngleContract]
|
|
723
592
|
* @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
|
|
724
|
-
* @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted/empty skips the foreign-angle check; {@link FANIN_SYNTHETIC_ANGLES} are unioned in
|
|
593
|
+
* @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted/empty skips the foreign-angle check; {@link FANIN_SYNTHETIC_ANGLES} are unioned in first
|
|
725
594
|
* @returns {{ missingMandatory: string[], foreignAngles: string[] }}
|
|
726
595
|
*/
|
|
727
596
|
export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
|
|
@@ -742,33 +611,23 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
|
|
|
742
611
|
|
|
743
612
|
/**
|
|
744
613
|
* Angles the fan-in itself mandates and may synthesize (consolidate-fanin's
|
|
745
|
-
* `--pr-checklist
|
|
746
|
-
*
|
|
747
|
-
* requiring every consumer repo to also list them per-gate would make the two
|
|
748
|
-
* tools contradict the shared contract they implement.
|
|
614
|
+
* `--pr-checklist clean` upsert) without appearing in any gate's configured
|
|
615
|
+
* pool. Always legal in the foreign-angle check above.
|
|
749
616
|
*/
|
|
750
|
-
export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist
|
|
617
|
+
export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist"]);
|
|
751
618
|
|
|
752
619
|
/**
|
|
753
620
|
* Validate a round's RESOLVED angle set — the full angle list the round
|
|
754
|
-
* targeted, independent of any
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
* named in `carriedAngles` (angle names a caller has already PROVEN carried
|
|
760
|
-
* forward from a prior clean head — never a bare, unverified name; the
|
|
761
|
-
* consolidate-fanin CLI's own `--carried-angles` is only ever populated after
|
|
762
|
-
* its `--carry-forward-plan` proof check, so passing it straight through here
|
|
763
|
-
* keeps that same guarantee).
|
|
621
|
+
* targeted, independent of any gate's mandatory subset — against recorded
|
|
622
|
+
* evidence: every resolved angle must have a per-angle artifact in
|
|
623
|
+
* `recordedAngles` (matched by {@link baseAngleName} + case-insensitive compare)
|
|
624
|
+
* or be named in `carriedAngles` (names a caller has already PROVEN carried
|
|
625
|
+
* forward, e.g. after consolidate-fanin's `--carry-forward-plan` proof).
|
|
764
626
|
*
|
|
765
|
-
*
|
|
766
|
-
* only
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
* provenance (see the Gate Review Sub-Loop Contract's Phase 3 backstop
|
|
770
|
-
* paragraph). This function protects every resolved angle, not just the
|
|
771
|
-
* mandatory ones. Pure.
|
|
627
|
+
* Closes a gap {@link checkFanoutAngleCoverage} leaves open: that check protects
|
|
628
|
+
* only a caller-supplied mandatory subset, so a wrong carry-forward naming only
|
|
629
|
+
* NON-mandatory angles under-dispatches with no refusal. This protects every
|
|
630
|
+
* resolved angle. Pure.
|
|
772
631
|
*
|
|
773
632
|
* @param {unknown} resolvedAngles — the round's full resolved angle-name list
|
|
774
633
|
* @param {object} [evidence]
|
|
@@ -789,11 +648,9 @@ export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, car
|
|
|
789
648
|
.map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
|
|
790
649
|
.filter((a) => a.length > 0)
|
|
791
650
|
: [];
|
|
792
|
-
// Matched base+lowercase
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
// independently authored, so a case difference between a resolved angle
|
|
796
|
-
// name and its recorded/carried evidence must not read as missing.
|
|
651
|
+
// Matched base+lowercase (per-angle artifacts are independently authored, so a
|
|
652
|
+
// case difference must not read as missing), same rule as
|
|
653
|
+
// checkFanoutAngleCoverage's callers and reviewerBudgetPreflight.
|
|
797
654
|
const normalizeAngleBase = (a) => baseAngleName(a).toLowerCase();
|
|
798
655
|
const recordedBases = new Set(recorded.map(normalizeAngleBase));
|
|
799
656
|
const carriedBases = new Set(
|
|
@@ -807,22 +664,17 @@ export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, car
|
|
|
807
664
|
}
|
|
808
665
|
|
|
809
666
|
/**
|
|
810
|
-
* Default cap on parallel fan-out reviewers when a caller
|
|
811
|
-
*
|
|
667
|
+
* Default cap on parallel fan-out reviewers when a caller supplies none. Mirrors
|
|
668
|
+
* gates.maxFanoutReviewers.
|
|
812
669
|
*/
|
|
813
670
|
export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
|
|
814
671
|
|
|
815
|
-
// Every sanctioned angle name is a short
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
// where consolidate-fanin.mjs's per-angle budget marking cannot compress it.
|
|
822
|
-
// This is a malformed-artifact guard, not a comment-budget guarantee: several
|
|
823
|
-
// angles each right at this cap can still exceed the render budget on their
|
|
824
|
-
// headers alone and force the withheld tier — that outcome is the render
|
|
825
|
-
// budget's degradation ladder doing its job, not something this cap prevents.
|
|
672
|
+
// Every sanctioned angle name is a short hand-authored slug; nothing legitimate
|
|
673
|
+
// approaches this length. Bounding it at this trust boundary fails a
|
|
674
|
+
// pathological artifact closed as malformed — where every other angle-result
|
|
675
|
+
// defect is caught. A malformed-artifact guard, not a comment-budget guarantee:
|
|
676
|
+
// several angles each at this cap can still exceed the render budget, which the
|
|
677
|
+
// render budget's degradation ladder handles.
|
|
826
678
|
const MAX_ANGLE_NAME_LENGTH = 200;
|
|
827
679
|
|
|
828
680
|
/**
|
|
@@ -949,9 +801,8 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
949
801
|
verdict = "clean";
|
|
950
802
|
}
|
|
951
803
|
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
// above) reproduces the same counts via the one shared tally rule.
|
|
804
|
+
// findings already carries each entry's normalized severity, so tallying it
|
|
805
|
+
// directly reproduces the same counts via the one shared tally rule.
|
|
955
806
|
return {
|
|
956
807
|
verdict,
|
|
957
808
|
findings,
|
|
@@ -967,21 +818,19 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
967
818
|
|
|
968
819
|
/**
|
|
969
820
|
* The judge's relevance-based disposition vocabulary — distinct from the
|
|
970
|
-
* severity-based `disposition`
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
* rejection; the judge owns relevance (#1525).
|
|
821
|
+
* severity-based `disposition` that `deriveDisposition` owns. The judge decides
|
|
822
|
+
* *where* a finding is acted on (this PR or a follow-up), never *whether* it is
|
|
823
|
+
* real: a `reject` is a relevance verdict (out-of-scope), not a reproduction
|
|
824
|
+
* verdict. The fixer retains reproduction-based rejection; the judge owns
|
|
825
|
+
* relevance.
|
|
976
826
|
*/
|
|
977
827
|
export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
|
|
978
828
|
|
|
979
829
|
/**
|
|
980
|
-
* Validate a judge verdict artifact shape (the
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
* closed rather than degrading to severity-only disposition.
|
|
830
|
+
* Validate a judge verdict artifact shape (the `judge` agent's only write).
|
|
831
|
+
* Pure; throws on a malformed verdict rather than enriching findings with
|
|
832
|
+
* garbage. The judge is the designated memory across rounds, so a malformed
|
|
833
|
+
* artifact fails closed rather than degrading to severity-only disposition.
|
|
985
834
|
*
|
|
986
835
|
* Shape:
|
|
987
836
|
* ```
|
|
@@ -1043,8 +892,8 @@ export function validateJudgeVerdict(verdict) {
|
|
|
1043
892
|
if (typeof entry.rationale !== "string" || entry.rationale.trim().length === 0) {
|
|
1044
893
|
throw new Error(`judge verdict.dispositions[${i}].rationale must be a non-empty string naming the criterion, non-goal, or scope boundary`);
|
|
1045
894
|
}
|
|
1046
|
-
// followUpDraft is REQUIRED on a defer disposition (
|
|
1047
|
-
//
|
|
895
|
+
// followUpDraft is REQUIRED on a defer disposition (a deferred finding
|
|
896
|
+
// carries a fileable follow-up draft). Optional otherwise.
|
|
1048
897
|
if (entry.disposition === "defer") {
|
|
1049
898
|
if (!entry.followUpDraft || typeof entry.followUpDraft !== "object" || Array.isArray(entry.followUpDraft)) {
|
|
1050
899
|
throw new Error(`judge verdict.dispositions[${i}].followUpDraft is required on a defer disposition`);
|
|
@@ -1059,31 +908,20 @@ export function validateJudgeVerdict(verdict) {
|
|
|
1059
908
|
}
|
|
1060
909
|
|
|
1061
910
|
/**
|
|
1062
|
-
* Merge the judge's relevance-based dispositions into the consolidated
|
|
1063
|
-
* array
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
* declared scope, and prior-round ledgers, and emits a per-finding disposition
|
|
1067
|
-
* (`act` / `defer` / `reject`) plus a scope-drift verdict on the PR as a whole.
|
|
1068
|
-
*
|
|
1069
|
-
* This function enriches each finding with `judgeDisposition`, `judgeRationale`,
|
|
1070
|
-
* and (for `defer`) `followUpDraft` so the disposition ledger and posted findings
|
|
1071
|
-
* comment carry what was consciously not acted on and why. The severity-based
|
|
1072
|
-
* `disposition` (accepted-for-fix/deferred/needs-answer) is LEFT INTACT — the
|
|
1073
|
-
* judge's relevance axis is complementary, not a replacement (a real defect
|
|
1074
|
-
* stays a real defect; the judge decides *where* it is fixed, not *whether* it
|
|
1075
|
-
* is real).
|
|
911
|
+
* Merge the judge's relevance-based dispositions into the flat consolidated
|
|
912
|
+
* findings array. The judge runs AFTER fan-in and BEFORE the fix pass:
|
|
913
|
+
* it emits a per-finding disposition (`act`/`defer`/`reject`) plus a scope-drift
|
|
914
|
+
* verdict on the PR as a whole.
|
|
1076
915
|
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
* judge's
|
|
916
|
+
* Enriches each finding with `judgeDisposition`, `judgeRationale`, and (for
|
|
917
|
+
* `defer`) `followUpDraft`. The severity-based `disposition` is LEFT INTACT —
|
|
918
|
+
* the judge's relevance axis is complementary, not a replacement. The fix pass
|
|
919
|
+
* consumes only the `act` list.
|
|
1080
920
|
*
|
|
1081
921
|
* Pure. Fails closed (throws) when a disposition references an out-of-range
|
|
1082
|
-
* index
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1085
|
-
* from the fixer's act list. An empty findings array with an empty
|
|
1086
|
-
* dispositions array is vacuously covered and returns without error.
|
|
922
|
+
* index, and when the dispositions do not cover every finding — an undisposed
|
|
923
|
+
* finding must never be silently dropped from the fixer's act list. An empty
|
|
924
|
+
* findings + empty dispositions pair is vacuously covered.
|
|
1087
925
|
*
|
|
1088
926
|
* @param {Array<object>} findings — the flat consolidated findings array
|
|
1089
927
|
* @param {object} judgeVerdict — the validated judge verdict artifact
|
|
@@ -1098,11 +936,9 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
|
|
|
1098
936
|
throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`);
|
|
1099
937
|
}
|
|
1100
938
|
const target = enriched[d.index];
|
|
1101
|
-
// Reset judge-owned fields before the re-merge
|
|
1102
|
-
// (
|
|
1103
|
-
//
|
|
1104
|
-
// defer -> act/reject re-disposition — the merged copy carries only
|
|
1105
|
-
// what the current disposition provides, never prior-round residue.
|
|
939
|
+
// Reset judge-owned fields before the re-merge so a re-disposed finding
|
|
940
|
+
// (defer -> act/reject) carries only what the current disposition provides,
|
|
941
|
+
// never stale judgeCriterion/followUpDraft from a prior round.
|
|
1106
942
|
delete target.judgeCriterion;
|
|
1107
943
|
delete target.followUpDraft;
|
|
1108
944
|
target.judgeDisposition = d.disposition;
|
|
@@ -1115,10 +951,9 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
|
|
|
1115
951
|
}
|
|
1116
952
|
}
|
|
1117
953
|
// Coverage is judged against THIS verdict's disposed-index set, not field
|
|
1118
|
-
// presence on the merged copy
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
// rejects duplicate indexes, so the Set is exact.
|
|
954
|
+
// presence on the merged copy, so an already-enriched ledger can't let a
|
|
955
|
+
// verdict that disposes nothing pass silently. validateJudgeVerdict rejects
|
|
956
|
+
// duplicate indexes, so the Set is exact.
|
|
1122
957
|
const disposed = new Set(validated.dispositions.map((d) => d.index));
|
|
1123
958
|
const uncovered = enriched.reduce((positions, _f, i) => {
|
|
1124
959
|
if (!disposed.has(i)) positions.push(i);
|
|
@@ -1163,9 +998,8 @@ export function toFindingsLogShape(findings) {
|
|
|
1163
998
|
if (Number.isInteger(f.line) && f.line > 0) {
|
|
1164
999
|
entry.line = f.line;
|
|
1165
1000
|
}
|
|
1166
|
-
// Carry the judge's relevance-based dispositions through
|
|
1167
|
-
//
|
|
1168
|
-
// acted on and why.
|
|
1001
|
+
// Carry the judge's relevance-based dispositions through so the ledger and
|
|
1002
|
+
// posted findings comment show what was consciously not acted on.
|
|
1169
1003
|
if (typeof f.judgeDisposition === "string" && f.judgeDisposition.trim().length > 0) {
|
|
1170
1004
|
entry.judgeDisposition = f.judgeDisposition.trim();
|
|
1171
1005
|
}
|
|
@@ -1185,16 +1019,13 @@ export function toFindingsLogShape(findings) {
|
|
|
1185
1019
|
/**
|
|
1186
1020
|
* Plan how a resolved angle set fans out across the reviewer cap. Pure.
|
|
1187
1021
|
*
|
|
1188
|
-
* SUPERSEDED by `scheduleFanoutWaves` (
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
* `write-gate-context.mjs`. This helper is kept only for back-compat (zero
|
|
1192
|
-
* non-test callers) and no longer participates in the dispatch path.
|
|
1022
|
+
* SUPERSEDED by `scheduleFanoutWaves` (ADR 0048): the conductor now dispatches
|
|
1023
|
+
* wave-by-wave. Kept for back-compat only (zero non-test callers); no longer in
|
|
1024
|
+
* the dispatch path.
|
|
1193
1025
|
*
|
|
1194
|
-
* When `angles.length <= maxReviewers`, all reviewers run in
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* the skill can record the sequential degradation in the gate evidence.
|
|
1026
|
+
* When `angles.length <= maxReviewers`, all reviewers run in one parallel batch;
|
|
1027
|
+
* otherwise the overflow splits into sequential batches of at most `maxReviewers`
|
|
1028
|
+
* and `degraded` is true.
|
|
1198
1029
|
*
|
|
1199
1030
|
* @param {string[]} angles
|
|
1200
1031
|
* @param {number} [maxReviewers] — default DEFAULT_MAX_FANOUT_REVIEWERS (8)
|