@dev-loops/core 1.0.0-rc.3 → 1.0.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +549 -44
- package/src/config/extension-defaults.yaml +38 -5
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +77 -5
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/copilot-ci-status.mjs +46 -18
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +28 -1
- package/src/loop/gate-fanin.mjs +723 -29
- package/src/loop/handoff-envelope.mjs +37 -16
- package/src/loop/issue-refinement-artifact.mjs +60 -18
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +194 -13
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +1 -1
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -14,14 +14,318 @@
|
|
|
14
14
|
* {
|
|
15
15
|
* angle: string,
|
|
16
16
|
* verdict: "clean" | "findings_present",
|
|
17
|
+
* headSha: string, // reviewed head; consolidate-fanin --head-sha enforces it (GATE-EXEC-ARTIFACT-HEAD-STAMP)
|
|
17
18
|
* findings: [{ severity, file?, line?, summary, recommendation? }]
|
|
18
19
|
* }
|
|
19
20
|
*
|
|
20
|
-
* Severity vocabulary (
|
|
21
|
-
*
|
|
21
|
+
* Severity vocabulary (owned here; consumers import SEVERITY_ORDER /
|
|
22
|
+
* VALID_SEVERITIES / normalizeSeverity), aligned to the Copilot review
|
|
23
|
+
* severity scale:
|
|
24
|
+
* "high" | "medium" | "low" (defects) | "question" | "nit" (non-defects)
|
|
25
|
+
* Severity is the reviewer's advisory weight only. Deferral is a DISPOSITION
|
|
26
|
+
* (derived at fan-in for non-blocking findings, finalized per thread by the
|
|
27
|
+
* fix cycle / gate close), never a severity — the pre-rename severity
|
|
28
|
+
* spellings ("must-fix", "worth-fixing-now", "nice-to-have", "defer") are
|
|
29
|
+
* accepted on read and normalized to their canonical replacement (see
|
|
30
|
+
* LEGACY_SEVERITY_ALIASES / normalizeSeverity). "question" and "nit" are
|
|
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
34
|
*/
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
import { scheduleParallelWaves } from "./queue-parallel.mjs";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Schedule fan-out dispatch units into bounded-concurrency waves (issue #1601).
|
|
40
|
+
*
|
|
41
|
+
* Reuses the existing wave scheduler `scheduleParallelWaves`
|
|
42
|
+
* (packages/core/src/loop/queue-parallel.mjs, originally the queue-mode parallel
|
|
43
|
+
* scheduler): each wave holds at most `maxConcurrent` dispatch units, and the
|
|
44
|
+
* conductor dispatches wave-by-wave — awaiting a free slot (wave completion)
|
|
45
|
+
* before launching the next — instead of fire-all-then-retry. This replaces
|
|
46
|
+
* the unbounded concurrent fan-out that 429-stormed multi-angle gate rounds
|
|
47
|
+
* (issue #1588 drive: 5–6 reviewers 429'd per round).
|
|
48
|
+
*
|
|
49
|
+
* Pure: same input always yields the same wave plan (deterministic order, so
|
|
50
|
+
* the wave plan a reviewer's gate-context artifact records is byte-stable
|
|
51
|
+
* across fresh reviewer spawns for the same head+config).
|
|
52
|
+
*
|
|
53
|
+
* @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output
|
|
54
|
+
* @param {number} [maxConcurrent] — `gates.fanout.maxConcurrent` (default 4, min 1)
|
|
55
|
+
* @returns {{ name: string, angles: string[] }[][]} waves of dispatch units (at most `maxConcurrent` per wave)
|
|
56
|
+
*/
|
|
57
|
+
export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
|
|
58
|
+
const groups = Array.isArray(dispatchGroups) ? dispatchGroups : [];
|
|
59
|
+
const cap = Number.isInteger(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 4;
|
|
60
|
+
if (groups.length === 0) return [];
|
|
61
|
+
return scheduleParallelWaves(groups, cap);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Adaptive 429-backoff concurrency (issue #1601): halve the active batch before
|
|
66
|
+
* escalating to foreground one-at-a-time fallback. On a 429, the conductor
|
|
67
|
+
* recomputes the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
|
|
68
|
+
* retries the failed wave; if a single-unit wave still 429s, it falls back to
|
|
69
|
+
* foreground (one-at-a-time) dispatch. The backoff is recorded in the round's
|
|
70
|
+
* provenance (see skills/docs/gate-review-sub-loop-contract.md). Pure; never
|
|
71
|
+
* returns 0 (a backoff from 1 stays 1 → foreground fallback owns that path).
|
|
72
|
+
* @param {number} maxConcurrent
|
|
73
|
+
* @returns {number}
|
|
74
|
+
*/
|
|
75
|
+
export function backoffMaxConcurrent(maxConcurrent) {
|
|
76
|
+
const cap = Number.isInteger(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 4;
|
|
77
|
+
return Math.max(1, Math.floor(cap / 2));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reviewer-budget preflight for a gate fan-out (issue #1507).
|
|
82
|
+
*
|
|
83
|
+
* Before the conductor dispatches any reviewer, it derives how many reviewers
|
|
84
|
+
* the round needs (one per dispatch unit — fresh angles + re-verifications) and
|
|
85
|
+
* compares against the harness's remaining reviewer budget. When the budget
|
|
86
|
+
* cannot cover the dispatch, the preflight reports the shortfall BEFORE any
|
|
87
|
+
* reviewer spawns, naming the shortfall; the shortfall is a recorded, resumable
|
|
88
|
+
* state (completed per-angle artifacts stay valid for their head, so a later
|
|
89
|
+
* session resumes the fan-out instead of restarting it). A budget shortfall
|
|
90
|
+
* NEVER downgrades a required gate to `inline_single_agent` and NEVER produces a
|
|
91
|
+
* clean verdict — no new gate-exemption path (#1507 AC4).
|
|
92
|
+
*
|
|
93
|
+
* Pure: takes the dispatch plan + available budget, returns the decision. The
|
|
94
|
+
* conductor reads `artifact.fanout.preflight` (emitted by `write-gate-context`)
|
|
95
|
+
* and dispatches wave-by-wave only when `dispatch === true`; on `false` it
|
|
96
|
+
* records the shortfall (the artifact itself is the resumable record) and
|
|
97
|
+
* stops without spawning a single reviewer. `availableReviewers` is `null` when
|
|
98
|
+
* the harness does not expose a budget — no shortfall can be proven, so the
|
|
99
|
+
* preflight proceeds (today's behavior); it only blocks on a PROVEN shortfall.
|
|
100
|
+
*
|
|
101
|
+
* The returned `verdict` and `executionMode` are ALWAYS `null`: a shortfall is
|
|
102
|
+
* not a verdict. `buildPreMergeGateCheck` / `evaluateInlineFanoutMode` reject a
|
|
103
|
+
* gate with no clean current-head marker and a non-`fanout_fanin` execution
|
|
104
|
+
* mode, so a shortfall state fails closed at merge rather than yielding a clean
|
|
105
|
+
* or inline verdict (#1507 DoD).
|
|
106
|
+
*
|
|
107
|
+
* @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output (fresh angles + re-verifications)
|
|
108
|
+
* @param {number|null} [availableReviewers] — harness remaining reviewer budget; null/non-finite = unknown/unexposed
|
|
109
|
+
* @param {{ completedAngles?: Iterable<string> }} [options] — `completedAngles`: angle names that
|
|
110
|
+
* already have a clean per-angle findings artifact stamped for THIS head. A dispatch unit (group)
|
|
111
|
+
* whose angles are ALL complete is excluded from the required count and from `pendingGroups`, so a
|
|
112
|
+
* later session resumes the fan-out instead of restarting it (issue #1507 AC3): it re-runs the
|
|
113
|
+
* preflight and dispatches only the groups not already complete at this head.
|
|
114
|
+
* @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[] }}
|
|
115
|
+
*/
|
|
116
|
+
export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles } = {}) {
|
|
117
|
+
const groups = Array.isArray(dispatchGroups) ? dispatchGroups : [];
|
|
118
|
+
const completedSet = new Set(
|
|
119
|
+
Array.isArray(completedAngles)
|
|
120
|
+
? completedAngles
|
|
121
|
+
: completedAngles == null
|
|
122
|
+
? []
|
|
123
|
+
: [...completedAngles],
|
|
124
|
+
);
|
|
125
|
+
// #1507 AC3: resume instead of restart. One reviewer per dispatch unit (a
|
|
126
|
+
// group of N angles is one reviewer's scoped dispatch — see resolveFanoutGroups /
|
|
127
|
+
// countFreshDispatchUnits), but a group already COMPLETE at this head — every
|
|
128
|
+
// one of its angles has a clean artifact stamped for this head — needs no
|
|
129
|
+
// reviewer and is excluded from the required count and the pending plan. The
|
|
130
|
+
// conductor dispatches only `pendingGroups`.
|
|
131
|
+
const groupIsComplete = (g) =>
|
|
132
|
+
Array.isArray(g?.angles) && g.angles.length > 0 && g.angles.every((a) => completedSet.has(a));
|
|
133
|
+
const pendingGroups = groups.filter((g) => !groupIsComplete(g));
|
|
134
|
+
const skippedGroups = groups.filter((g) => groupIsComplete(g));
|
|
135
|
+
// One reviewer per dispatch unit: a group of N angles is one reviewer's
|
|
136
|
+
// scoped dispatch, so the reviewer count is the pending dispatch-unit count,
|
|
137
|
+
// not the raw angle count.
|
|
138
|
+
const requiredReviewers = pendingGroups.length;
|
|
139
|
+
const verdict = null;
|
|
140
|
+
const executionMode = null;
|
|
141
|
+
const resume = { pendingGroups, skippedGroups, completedAngles: [...completedSet] };
|
|
142
|
+
if (typeof availableReviewers !== "number" || !Number.isFinite(availableReviewers)) {
|
|
143
|
+
return { ok: true, dispatch: true, requiredReviewers, availableReviewers: null, shortfall: null, reason: "budget_unknown", verdict, executionMode, ...resume };
|
|
144
|
+
}
|
|
145
|
+
// A negative/over-spent budget clamps to 0 (budget exhausted → shortfall for
|
|
146
|
+
// any non-empty round); a fractional budget truncates to the integer floor.
|
|
147
|
+
const available = Math.max(0, Math.trunc(availableReviewers));
|
|
148
|
+
if (requiredReviewers === 0) {
|
|
149
|
+
return { ok: true, dispatch: true, requiredReviewers: 0, availableReviewers: available, shortfall: null, reason: "no_reviewers_needed", verdict, executionMode, ...resume };
|
|
150
|
+
}
|
|
151
|
+
if (available >= requiredReviewers) {
|
|
152
|
+
return { ok: true, dispatch: true, requiredReviewers, availableReviewers: available, shortfall: null, reason: "budget_sufficient", verdict, executionMode, ...resume };
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
dispatch: false,
|
|
157
|
+
requiredReviewers,
|
|
158
|
+
availableReviewers: available,
|
|
159
|
+
shortfall: requiredReviewers - available,
|
|
160
|
+
reason: "budget_shortfall",
|
|
161
|
+
verdict,
|
|
162
|
+
executionMode,
|
|
163
|
+
...resume,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Exported so other tools (e.g. scripts/loop/consolidate-fanin.mjs,
|
|
168
|
+
// scripts/github/upsert-checkpoint-verdict.mjs) sort/rank/validate against
|
|
169
|
+
// this single ordered copy of the severity vocabulary instead of each
|
|
170
|
+
// hand-copying its own list (and its own load-time drift guard) — ORDER is
|
|
171
|
+
// part of the contract here, not just membership, so a consumer that only
|
|
172
|
+
// checked membership against a Set could accept a silently reordered copy.
|
|
173
|
+
// Ranked by gate-close urgency, not just defect-severity: "question" sits
|
|
174
|
+
// right after "high" because BOTH force gate-close to stay blocked (a high
|
|
175
|
+
// finding via the fix loop, a question via never being auto-deferred) — it
|
|
176
|
+
// outranks "medium"/"low", which both eventually defer. "nit" trails last:
|
|
177
|
+
// it defers immediately, with no fixer cycle at all.
|
|
178
|
+
export const SEVERITY_ORDER = ["high", "question", "medium", "low", "nit"];
|
|
179
|
+
|
|
180
|
+
// Marker gate name → gates.<key> config key. Owned here so every caller of
|
|
181
|
+
// resolveFanoutGroups maps the same way; passing the marker name verbatim
|
|
182
|
+
// resolves no groups and silently downgrades pairing enforcement.
|
|
183
|
+
export const GATE_CONFIG_KEY = Object.freeze({ draft_gate: "draft", pre_approval_gate: "preApproval" });
|
|
184
|
+
export const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
|
|
185
|
+
|
|
186
|
+
// Pre-rename spellings. Old ledgers, markers, and configs still carry them;
|
|
187
|
+
// every read boundary normalizes through this map. Every SANCTIONED producer
|
|
188
|
+
// (consolidateFanin, write-gate-findings-log.mjs, post-gate-findings.mjs)
|
|
189
|
+
// normalizes before a severity reaches a marker/ledger, so a freshly posted
|
|
190
|
+
// marker carries only a canonical spelling in practice — but this map is a
|
|
191
|
+
// read-side normalizer, not a write-side enforcement boundary:
|
|
192
|
+
// buildFindingMarker (_gate-finding-surface.mjs) is a thin text builder that
|
|
193
|
+
// emits whatever severity string it is given, verbatim (a legacy-spelled
|
|
194
|
+
// marker built directly, e.g. for round-trip test fixtures, still parses
|
|
195
|
+
// correctly via normalizeSeverity on read).
|
|
196
|
+
export const LEGACY_SEVERITY_ALIASES = Object.freeze({
|
|
197
|
+
"must-fix": "high",
|
|
198
|
+
"worth-fixing-now": "medium",
|
|
199
|
+
"nice-to-have": "low",
|
|
200
|
+
defer: "low",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Map a legacy severity spelling to its canonical name; unknown values pass
|
|
205
|
+
* through trimmed (the caller's validation still rejects them) — a
|
|
206
|
+
* non-string passes through unchanged. Trimming BEFORE the alias lookup
|
|
207
|
+
* (rather than requiring every caller to do it first) is what keeps every
|
|
208
|
+
* call site of this function agreeing on the same value for the same
|
|
209
|
+
* incidentally-whitespace-varied input: consolidate-fanin.mjs's own floor
|
|
210
|
+
* validation trims before calling this, while gate-fanin's `consolidateFanin`
|
|
211
|
+
* does not — two call sites trimming inconsistently is exactly how an
|
|
212
|
+
* untrimmed "high " passed one gate's validation and then failed the
|
|
213
|
+
* other's. Deliberately case-SENSITIVE (no lowercasing): every sanctioned
|
|
214
|
+
* writer (slugForMarker, config authoring, this module's own producers)
|
|
215
|
+
* already emits lowercase, so a forged/hand-edited mixed-case value (e.g.
|
|
216
|
+
* "NIT") must fail VALID_SEVERITIES validation and dangle fail-closed rather
|
|
217
|
+
* than being silently coerced into a real severity that then auto-defers.
|
|
218
|
+
* @param {unknown} severity
|
|
219
|
+
* @returns {unknown}
|
|
220
|
+
*/
|
|
221
|
+
export function normalizeSeverity(severity) {
|
|
222
|
+
if (typeof severity !== "string") return severity;
|
|
223
|
+
const normalized = severity.trim();
|
|
224
|
+
return Object.hasOwn(LEGACY_SEVERITY_ALIASES, normalized) ? LEGACY_SEVERITY_ALIASES[normalized] : normalized;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Map a (possibly legacy-spelled/untrimmed) severity to its SEVERITY_ORDER
|
|
229
|
+
* index — the ONE rank rule every sort/ordering consumer
|
|
230
|
+
* (consolidate-fanin.mjs's `angleWorstSeverityRank`,
|
|
231
|
+
* upsert-checkpoint-verdict.mjs's severity-grouped rendering) shares, so the
|
|
232
|
+
* two can never drift on how an unknown severity ranks. An unrecognized
|
|
233
|
+
* severity (after normalization) ranks LAST (`SEVERITY_ORDER.length`, never
|
|
234
|
+
* -1) so it always sorts after every known severity instead of floating
|
|
235
|
+
* above "high" the way a raw, unmapped `indexOf` would.
|
|
236
|
+
* @param {unknown} severity
|
|
237
|
+
* @returns {number}
|
|
238
|
+
*/
|
|
239
|
+
export function severityRank(severity) {
|
|
240
|
+
const idx = SEVERITY_ORDER.indexOf(/** @type {string} */ (normalizeSeverity(severity)));
|
|
241
|
+
return idx === -1 ? SEVERITY_ORDER.length : idx;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Merge a severity→count map's legacy-spelled keys into their canonical keys
|
|
246
|
+
* (summing counts) so both the CLI parser and direct programmatic callers of
|
|
247
|
+
* the verdict poster share ONE merge rule. Values pass through unvalidated —
|
|
248
|
+
* the caller keeps its own integer/shape checks.
|
|
249
|
+
* @param {Record<string, number>} counts
|
|
250
|
+
* @returns {Record<string, number>} null-prototype object with canonical keys
|
|
251
|
+
*/
|
|
252
|
+
export function normalizeSeverityCounts(counts) {
|
|
253
|
+
const normalized = Object.create(null);
|
|
254
|
+
for (const [key, value] of Object.entries(counts)) {
|
|
255
|
+
const canonicalKey = /** @type {string} */ (normalizeSeverity(key));
|
|
256
|
+
normalized[canonicalKey] = (normalized[canonicalKey] ?? 0) + value;
|
|
257
|
+
}
|
|
258
|
+
return normalized;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A finding is LOCATABLE-SHAPED when it names a real file (via `file` or
|
|
263
|
+
* `files[0]`) and a positive-integer `line` — the ONE shared shape check
|
|
264
|
+
* every producer/consumer of the locatable/non-locatable distinction keys
|
|
265
|
+
* on, whether the finding is the raw per-angle `{file, line}` shape
|
|
266
|
+
* (consolidateFanin's own input) or the ledger's `{files, line}` shape
|
|
267
|
+
* (write-gate-findings-log.mjs / post-gate-findings.mjs). This is NECESSARY
|
|
268
|
+
* but not SUFFICIENT for a thread-locatable finding: `isLocatableFinding`
|
|
269
|
+
* (scripts/github/_gate-finding-surface.mjs) additionally requires the
|
|
270
|
+
* file:line to fall inside the reviewed diff, which only that caller
|
|
271
|
+
* (holding the diff's commentable-line set) can check — this function is
|
|
272
|
+
* its shared shape floor, not a replacement for it.
|
|
273
|
+
* @param {{ file?: unknown, files?: unknown, line?: unknown }} finding
|
|
274
|
+
* @returns {boolean}
|
|
275
|
+
*/
|
|
276
|
+
export function hasLocatableShape(finding) {
|
|
277
|
+
const file = typeof finding?.file === "string"
|
|
278
|
+
? finding.file
|
|
279
|
+
: (Array.isArray(finding?.files) ? finding.files[0] : undefined);
|
|
280
|
+
return typeof file === "string" && file.trim().length > 0
|
|
281
|
+
&& Number.isInteger(finding?.line) && /** @type {number} */ (finding.line) >= 1;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Derive the ledger disposition for a finding at `severity` — the ONE rule
|
|
286
|
+
* every producer (consolidateFanin, write-gate-findings-log.mjs,
|
|
287
|
+
* post-gate-findings.mjs) shares, so the three can never drift on what a
|
|
288
|
+
* severity/locatability combination resolves to. A LOCATABLE `question` is
|
|
289
|
+
* answered, never fixed or deferred — it gets its own disposition
|
|
290
|
+
* ("needs-answer") regardless of `isBlocking` (a question can never be
|
|
291
|
+
* blocking in practice — blockCleanOnFindingSeverities is restricted to
|
|
292
|
+
* defect severities — but this stays severity-first rather than
|
|
293
|
+
* isBlocking-first so that invariant is enforced here too, not just at the
|
|
294
|
+
* config boundary). A NON-LOCATABLE question has no resolvable thread to
|
|
295
|
+
* answer through — it is body-filed and deferred by construction, exactly
|
|
296
|
+
* like every other non-`high` body-filed finding
|
|
297
|
+
* (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`
|
|
298
|
+
* entirely: `isBlocking` alone decides accepted-for-fix vs deferred.
|
|
299
|
+
* @param {string} severity — already normalized
|
|
300
|
+
* @param {{ isBlocking?: boolean, locatable?: boolean }} [options]
|
|
301
|
+
* @returns {"accepted-for-fix"|"deferred"|"needs-answer"}
|
|
302
|
+
*/
|
|
303
|
+
export function deriveDisposition(severity, { isBlocking = false, locatable = false } = {}) {
|
|
304
|
+
if (severity === "question") return locatable ? "needs-answer" : "deferred";
|
|
305
|
+
return isBlocking ? "accepted-for-fix" : "deferred";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Does `severity` (already normalized) have a default disposition that
|
|
310
|
+
* `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and
|
|
311
|
+
* "nit" always defer regardless of any gate's `blockCleanOnFindingSeverities`
|
|
312
|
+
* config, and "question" resolves off `locatable` alone — so a caller with no
|
|
313
|
+
* `isBlocking` context (write-gate-findings-log.mjs / post-gate-findings.mjs's
|
|
314
|
+
* CLI validators, which accept a bare `--findings` array with no config in
|
|
315
|
+
* scope) can still fill in a default disposition for these three, and only
|
|
316
|
+
* these three, when the caller left it unset. "high" and "medium" are
|
|
317
|
+
* excluded: whether either blocks a clean verdict depends on config, which
|
|
318
|
+
* only a caller holding `blockCleanOnFindingSeverities` can know — guessing
|
|
319
|
+
* "deferred" for one of those here would be wrong for a repo that configures
|
|
320
|
+
* it as blocking. Shared by both CLI validators (see `deriveDisposition`) so
|
|
321
|
+
* the two can never restate this guard out of sync.
|
|
322
|
+
* @param {string} severity — already normalized
|
|
323
|
+
* @returns {boolean}
|
|
324
|
+
*/
|
|
325
|
+
export function isDefaultDeferrableSeverity(severity) {
|
|
326
|
+
return severity === "low" || severity === "nit" || severity === "question";
|
|
327
|
+
}
|
|
328
|
+
|
|
25
329
|
const VALID_VERDICTS = new Set(["clean", "findings_present"]);
|
|
26
330
|
|
|
27
331
|
/**
|
|
@@ -63,16 +367,32 @@ export function countDistinctReviewers(perAngle) {
|
|
|
63
367
|
const ids = new Set();
|
|
64
368
|
for (const e of perAngle) {
|
|
65
369
|
if (!e || typeof e !== "object" || Array.isArray(e)) continue;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
: typeof e.dispatchId === "string" && e.dispatchId.trim().length > 0
|
|
69
|
-
? e.dispatchId.trim()
|
|
70
|
-
: null;
|
|
71
|
-
if (id) ids.add(id);
|
|
370
|
+
const identity = reviewerIdentity(e);
|
|
371
|
+
if (identity) ids.add(identity.id);
|
|
72
372
|
}
|
|
73
373
|
return ids.size;
|
|
74
374
|
}
|
|
75
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The single identity-selection rule for a perAngle entry: a non-empty
|
|
378
|
+
* `reviewer` wins, else a non-empty `dispatchId`, else no identity. Returns
|
|
379
|
+
* `{ id, label }` (label = which field carried the identity, for error
|
|
380
|
+
* messages) or null. Shared by countDistinctReviewers and
|
|
381
|
+
* fanoutReviewerPairingError so the two can never diverge.
|
|
382
|
+
*
|
|
383
|
+
* @param {object} entry — a perAngle entry
|
|
384
|
+
* @returns {{ id: string, label: "reviewer"|"dispatchId" }|null}
|
|
385
|
+
*/
|
|
386
|
+
function reviewerIdentity(entry) {
|
|
387
|
+
if (typeof entry.reviewer === "string" && entry.reviewer.trim().length > 0) {
|
|
388
|
+
return { id: entry.reviewer.trim(), label: "reviewer" };
|
|
389
|
+
}
|
|
390
|
+
if (typeof entry.dispatchId === "string" && entry.dispatchId.trim().length > 0) {
|
|
391
|
+
return { id: entry.dispatchId.trim(), label: "dispatchId" };
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
76
396
|
/**
|
|
77
397
|
* Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an error
|
|
78
398
|
* string when the provenance is malformed or self-inconsistent, or null when it
|
|
@@ -116,6 +436,180 @@ export function provenanceConsistencyError(prov) {
|
|
|
116
436
|
return null;
|
|
117
437
|
}
|
|
118
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle`
|
|
441
|
+
* array — a valid object entry naming a non-blank `angle` and carrying no
|
|
442
|
+
* `carriedFromHead` (a carried angle's clean verdict was reused from a prior
|
|
443
|
+
* head's review, see @dev-loops/core/loop/gate-carry-forward, not freshly
|
|
444
|
+
* reviewed here). `group` is the entry's normalized, non-blank `group`
|
|
445
|
+
* string, or `null`. This is the ONE definition of "fresh" and "declared
|
|
446
|
+
* group" — {@link freshAngleNames}, {@link countFreshDispatchUnits}, and
|
|
447
|
+
* {@link fanoutReviewerPairingError} all derive from it so the write-time
|
|
448
|
+
* floor and the pairing check can never silently drift apart on what either
|
|
449
|
+
* term means. Pure.
|
|
450
|
+
* @param {unknown} perAngle
|
|
451
|
+
* @returns {Generator<{ entry: object, angle: string, group: string|null }>}
|
|
452
|
+
*/
|
|
453
|
+
function* freshEntries(perAngle) {
|
|
454
|
+
if (!Array.isArray(perAngle)) return;
|
|
455
|
+
for (const entry of perAngle) {
|
|
456
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
457
|
+
if (typeof entry.carriedFromHead === "string" && entry.carriedFromHead.trim().length > 0) continue;
|
|
458
|
+
const angle = typeof entry.angle === "string" ? entry.angle.trim() : "";
|
|
459
|
+
if (!angle) continue;
|
|
460
|
+
const group = typeof entry.group === "string" && entry.group.trim().length > 0 ? entry.group.trim() : null;
|
|
461
|
+
yield { entry, angle, group };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Names of DISTINCT "fresh" angles in a `perAngle` array — see
|
|
467
|
+
* {@link freshEntries}. Used by callers that need the names themselves (e.g.
|
|
468
|
+
* resolving this round's dispatch groups via `resolveFanoutGroups` for
|
|
469
|
+
* {@link fanoutReviewerPairingError}'s cross-check). Pure.
|
|
470
|
+
*
|
|
471
|
+
* @param {unknown} perAngle
|
|
472
|
+
* @returns {string[]}
|
|
473
|
+
*/
|
|
474
|
+
export function freshAngleNames(perAngle) {
|
|
475
|
+
const angles = new Set();
|
|
476
|
+
for (const { angle } of freshEntries(perAngle)) angles.add(angle);
|
|
477
|
+
return [...angles];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Count distinct FRESH dispatch units in a `perAngle` array: a fresh angle
|
|
482
|
+
* that declares a `group` counts once per DISTINCT group name (its whole
|
|
483
|
+
* group is one reviewer's dispatch), and a fresh angle with no `group`
|
|
484
|
+
* counts as its own dispatch unit (today's one-reviewer-per-angle shape).
|
|
485
|
+
* This is the grouping-aware generalization of counting distinct fresh
|
|
486
|
+
* angle names via {@link freshAngleNames} — for an ungrouped ledger the two
|
|
487
|
+
* are identical; for a grouped ledger this is <= the ungrouped count, since
|
|
488
|
+
* one group of N angles is one dispatch unit, not N. Shared by the write
|
|
489
|
+
* path (write-gate-findings-log.mjs) and the
|
|
490
|
+
* requireFanoutProvenance read path (detect-checkpoint-evidence.mjs) so the
|
|
491
|
+
* `distinctReviewers` floor scales with what was actually DISPATCHED, not
|
|
492
|
+
* with the angle count a grouped round deliberately dispatches fewer
|
|
493
|
+
* reviewers than. Pure.
|
|
494
|
+
*
|
|
495
|
+
* @param {unknown} perAngle
|
|
496
|
+
* @returns {number}
|
|
497
|
+
*/
|
|
498
|
+
export function countFreshDispatchUnits(perAngle) {
|
|
499
|
+
const groups = new Set();
|
|
500
|
+
const ungroupedAngles = new Set();
|
|
501
|
+
for (const { angle, group } of freshEntries(perAngle)) {
|
|
502
|
+
if (group) groups.add(group);
|
|
503
|
+
else ungroupedAngles.add(angle);
|
|
504
|
+
}
|
|
505
|
+
return groups.size + ungroupedAngles.size;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Validate the one-scoped-reviewer-per-fresh-angle contract (fanout_fanin
|
|
510
|
+
* execution mandates one independent reviewer per resolved angle; #1431): no
|
|
511
|
+
* two FRESH angles (angles without `carriedFromHead` — see
|
|
512
|
+
* {@link freshEntries}) may share one reviewer identity (`reviewer`,
|
|
513
|
+
* else `dispatchId` — matching {@link countDistinctReviewers}'s identity
|
|
514
|
+
* rule), UNLESS every entry sharing that identity declares the SAME `group`
|
|
515
|
+
* name (grouped fan-out dispatch, AC6/AC7 — see resolveFanoutGroups). The
|
|
516
|
+
* recorded `group` is self-attested at write time; when `resolvedGroups` is
|
|
517
|
+
* supplied (both call sites always supply it) it is also checked against
|
|
518
|
+
* the CURRENT `gates.fanout.groups` table, so an edit to that table between
|
|
519
|
+
* the round and a later read (e.g. a merge-evidence check) can invalidate a
|
|
520
|
+
* ledger's group claim that was honest when written — see the
|
|
521
|
+
* `resolvedGroups` paragraph below. Two
|
|
522
|
+
* fresh angles sharing a reviewer with differing or missing `group` values
|
|
523
|
+
* still violate the contract. Carried angles keep their prior reviewer and
|
|
524
|
+
* are exempt. Pure; shared by the write path (write-gate-findings-log.mjs,
|
|
525
|
+
* always-on) and the merge-evidence read path (detect-checkpoint-evidence.mjs,
|
|
526
|
+
* scaling the `requireFanoutProvenance` floor) so both agree.
|
|
527
|
+
*
|
|
528
|
+
* Returns an actionable error string naming the offending angle(s) when the
|
|
529
|
+
* contract is violated (an ungrouped reviewer covering >1 fresh angle, angles
|
|
530
|
+
* sharing a reviewer under inconsistent `group` values, or a fresh angle
|
|
531
|
+
* recording no reviewer identity at all — which also silently lowers the
|
|
532
|
+
* distinct-reviewer count below the fresh-angle count), or `null` when it
|
|
533
|
+
* holds (including when `perAngle` has no fresh angles).
|
|
534
|
+
*
|
|
535
|
+
* The recorded `group` is self-attested (any non-empty string the writer
|
|
536
|
+
* chooses), so the grouped exception above is only as strong as the caller
|
|
537
|
+
* lets it be. An optional `resolvedGroups` (the round's `resolveFanoutGroups`
|
|
538
|
+
* output, `{ name, angles }[]`) closes that: a shared identity is only
|
|
539
|
+
* honored when every fresh angle it covers is a member of the SAME
|
|
540
|
+
* configured dispatch unit — a fabricated `group` label spanning angles the
|
|
541
|
+
* table splits apart (or never groups at all) no longer passes.
|
|
542
|
+
* `resolveFanoutGroups` itself emits one-angle-per-unit singletons for
|
|
543
|
+
* `gates.fanout.mode: per-angle` (bypasses configured groups), so passing its
|
|
544
|
+
* output here rejects ANY shared identity in that mode — no separate mode flag
|
|
545
|
+
* needed. As of #1601 (ADR 0048) `gate:full` dispatches GROUPED (fullLabel is a
|
|
546
|
+
* no-op for dispatch shape), so a shared identity within an auto-chunked
|
|
547
|
+
* dispatch unit is honored exactly as for a configured group.
|
|
548
|
+
* Omitting `resolvedGroups` entirely keeps today's fully permissive behavior (any one
|
|
549
|
+
* shared non-null `group` value is accepted, unchecked against config) — both
|
|
550
|
+
* call sites already load config, so they should always supply it; this
|
|
551
|
+
* default only preserves callers (and old ledgers) that don't.
|
|
552
|
+
*
|
|
553
|
+
* @param {unknown} perAngle
|
|
554
|
+
* @param {{name: string, angles: string[]}[]|null} [resolvedGroups]
|
|
555
|
+
* @returns {string|null}
|
|
556
|
+
*/
|
|
557
|
+
export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
|
|
558
|
+
if (!Array.isArray(perAngle)) return null;
|
|
559
|
+
const configuredGroupOf = new Map();
|
|
560
|
+
for (const g of Array.isArray(resolvedGroups) ? resolvedGroups : []) {
|
|
561
|
+
for (const a of Array.isArray(g?.angles) ? g.angles : []) configuredGroupOf.set(a, g.name);
|
|
562
|
+
}
|
|
563
|
+
const freshAngles = new Set();
|
|
564
|
+
const anglesByIdentity = new Map();
|
|
565
|
+
const anonymousAngles = [];
|
|
566
|
+
for (const { entry, angle, group } of freshEntries(perAngle)) {
|
|
567
|
+
freshAngles.add(angle);
|
|
568
|
+
const identity = reviewerIdentity(entry);
|
|
569
|
+
if (identity) {
|
|
570
|
+
if (!anglesByIdentity.has(identity.id)) anglesByIdentity.set(identity.id, { angles: new Set(), label: identity.label, groups: new Set() });
|
|
571
|
+
const record = anglesByIdentity.get(identity.id);
|
|
572
|
+
record.angles.add(angle);
|
|
573
|
+
record.groups.add(group);
|
|
574
|
+
} else {
|
|
575
|
+
anonymousAngles.push(angle);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const freshAngleCount = freshAngles.size;
|
|
579
|
+
const distinctFreshReviewers = anglesByIdentity.size;
|
|
580
|
+
// Enforce the relation itself, not its cardinality shadow: a padded ledger
|
|
581
|
+
// (duplicate-angle entries) can satisfy distinctReviewers >= freshAngleCount
|
|
582
|
+
// while one identity still covers two fresh angles.
|
|
583
|
+
const details = [];
|
|
584
|
+
for (const [id, { angles, label, groups }] of anglesByIdentity) {
|
|
585
|
+
if (angles.size <= 1) continue;
|
|
586
|
+
// One shared, non-null `group` across every entry for this identity is
|
|
587
|
+
// the grouped-dispatch exception: a single reviewer legitimately covers
|
|
588
|
+
// its whole declared group. Differing or missing `group` values fall
|
|
589
|
+
// back to the one-reviewer-per-angle rule.
|
|
590
|
+
const sameGroup = groups.size === 1 && [...groups][0] !== null;
|
|
591
|
+
if (!sameGroup) {
|
|
592
|
+
details.push(`${label} "${id}" is recorded for fresh angles: ${[...angles].join(", ")}`);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
// resolvedGroups supplied: the claimed group is only honest when every
|
|
596
|
+
// angle it covers is a member of the SAME configured group — a claimed
|
|
597
|
+
// group spanning angles the table splits apart (or never groups) fails
|
|
598
|
+
// closed even though the audit record itself is internally consistent.
|
|
599
|
+
if (configuredGroupOf.size > 0) {
|
|
600
|
+
const configuredGroups = new Set([...angles].map((a) => configuredGroupOf.get(a) ?? null));
|
|
601
|
+
if (configuredGroups.size !== 1 || configuredGroups.has(null)) {
|
|
602
|
+
details.push(`${label} "${id}" declares group "${[...groups][0]}" for fresh angles: ${[...angles].join(", ")}, but the configured gates.fanout.groups table does not place all of them in one group`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (anonymousAngles.length > 0) {
|
|
607
|
+
details.push(`fresh angle(s) with no recorded reviewer identity: ${anonymousAngles.join(", ")}`);
|
|
608
|
+
}
|
|
609
|
+
if (details.length === 0) return null;
|
|
610
|
+
return `fan-out provenance violates the one-scoped-reviewer-per-angle contract (${distinctFreshReviewers} distinct reviewer(s) for ${freshAngleCount} fresh angle(s)): ${details.join("; ")} — use executionMode inline_single_agent + --inline-reason for a sanctioned single-reviewer run`;
|
|
611
|
+
}
|
|
612
|
+
|
|
119
613
|
/**
|
|
120
614
|
* Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
|
|
121
615
|
* e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
|
|
@@ -125,15 +619,16 @@ export function provenanceConsistencyError(prov) {
|
|
|
125
619
|
* @param {string} angle
|
|
126
620
|
* @returns {string}
|
|
127
621
|
*/
|
|
128
|
-
function baseAngleName(angle) {
|
|
622
|
+
export function baseAngleName(angle) {
|
|
129
623
|
return angle.replace(/-delta-at-.+$/, "");
|
|
130
624
|
}
|
|
131
625
|
|
|
132
626
|
/**
|
|
133
627
|
* Validate a recorded fan-out angle list against a gate's configured angle
|
|
134
628
|
* contract: every mandatory angle must be represented, and — when a pool is
|
|
135
|
-
* supplied — every recorded angle must be a member of it
|
|
136
|
-
*
|
|
629
|
+
* supplied — every recorded angle must be a member of it or of
|
|
630
|
+
* {@link FANIN_SYNTHETIC_ANGLES} (delta-suffixed angles count toward their
|
|
631
|
+
* {@link baseAngleName}). Pure; shared by the write
|
|
137
632
|
* path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
|
|
138
633
|
* `--findings-json` per-angle results) and the merge-evidence read path
|
|
139
634
|
* (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
|
|
@@ -142,7 +637,7 @@ function baseAngleName(angle) {
|
|
|
142
637
|
* @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
|
|
143
638
|
* @param {object} [gateAngleContract]
|
|
144
639
|
* @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
|
|
145
|
-
* @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted skips the foreign-angle check
|
|
640
|
+
* @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted/empty skips the foreign-angle check; {@link FANIN_SYNTHETIC_ANGLES} are unioned in before membership is checked
|
|
146
641
|
* @returns {{ missingMandatory: string[], foreignAngles: string[] }}
|
|
147
642
|
*/
|
|
148
643
|
export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
|
|
@@ -155,18 +650,40 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
|
|
|
155
650
|
const missingMandatory = mandatoryAngles.filter((a) => !recordedBases.has(a));
|
|
156
651
|
let foreignAngles = [];
|
|
157
652
|
if (Array.isArray(pool) && pool.length > 0) {
|
|
158
|
-
const poolSet = new Set(pool);
|
|
653
|
+
const poolSet = new Set([...pool, ...FANIN_SYNTHETIC_ANGLES]);
|
|
159
654
|
foreignAngles = [...new Set(recorded.filter((a) => !poolSet.has(baseAngleName(a))))];
|
|
160
655
|
}
|
|
161
656
|
return { missingMandatory, foreignAngles };
|
|
162
657
|
}
|
|
163
658
|
|
|
659
|
+
/**
|
|
660
|
+
* Angles the fan-in itself mandates and may synthesize (consolidate-fanin's
|
|
661
|
+
* `--pr-checklist-matrix clean` upsert) without them appearing in any gate's
|
|
662
|
+
* configured `angles` pool. Always legal in the foreign-angle check above —
|
|
663
|
+
* requiring every consumer repo to also list them per-gate would make the two
|
|
664
|
+
* tools contradict the shared contract they implement.
|
|
665
|
+
*/
|
|
666
|
+
export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist-matrix"]);
|
|
667
|
+
|
|
164
668
|
/**
|
|
165
669
|
* Default cap on parallel fan-out reviewers when a caller does not supply one.
|
|
166
670
|
* Mirrors the config default (gates.maxFanoutReviewers).
|
|
167
671
|
*/
|
|
168
672
|
export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
|
|
169
673
|
|
|
674
|
+
// Every sanctioned angle name is a short, hand-authored slug (e.g.
|
|
675
|
+
// "contradiction-lens", "pr-checklist-matrix"); nothing legitimate ever
|
|
676
|
+
// approaches this length. Bounding it here, at the trust boundary this
|
|
677
|
+
// function already owns, fails a pathological artifact closed as malformed —
|
|
678
|
+
// the same place every other angle-result defect is caught — instead of
|
|
679
|
+
// leaving an unbounded reviewer-supplied string to reach the render path,
|
|
680
|
+
// where consolidate-fanin.mjs's per-angle budget marking cannot compress it.
|
|
681
|
+
// This is a malformed-artifact guard, not a comment-budget guarantee: several
|
|
682
|
+
// angles each right at this cap can still exceed the render budget on their
|
|
683
|
+
// headers alone and force the withheld tier — that outcome is the render
|
|
684
|
+
// budget's degradation ladder doing its job, not something this cap prevents.
|
|
685
|
+
const MAX_ANGLE_NAME_LENGTH = 200;
|
|
686
|
+
|
|
170
687
|
/**
|
|
171
688
|
* Validate a single per-angle review result. Returns an error string when the
|
|
172
689
|
* result is malformed, or null when it is well-formed.
|
|
@@ -182,6 +699,9 @@ function validateAngleResult(result) {
|
|
|
182
699
|
if (typeof r.angle !== "string" || r.angle.trim().length === 0) {
|
|
183
700
|
return "angle result is missing a non-empty 'angle'";
|
|
184
701
|
}
|
|
702
|
+
if (r.angle.trim().length > MAX_ANGLE_NAME_LENGTH) {
|
|
703
|
+
return `angle result's 'angle' exceeds ${MAX_ANGLE_NAME_LENGTH} chars`;
|
|
704
|
+
}
|
|
185
705
|
if (typeof r.verdict !== "string" || !VALID_VERDICTS.has(r.verdict)) {
|
|
186
706
|
return `angle '${r.angle}' has invalid verdict (expected clean|findings_present)`;
|
|
187
707
|
}
|
|
@@ -193,8 +713,8 @@ function validateAngleResult(result) {
|
|
|
193
713
|
return `angle '${r.angle}' has a non-object finding`;
|
|
194
714
|
}
|
|
195
715
|
const finding = /** @type {Record<string, unknown>} */ (f);
|
|
196
|
-
if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(finding.severity)) {
|
|
197
|
-
return `angle '${r.angle}' has a finding with invalid severity (expected
|
|
716
|
+
if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(normalizeSeverity(finding.severity))) {
|
|
717
|
+
return `angle '${r.angle}' has a finding with invalid severity (expected ${SEVERITY_ORDER.join("|")})`;
|
|
198
718
|
}
|
|
199
719
|
if (typeof finding.summary !== "string" || finding.summary.trim().length === 0) {
|
|
200
720
|
return `angle '${r.angle}' has a finding without a summary`;
|
|
@@ -224,7 +744,7 @@ function validateAngleResult(result) {
|
|
|
224
744
|
*
|
|
225
745
|
* @param {object} input
|
|
226
746
|
* @param {Array<unknown>} input.angleResults — per-angle review artifacts
|
|
227
|
-
* @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["
|
|
747
|
+
* @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["high"])
|
|
228
748
|
* @returns {{
|
|
229
749
|
* verdict: "clean"|"findings_present"|"blocked",
|
|
230
750
|
* findings: Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>,
|
|
@@ -234,10 +754,14 @@ function validateAngleResult(result) {
|
|
|
234
754
|
*/
|
|
235
755
|
export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities } = {}) {
|
|
236
756
|
const results = Array.isArray(angleResults) ? angleResults : [];
|
|
757
|
+
// Config values normalize through the same alias map as finding severities,
|
|
758
|
+
// so a legacy config spelling ("must-fix", "defer", …) still blocks the
|
|
759
|
+
// renamed tier.
|
|
237
760
|
const blocking = new Set(
|
|
238
|
-
Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
|
|
761
|
+
(Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
|
|
239
762
|
? blockCleanOnFindingSeverities
|
|
240
|
-
: ["
|
|
763
|
+
: ["high"]
|
|
764
|
+
).map((s) => normalizeSeverity(s)),
|
|
241
765
|
);
|
|
242
766
|
|
|
243
767
|
const malformed = [];
|
|
@@ -246,7 +770,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
246
770
|
if (err) malformed.push({ index, reason: err });
|
|
247
771
|
});
|
|
248
772
|
|
|
249
|
-
const bySeverity =
|
|
773
|
+
const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
|
|
250
774
|
/** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
|
|
251
775
|
const findings = [];
|
|
252
776
|
let blockingCount = 0;
|
|
@@ -255,16 +779,17 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
255
779
|
for (const r of results) {
|
|
256
780
|
const angle = r.angle.trim();
|
|
257
781
|
for (const f of r.findings) {
|
|
258
|
-
const
|
|
782
|
+
const severity = /** @type {string} */ (normalizeSeverity(f.severity));
|
|
783
|
+
const isBlocking = blocking.has(severity);
|
|
259
784
|
if (isBlocking) blockingCount += 1;
|
|
260
|
-
bySeverity[
|
|
785
|
+
bySeverity[severity] += 1;
|
|
261
786
|
const entry = {
|
|
262
|
-
severity
|
|
787
|
+
severity,
|
|
263
788
|
angle,
|
|
264
789
|
summary: String(f.summary).trim(),
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
disposition:
|
|
790
|
+
// See deriveDisposition's own doc for the full rule; the fix cycle
|
|
791
|
+
// / operator can override the disposition afterward.
|
|
792
|
+
disposition: deriveDisposition(severity, { isBlocking, locatable: hasLocatableShape(f) }),
|
|
268
793
|
};
|
|
269
794
|
if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
|
|
270
795
|
if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
|
|
@@ -298,13 +823,155 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
298
823
|
};
|
|
299
824
|
}
|
|
300
825
|
|
|
826
|
+
/**
|
|
827
|
+
* The judge's relevance-based disposition vocabulary — distinct from the
|
|
828
|
+
* severity-based `disposition` (accepted-for-fix/deferred/needs-answer) that
|
|
829
|
+
* `deriveDisposition` owns. The judge decides *where* a finding is acted on
|
|
830
|
+
* (this PR or a follow-up), never *whether* it is real: a `reject` is a
|
|
831
|
+
* relevance verdict (out-of-scope against a named non-goal or scope
|
|
832
|
+
* boundary), not a reproduction verdict. The fixer retains reproduction-based
|
|
833
|
+
* rejection; the judge owns relevance (#1525).
|
|
834
|
+
*/
|
|
835
|
+
export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Validate a judge verdict artifact shape (the dedicated `judge` agent's only
|
|
839
|
+
* write). Pure; throws on a malformed verdict rather than silently enriching
|
|
840
|
+
* findings with garbage. The judge is the designated memory across rounds, so
|
|
841
|
+
* its artifact is the authoritative relevance record — a malformed one fails
|
|
842
|
+
* closed rather than degrading to severity-only disposition.
|
|
843
|
+
*
|
|
844
|
+
* Shape:
|
|
845
|
+
* ```
|
|
846
|
+
* {
|
|
847
|
+
* headSha: "<sha>",
|
|
848
|
+
* scopeDrift: { verdict: "within_scope"|"drift_detected", rationale: "...", driftedAreas: ["..."] },
|
|
849
|
+
* dispositions: [{ index, disposition: "act"|"defer"|"reject", rationale, criterion?, followUpDraft? }]
|
|
850
|
+
* }
|
|
851
|
+
* ```
|
|
852
|
+
*
|
|
853
|
+
* @param {unknown} verdict
|
|
854
|
+
* @returns {{ headSha: string, scopeDrift: object, dispositions: Array<object> }}
|
|
855
|
+
*/
|
|
856
|
+
export function validateJudgeVerdict(verdict) {
|
|
857
|
+
if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
|
|
858
|
+
throw new Error("judge verdict must be a JSON object");
|
|
859
|
+
}
|
|
860
|
+
const v = /** @type {Record<string, unknown>} */ (verdict);
|
|
861
|
+
if (typeof v.headSha !== "string" || v.headSha.trim().length === 0) {
|
|
862
|
+
throw new Error("judge verdict.headSha must be a non-empty string");
|
|
863
|
+
}
|
|
864
|
+
if (!v.scopeDrift || typeof v.scopeDrift !== "object" || Array.isArray(v.scopeDrift)) {
|
|
865
|
+
throw new Error("judge verdict.scopeDrift must be an object");
|
|
866
|
+
}
|
|
867
|
+
const sd = /** @type {Record<string, unknown>} */ (v.scopeDrift);
|
|
868
|
+
if (sd.verdict !== "within_scope" && sd.verdict !== "drift_detected") {
|
|
869
|
+
throw new Error("judge verdict.scopeDrift.verdict must be 'within_scope' or 'drift_detected'");
|
|
870
|
+
}
|
|
871
|
+
if (typeof sd.rationale !== "string" || sd.rationale.trim().length === 0) {
|
|
872
|
+
throw new Error("judge verdict.scopeDrift.rationale must be a non-empty string");
|
|
873
|
+
}
|
|
874
|
+
if (!Array.isArray(sd.driftedAreas)) {
|
|
875
|
+
throw new Error("judge verdict.scopeDrift.driftedAreas must be an array");
|
|
876
|
+
}
|
|
877
|
+
for (const [di, area] of sd.driftedAreas.entries()) {
|
|
878
|
+
if (typeof area !== "string" || area.trim().length === 0) {
|
|
879
|
+
throw new Error(`judge verdict.scopeDrift.driftedAreas[${di}] must be a non-empty string`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (!Array.isArray(v.dispositions)) {
|
|
883
|
+
throw new Error("judge verdict.dispositions must be an array");
|
|
884
|
+
}
|
|
885
|
+
const seenIndices = new Set();
|
|
886
|
+
for (const [i, d] of v.dispositions.entries()) {
|
|
887
|
+
if (!d || typeof d !== "object" || Array.isArray(d)) {
|
|
888
|
+
throw new Error(`judge verdict.dispositions[${i}] must be an object`);
|
|
889
|
+
}
|
|
890
|
+
const entry = /** @type {Record<string, unknown>} */ (d);
|
|
891
|
+
if (!Number.isInteger(entry.index) || entry.index < 0) {
|
|
892
|
+
throw new Error(`judge verdict.dispositions[${i}].index must be a non-negative integer`);
|
|
893
|
+
}
|
|
894
|
+
if (seenIndices.has(entry.index)) {
|
|
895
|
+
throw new Error(`judge verdict.dispositions[${i}].index ${entry.index} is a duplicate — the contract is one disposition per finding`);
|
|
896
|
+
}
|
|
897
|
+
seenIndices.add(entry.index);
|
|
898
|
+
if (!JUDGE_DISPOSITIONS.includes(entry.disposition)) {
|
|
899
|
+
throw new Error(`judge verdict.dispositions[${i}].disposition must be one of: ${JUDGE_DISPOSITIONS.join(", ")}`);
|
|
900
|
+
}
|
|
901
|
+
if (typeof entry.rationale !== "string" || entry.rationale.trim().length === 0) {
|
|
902
|
+
throw new Error(`judge verdict.dispositions[${i}].rationale must be a non-empty string naming the criterion, non-goal, or scope boundary`);
|
|
903
|
+
}
|
|
904
|
+
// followUpDraft is REQUIRED on a defer disposition (soft-cap contract: a
|
|
905
|
+
// deferred finding carries a fileable follow-up draft). Optional otherwise.
|
|
906
|
+
if (entry.disposition === "defer") {
|
|
907
|
+
if (!entry.followUpDraft || typeof entry.followUpDraft !== "object" || Array.isArray(entry.followUpDraft)) {
|
|
908
|
+
throw new Error(`judge verdict.dispositions[${i}].followUpDraft is required on a defer disposition`);
|
|
909
|
+
}
|
|
910
|
+
const draft = /** @type {Record<string, unknown>} */ (entry.followUpDraft);
|
|
911
|
+
if (typeof draft.title !== "string" || draft.title.trim().length === 0 || typeof draft.body !== "string") {
|
|
912
|
+
throw new Error(`judge verdict.dispositions[${i}].followUpDraft must have a non-empty title and a body string`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return { headSha: v.headSha, scopeDrift: v.scopeDrift, dispositions: v.dispositions };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Merge the judge's relevance-based dispositions into the consolidated findings
|
|
921
|
+
* array (the flat per-finding shape `consolidateFanin` / `toFindingsLogShape`
|
|
922
|
+
* produce). The judge runs AFTER fan-in and BEFORE the fix pass (#1525): it
|
|
923
|
+
* receives the consolidated ledger, the issue's AC/DoD/non-goals, the PR's
|
|
924
|
+
* declared scope, and prior-round ledgers, and emits a per-finding disposition
|
|
925
|
+
* (`act` / `defer` / `reject`) plus a scope-drift verdict on the PR as a whole.
|
|
926
|
+
*
|
|
927
|
+
* This function enriches each finding with `judgeDisposition`, `judgeRationale`,
|
|
928
|
+
* and (for `defer`) `followUpDraft` so the disposition ledger and posted findings
|
|
929
|
+
* comment carry what was consciously not acted on and why. The severity-based
|
|
930
|
+
* `disposition` (accepted-for-fix/deferred/needs-answer) is LEFT INTACT — the
|
|
931
|
+
* judge's relevance axis is complementary, not a replacement (a real defect
|
|
932
|
+
* stays a real defect; the judge decides *where* it is fixed, not *whether* it
|
|
933
|
+
* is real).
|
|
934
|
+
*
|
|
935
|
+
* The fix pass consumes only the `act` list; the fixer retains reproduction-
|
|
936
|
+
* based rejection (a finding that does not reproduce is dead regardless of the
|
|
937
|
+
* judge's verdict) but stops deciding relevance.
|
|
938
|
+
*
|
|
939
|
+
* Pure. Fails closed (throws) when a disposition references an out-of-range
|
|
940
|
+
* index — a judge verdict that names a finding that is not in the ledger is a
|
|
941
|
+
* mismatch, never a silent enrichment.
|
|
942
|
+
*
|
|
943
|
+
* @param {Array<object>} findings — the flat consolidated findings array
|
|
944
|
+
* @param {object} judgeVerdict — the validated judge verdict artifact
|
|
945
|
+
* @returns {{ findings: Array<object>, scopeDrift: object }}
|
|
946
|
+
*/
|
|
947
|
+
export function applyJudgeDispositions(findings, judgeVerdict) {
|
|
948
|
+
const validated = validateJudgeVerdict(judgeVerdict);
|
|
949
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
950
|
+
const enriched = list.map((f) => ({ ...f }));
|
|
951
|
+
for (const d of validated.dispositions) {
|
|
952
|
+
if (d.index >= enriched.length) {
|
|
953
|
+
throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`);
|
|
954
|
+
}
|
|
955
|
+
const target = enriched[d.index];
|
|
956
|
+
target.judgeDisposition = d.disposition;
|
|
957
|
+
target.judgeRationale = d.rationale;
|
|
958
|
+
if (typeof d.criterion === "string" && d.criterion.trim().length > 0) {
|
|
959
|
+
target.judgeCriterion = d.criterion.trim();
|
|
960
|
+
}
|
|
961
|
+
if (d.disposition === "defer" && d.followUpDraft) {
|
|
962
|
+
target.followUpDraft = d.followUpDraft;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return { findings: enriched, scopeDrift: validated.scopeDrift };
|
|
966
|
+
}
|
|
967
|
+
|
|
301
968
|
/**
|
|
302
969
|
* Map consolidated findings into the `--findings` JSON shape consumed by
|
|
303
970
|
* scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
|
|
304
|
-
* disposition, optional files). Pure.
|
|
971
|
+
* disposition, optional files, optional line). Pure.
|
|
305
972
|
*
|
|
306
|
-
* @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string}>} findings
|
|
307
|
-
* @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[]}>}
|
|
973
|
+
* @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string, recommendation?: string, line?: number}>} findings
|
|
974
|
+
* @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[], recommendation?: string, line?: number}>}
|
|
308
975
|
*/
|
|
309
976
|
export function toFindingsLogShape(findings) {
|
|
310
977
|
const list = Array.isArray(findings) ? findings : [];
|
|
@@ -317,12 +984,33 @@ export function toFindingsLogShape(findings) {
|
|
|
317
984
|
if (typeof f.disposition === "string" && f.disposition.trim().length > 0) {
|
|
318
985
|
entry.disposition = f.disposition.trim();
|
|
319
986
|
}
|
|
987
|
+
if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
|
|
988
|
+
entry.recommendation = f.recommendation.trim();
|
|
989
|
+
}
|
|
320
990
|
if (typeof f.file === "string" && f.file.trim().length > 0) {
|
|
321
991
|
entry.files = [f.file.trim()];
|
|
322
992
|
} else if (Array.isArray(f.files)) {
|
|
323
993
|
const files = f.files.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
|
|
324
994
|
if (files.length > 0) entry.files = files;
|
|
325
995
|
}
|
|
996
|
+
if (Number.isInteger(f.line) && f.line > 0) {
|
|
997
|
+
entry.line = f.line;
|
|
998
|
+
}
|
|
999
|
+
// Carry the judge's relevance-based dispositions through (#1525) so the
|
|
1000
|
+
// durable ledger and posted findings comment show what was consciously not
|
|
1001
|
+
// acted on and why.
|
|
1002
|
+
if (typeof f.judgeDisposition === "string" && f.judgeDisposition.trim().length > 0) {
|
|
1003
|
+
entry.judgeDisposition = f.judgeDisposition.trim();
|
|
1004
|
+
}
|
|
1005
|
+
if (typeof f.judgeRationale === "string" && f.judgeRationale.trim().length > 0) {
|
|
1006
|
+
entry.judgeRationale = f.judgeRationale.trim();
|
|
1007
|
+
}
|
|
1008
|
+
if (typeof f.judgeCriterion === "string" && f.judgeCriterion.trim().length > 0) {
|
|
1009
|
+
entry.judgeCriterion = f.judgeCriterion.trim();
|
|
1010
|
+
}
|
|
1011
|
+
if (f.followUpDraft && typeof f.followUpDraft === "object" && !Array.isArray(f.followUpDraft)) {
|
|
1012
|
+
entry.followUpDraft = f.followUpDraft;
|
|
1013
|
+
}
|
|
326
1014
|
return entry;
|
|
327
1015
|
});
|
|
328
1016
|
}
|
|
@@ -330,6 +1018,12 @@ export function toFindingsLogShape(findings) {
|
|
|
330
1018
|
/**
|
|
331
1019
|
* Plan how a resolved angle set fans out across the reviewer cap. Pure.
|
|
332
1020
|
*
|
|
1021
|
+
* SUPERSEDED by `scheduleFanoutWaves` (#1601, ADR 0048): the gate fan-out
|
|
1022
|
+
* conductor now dispatches wave-by-wave at most `gates.fanout.maxConcurrent`
|
|
1023
|
+
* (M) dispatch units per wave, using the wave plan emitted by
|
|
1024
|
+
* `write-gate-context.mjs`. This helper is kept only for back-compat (zero
|
|
1025
|
+
* non-test callers) and no longer participates in the dispatch path.
|
|
1026
|
+
*
|
|
333
1027
|
* When `angles.length <= maxReviewers`, all reviewers run in a single parallel
|
|
334
1028
|
* batch (no degradation). When it exceeds the cap, the overflow is split into
|
|
335
1029
|
* sequential batches of at most `maxReviewers` each, and `degraded` is true so
|