@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4
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 +7 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/claude/hook-decisions.mjs +14 -0
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +1125 -240
- package/src/config/extension-defaults.yaml +217 -426
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +556 -0
- package/src/github/ownership-helpers.mjs +79 -0
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/bash-command-classify.mjs +35 -5
- package/src/loop/conductor-routing.mjs +1 -1
- package/src/loop/copilot-ci-status.mjs +76 -0
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/copilot-loop-state.mjs +9 -5
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +29 -2
- package/src/loop/gate-fanin.mjs +481 -31
- package/src/loop/handoff-envelope.mjs +43 -23
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +204 -47
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/queue-board-sync.mjs +26 -9
- 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 +14 -7
- package/src/tracker/adapter.mjs +127 -0
- package/src/tracker/github-adapter.mjs +150 -0
- package/src/tracker/index.mjs +50 -0
- package/src/tracker/noop-adapter.mjs +35 -0
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -14,14 +14,231 @@
|
|
|
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
|
+
// Exported so other tools (e.g. scripts/loop/consolidate-fanin.mjs,
|
|
81
|
+
// scripts/github/upsert-checkpoint-verdict.mjs) sort/rank/validate against
|
|
82
|
+
// this single ordered copy of the severity vocabulary instead of each
|
|
83
|
+
// hand-copying its own list (and its own load-time drift guard) — ORDER is
|
|
84
|
+
// part of the contract here, not just membership, so a consumer that only
|
|
85
|
+
// checked membership against a Set could accept a silently reordered copy.
|
|
86
|
+
// Ranked by gate-close urgency, not just defect-severity: "question" sits
|
|
87
|
+
// right after "high" because BOTH force gate-close to stay blocked (a high
|
|
88
|
+
// finding via the fix loop, a question via never being auto-deferred) — it
|
|
89
|
+
// outranks "medium"/"low", which both eventually defer. "nit" trails last:
|
|
90
|
+
// it defers immediately, with no fixer cycle at all.
|
|
91
|
+
export const SEVERITY_ORDER = ["high", "question", "medium", "low", "nit"];
|
|
92
|
+
|
|
93
|
+
// Marker gate name → gates.<key> config key. Owned here so every caller of
|
|
94
|
+
// resolveFanoutGroups maps the same way; passing the marker name verbatim
|
|
95
|
+
// resolves no groups and silently downgrades pairing enforcement.
|
|
96
|
+
export const GATE_CONFIG_KEY = Object.freeze({ draft_gate: "draft", pre_approval_gate: "preApproval" });
|
|
97
|
+
export const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
|
|
98
|
+
|
|
99
|
+
// Pre-rename spellings. Old ledgers, markers, and configs still carry them;
|
|
100
|
+
// every read boundary normalizes through this map. Every SANCTIONED producer
|
|
101
|
+
// (consolidateFanin, write-gate-findings-log.mjs, post-gate-findings.mjs)
|
|
102
|
+
// normalizes before a severity reaches a marker/ledger, so a freshly posted
|
|
103
|
+
// marker carries only a canonical spelling in practice — but this map is a
|
|
104
|
+
// read-side normalizer, not a write-side enforcement boundary:
|
|
105
|
+
// buildFindingMarker (_gate-finding-surface.mjs) is a thin text builder that
|
|
106
|
+
// emits whatever severity string it is given, verbatim (a legacy-spelled
|
|
107
|
+
// marker built directly, e.g. for round-trip test fixtures, still parses
|
|
108
|
+
// correctly via normalizeSeverity on read).
|
|
109
|
+
export const LEGACY_SEVERITY_ALIASES = Object.freeze({
|
|
110
|
+
"must-fix": "high",
|
|
111
|
+
"worth-fixing-now": "medium",
|
|
112
|
+
"nice-to-have": "low",
|
|
113
|
+
defer: "low",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Map a legacy severity spelling to its canonical name; unknown values pass
|
|
118
|
+
* through trimmed (the caller's validation still rejects them) — a
|
|
119
|
+
* non-string passes through unchanged. Trimming BEFORE the alias lookup
|
|
120
|
+
* (rather than requiring every caller to do it first) is what keeps every
|
|
121
|
+
* call site of this function agreeing on the same value for the same
|
|
122
|
+
* incidentally-whitespace-varied input: consolidate-fanin.mjs's own floor
|
|
123
|
+
* validation trims before calling this, while gate-fanin's `consolidateFanin`
|
|
124
|
+
* does not — two call sites trimming inconsistently is exactly how an
|
|
125
|
+
* untrimmed "high " passed one gate's validation and then failed the
|
|
126
|
+
* other's. Deliberately case-SENSITIVE (no lowercasing): every sanctioned
|
|
127
|
+
* writer (slugForMarker, config authoring, this module's own producers)
|
|
128
|
+
* already emits lowercase, so a forged/hand-edited mixed-case value (e.g.
|
|
129
|
+
* "NIT") must fail VALID_SEVERITIES validation and dangle fail-closed rather
|
|
130
|
+
* than being silently coerced into a real severity that then auto-defers.
|
|
131
|
+
* @param {unknown} severity
|
|
132
|
+
* @returns {unknown}
|
|
133
|
+
*/
|
|
134
|
+
export function normalizeSeverity(severity) {
|
|
135
|
+
if (typeof severity !== "string") return severity;
|
|
136
|
+
const normalized = severity.trim();
|
|
137
|
+
return Object.hasOwn(LEGACY_SEVERITY_ALIASES, normalized) ? LEGACY_SEVERITY_ALIASES[normalized] : normalized;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Map a (possibly legacy-spelled/untrimmed) severity to its SEVERITY_ORDER
|
|
142
|
+
* index — the ONE rank rule every sort/ordering consumer
|
|
143
|
+
* (consolidate-fanin.mjs's `angleWorstSeverityRank`,
|
|
144
|
+
* upsert-checkpoint-verdict.mjs's severity-grouped rendering) shares, so the
|
|
145
|
+
* two can never drift on how an unknown severity ranks. An unrecognized
|
|
146
|
+
* severity (after normalization) ranks LAST (`SEVERITY_ORDER.length`, never
|
|
147
|
+
* -1) so it always sorts after every known severity instead of floating
|
|
148
|
+
* above "high" the way a raw, unmapped `indexOf` would.
|
|
149
|
+
* @param {unknown} severity
|
|
150
|
+
* @returns {number}
|
|
151
|
+
*/
|
|
152
|
+
export function severityRank(severity) {
|
|
153
|
+
const idx = SEVERITY_ORDER.indexOf(/** @type {string} */ (normalizeSeverity(severity)));
|
|
154
|
+
return idx === -1 ? SEVERITY_ORDER.length : idx;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Merge a severity→count map's legacy-spelled keys into their canonical keys
|
|
159
|
+
* (summing counts) so both the CLI parser and direct programmatic callers of
|
|
160
|
+
* the verdict poster share ONE merge rule. Values pass through unvalidated —
|
|
161
|
+
* the caller keeps its own integer/shape checks.
|
|
162
|
+
* @param {Record<string, number>} counts
|
|
163
|
+
* @returns {Record<string, number>} null-prototype object with canonical keys
|
|
164
|
+
*/
|
|
165
|
+
export function normalizeSeverityCounts(counts) {
|
|
166
|
+
const normalized = Object.create(null);
|
|
167
|
+
for (const [key, value] of Object.entries(counts)) {
|
|
168
|
+
const canonicalKey = /** @type {string} */ (normalizeSeverity(key));
|
|
169
|
+
normalized[canonicalKey] = (normalized[canonicalKey] ?? 0) + value;
|
|
170
|
+
}
|
|
171
|
+
return normalized;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A finding is LOCATABLE-SHAPED when it names a real file (via `file` or
|
|
176
|
+
* `files[0]`) and a positive-integer `line` — the ONE shared shape check
|
|
177
|
+
* every producer/consumer of the locatable/non-locatable distinction keys
|
|
178
|
+
* on, whether the finding is the raw per-angle `{file, line}` shape
|
|
179
|
+
* (consolidateFanin's own input) or the ledger's `{files, line}` shape
|
|
180
|
+
* (write-gate-findings-log.mjs / post-gate-findings.mjs). This is NECESSARY
|
|
181
|
+
* but not SUFFICIENT for a thread-locatable finding: `isLocatableFinding`
|
|
182
|
+
* (scripts/github/_gate-finding-surface.mjs) additionally requires the
|
|
183
|
+
* file:line to fall inside the reviewed diff, which only that caller
|
|
184
|
+
* (holding the diff's commentable-line set) can check — this function is
|
|
185
|
+
* its shared shape floor, not a replacement for it.
|
|
186
|
+
* @param {{ file?: unknown, files?: unknown, line?: unknown }} finding
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
export function hasLocatableShape(finding) {
|
|
190
|
+
const file = typeof finding?.file === "string"
|
|
191
|
+
? finding.file
|
|
192
|
+
: (Array.isArray(finding?.files) ? finding.files[0] : undefined);
|
|
193
|
+
return typeof file === "string" && file.trim().length > 0
|
|
194
|
+
&& Number.isInteger(finding?.line) && /** @type {number} */ (finding.line) >= 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Derive the ledger disposition for a finding at `severity` — the ONE rule
|
|
199
|
+
* every producer (consolidateFanin, write-gate-findings-log.mjs,
|
|
200
|
+
* post-gate-findings.mjs) shares, so the three can never drift on what a
|
|
201
|
+
* severity/locatability combination resolves to. A LOCATABLE `question` is
|
|
202
|
+
* answered, never fixed or deferred — it gets its own disposition
|
|
203
|
+
* ("needs-answer") regardless of `isBlocking` (a question can never be
|
|
204
|
+
* blocking in practice — blockCleanOnFindingSeverities is restricted to
|
|
205
|
+
* defect severities — but this stays severity-first rather than
|
|
206
|
+
* isBlocking-first so that invariant is enforced here too, not just at the
|
|
207
|
+
* config boundary). A NON-LOCATABLE question has no resolvable thread to
|
|
208
|
+
* answer through — it is body-filed and deferred by construction, exactly
|
|
209
|
+
* like every other non-`high` body-filed finding
|
|
210
|
+
* (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`
|
|
211
|
+
* entirely: `isBlocking` alone decides accepted-for-fix vs deferred.
|
|
212
|
+
* @param {string} severity — already normalized
|
|
213
|
+
* @param {{ isBlocking?: boolean, locatable?: boolean }} [options]
|
|
214
|
+
* @returns {"accepted-for-fix"|"deferred"|"needs-answer"}
|
|
215
|
+
*/
|
|
216
|
+
export function deriveDisposition(severity, { isBlocking = false, locatable = false } = {}) {
|
|
217
|
+
if (severity === "question") return locatable ? "needs-answer" : "deferred";
|
|
218
|
+
return isBlocking ? "accepted-for-fix" : "deferred";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Does `severity` (already normalized) have a default disposition that
|
|
223
|
+
* `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and
|
|
224
|
+
* "nit" always defer regardless of any gate's `blockCleanOnFindingSeverities`
|
|
225
|
+
* config, and "question" resolves off `locatable` alone — so a caller with no
|
|
226
|
+
* `isBlocking` context (write-gate-findings-log.mjs / post-gate-findings.mjs's
|
|
227
|
+
* CLI validators, which accept a bare `--findings` array with no config in
|
|
228
|
+
* scope) can still fill in a default disposition for these three, and only
|
|
229
|
+
* these three, when the caller left it unset. "high" and "medium" are
|
|
230
|
+
* excluded: whether either blocks a clean verdict depends on config, which
|
|
231
|
+
* only a caller holding `blockCleanOnFindingSeverities` can know — guessing
|
|
232
|
+
* "deferred" for one of those here would be wrong for a repo that configures
|
|
233
|
+
* it as blocking. Shared by both CLI validators (see `deriveDisposition`) so
|
|
234
|
+
* the two can never restate this guard out of sync.
|
|
235
|
+
* @param {string} severity — already normalized
|
|
236
|
+
* @returns {boolean}
|
|
237
|
+
*/
|
|
238
|
+
export function isDefaultDeferrableSeverity(severity) {
|
|
239
|
+
return severity === "low" || severity === "nit" || severity === "question";
|
|
240
|
+
}
|
|
241
|
+
|
|
25
242
|
const VALID_VERDICTS = new Set(["clean", "findings_present"]);
|
|
26
243
|
|
|
27
244
|
/**
|
|
@@ -30,7 +247,7 @@ const VALID_VERDICTS = new Set(["clean", "findings_present"]);
|
|
|
30
247
|
* depth). The flow MUST fail closed with this message and route the gate review
|
|
31
248
|
* to the conductor rather than silently degrading to a single-agent inline
|
|
32
249
|
* review (which requireFanoutProvenance is designed to reject). Documented as a
|
|
33
|
-
* contract in docs/gate-review-sub-loop-contract.md.
|
|
250
|
+
* contract in skills/docs/gate-review-sub-loop-contract.md.
|
|
34
251
|
*/
|
|
35
252
|
export const FANOUT_UNAVAILABLE_MESSAGE = "fan-out unavailable — route to conductor";
|
|
36
253
|
|
|
@@ -63,23 +280,39 @@ export function countDistinctReviewers(perAngle) {
|
|
|
63
280
|
const ids = new Set();
|
|
64
281
|
for (const e of perAngle) {
|
|
65
282
|
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);
|
|
283
|
+
const identity = reviewerIdentity(e);
|
|
284
|
+
if (identity) ids.add(identity.id);
|
|
72
285
|
}
|
|
73
286
|
return ids.size;
|
|
74
287
|
}
|
|
75
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The single identity-selection rule for a perAngle entry: a non-empty
|
|
291
|
+
* `reviewer` wins, else a non-empty `dispatchId`, else no identity. Returns
|
|
292
|
+
* `{ id, label }` (label = which field carried the identity, for error
|
|
293
|
+
* messages) or null. Shared by countDistinctReviewers and
|
|
294
|
+
* fanoutReviewerPairingError so the two can never diverge.
|
|
295
|
+
*
|
|
296
|
+
* @param {object} entry — a perAngle entry
|
|
297
|
+
* @returns {{ id: string, label: "reviewer"|"dispatchId" }|null}
|
|
298
|
+
*/
|
|
299
|
+
function reviewerIdentity(entry) {
|
|
300
|
+
if (typeof entry.reviewer === "string" && entry.reviewer.trim().length > 0) {
|
|
301
|
+
return { id: entry.reviewer.trim(), label: "reviewer" };
|
|
302
|
+
}
|
|
303
|
+
if (typeof entry.dispatchId === "string" && entry.dispatchId.trim().length > 0) {
|
|
304
|
+
return { id: entry.dispatchId.trim(), label: "dispatchId" };
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
76
309
|
/**
|
|
77
310
|
* Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an error
|
|
78
311
|
* string when the provenance is malformed or self-inconsistent, or null when it
|
|
79
312
|
* is well-formed and consistent. Shared by the write path (write-gate-findings-log)
|
|
80
313
|
* and the enforcement read path (buildPreMergeGateCheck) so both agree.
|
|
81
314
|
*
|
|
82
|
-
* Consistency rule (documented in docs/gate-review-sub-loop-contract.md):
|
|
315
|
+
* Consistency rule (documented in skills/docs/gate-review-sub-loop-contract.md):
|
|
83
316
|
* - `distinctReviewers` must be a non-negative integer.
|
|
84
317
|
* - `perAngle` must be an array, and non-empty when `distinctReviewers > 0`.
|
|
85
318
|
* - `distinctReviewers` must be <= the count of DISTINCT reviewer identities
|
|
@@ -116,6 +349,180 @@ export function provenanceConsistencyError(prov) {
|
|
|
116
349
|
return null;
|
|
117
350
|
}
|
|
118
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle`
|
|
354
|
+
* array — a valid object entry naming a non-blank `angle` and carrying no
|
|
355
|
+
* `carriedFromHead` (a carried angle's clean verdict was reused from a prior
|
|
356
|
+
* head's review, see @dev-loops/core/loop/gate-carry-forward, not freshly
|
|
357
|
+
* reviewed here). `group` is the entry's normalized, non-blank `group`
|
|
358
|
+
* string, or `null`. This is the ONE definition of "fresh" and "declared
|
|
359
|
+
* group" — {@link freshAngleNames}, {@link countFreshDispatchUnits}, and
|
|
360
|
+
* {@link fanoutReviewerPairingError} all derive from it so the write-time
|
|
361
|
+
* floor and the pairing check can never silently drift apart on what either
|
|
362
|
+
* term means. Pure.
|
|
363
|
+
* @param {unknown} perAngle
|
|
364
|
+
* @returns {Generator<{ entry: object, angle: string, group: string|null }>}
|
|
365
|
+
*/
|
|
366
|
+
function* freshEntries(perAngle) {
|
|
367
|
+
if (!Array.isArray(perAngle)) return;
|
|
368
|
+
for (const entry of perAngle) {
|
|
369
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
370
|
+
if (typeof entry.carriedFromHead === "string" && entry.carriedFromHead.trim().length > 0) continue;
|
|
371
|
+
const angle = typeof entry.angle === "string" ? entry.angle.trim() : "";
|
|
372
|
+
if (!angle) continue;
|
|
373
|
+
const group = typeof entry.group === "string" && entry.group.trim().length > 0 ? entry.group.trim() : null;
|
|
374
|
+
yield { entry, angle, group };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Names of DISTINCT "fresh" angles in a `perAngle` array — see
|
|
380
|
+
* {@link freshEntries}. Used by callers that need the names themselves (e.g.
|
|
381
|
+
* resolving this round's dispatch groups via `resolveFanoutGroups` for
|
|
382
|
+
* {@link fanoutReviewerPairingError}'s cross-check). Pure.
|
|
383
|
+
*
|
|
384
|
+
* @param {unknown} perAngle
|
|
385
|
+
* @returns {string[]}
|
|
386
|
+
*/
|
|
387
|
+
export function freshAngleNames(perAngle) {
|
|
388
|
+
const angles = new Set();
|
|
389
|
+
for (const { angle } of freshEntries(perAngle)) angles.add(angle);
|
|
390
|
+
return [...angles];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Count distinct FRESH dispatch units in a `perAngle` array: a fresh angle
|
|
395
|
+
* that declares a `group` counts once per DISTINCT group name (its whole
|
|
396
|
+
* group is one reviewer's dispatch), and a fresh angle with no `group`
|
|
397
|
+
* counts as its own dispatch unit (today's one-reviewer-per-angle shape).
|
|
398
|
+
* This is the grouping-aware generalization of counting distinct fresh
|
|
399
|
+
* angle names via {@link freshAngleNames} — for an ungrouped ledger the two
|
|
400
|
+
* are identical; for a grouped ledger this is <= the ungrouped count, since
|
|
401
|
+
* one group of N angles is one dispatch unit, not N. Shared by the write
|
|
402
|
+
* path (write-gate-findings-log.mjs) and the
|
|
403
|
+
* requireFanoutProvenance read path (detect-checkpoint-evidence.mjs) so the
|
|
404
|
+
* `distinctReviewers` floor scales with what was actually DISPATCHED, not
|
|
405
|
+
* with the angle count a grouped round deliberately dispatches fewer
|
|
406
|
+
* reviewers than. Pure.
|
|
407
|
+
*
|
|
408
|
+
* @param {unknown} perAngle
|
|
409
|
+
* @returns {number}
|
|
410
|
+
*/
|
|
411
|
+
export function countFreshDispatchUnits(perAngle) {
|
|
412
|
+
const groups = new Set();
|
|
413
|
+
const ungroupedAngles = new Set();
|
|
414
|
+
for (const { angle, group } of freshEntries(perAngle)) {
|
|
415
|
+
if (group) groups.add(group);
|
|
416
|
+
else ungroupedAngles.add(angle);
|
|
417
|
+
}
|
|
418
|
+
return groups.size + ungroupedAngles.size;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Validate the one-scoped-reviewer-per-fresh-angle contract (fanout_fanin
|
|
423
|
+
* execution mandates one independent reviewer per resolved angle; #1431): no
|
|
424
|
+
* two FRESH angles (angles without `carriedFromHead` — see
|
|
425
|
+
* {@link freshEntries}) may share one reviewer identity (`reviewer`,
|
|
426
|
+
* else `dispatchId` — matching {@link countDistinctReviewers}'s identity
|
|
427
|
+
* rule), UNLESS every entry sharing that identity declares the SAME `group`
|
|
428
|
+
* name (grouped fan-out dispatch, AC6/AC7 — see resolveFanoutGroups). The
|
|
429
|
+
* recorded `group` is self-attested at write time; when `resolvedGroups` is
|
|
430
|
+
* supplied (both call sites always supply it) it is also checked against
|
|
431
|
+
* the CURRENT `gates.fanout.groups` table, so an edit to that table between
|
|
432
|
+
* the round and a later read (e.g. a merge-evidence check) can invalidate a
|
|
433
|
+
* ledger's group claim that was honest when written — see the
|
|
434
|
+
* `resolvedGroups` paragraph below. Two
|
|
435
|
+
* fresh angles sharing a reviewer with differing or missing `group` values
|
|
436
|
+
* still violate the contract. Carried angles keep their prior reviewer and
|
|
437
|
+
* are exempt. Pure; shared by the write path (write-gate-findings-log.mjs,
|
|
438
|
+
* always-on) and the merge-evidence read path (detect-checkpoint-evidence.mjs,
|
|
439
|
+
* scaling the `requireFanoutProvenance` floor) so both agree.
|
|
440
|
+
*
|
|
441
|
+
* Returns an actionable error string naming the offending angle(s) when the
|
|
442
|
+
* contract is violated (an ungrouped reviewer covering >1 fresh angle, angles
|
|
443
|
+
* sharing a reviewer under inconsistent `group` values, or a fresh angle
|
|
444
|
+
* recording no reviewer identity at all — which also silently lowers the
|
|
445
|
+
* distinct-reviewer count below the fresh-angle count), or `null` when it
|
|
446
|
+
* holds (including when `perAngle` has no fresh angles).
|
|
447
|
+
*
|
|
448
|
+
* The recorded `group` is self-attested (any non-empty string the writer
|
|
449
|
+
* chooses), so the grouped exception above is only as strong as the caller
|
|
450
|
+
* lets it be. An optional `resolvedGroups` (the round's `resolveFanoutGroups`
|
|
451
|
+
* output, `{ name, angles }[]`) closes that: a shared identity is only
|
|
452
|
+
* honored when every fresh angle it covers is a member of the SAME
|
|
453
|
+
* configured dispatch unit — a fabricated `group` label spanning angles the
|
|
454
|
+
* table splits apart (or never groups at all) no longer passes.
|
|
455
|
+
* `resolveFanoutGroups` itself emits one-angle-per-unit singletons for
|
|
456
|
+
* `gates.fanout.mode: per-angle` (bypasses configured groups), so passing its
|
|
457
|
+
* output here rejects ANY shared identity in that mode — no separate mode flag
|
|
458
|
+
* needed. As of #1601 (ADR 0048) `gate:full` dispatches GROUPED (fullLabel is a
|
|
459
|
+
* no-op for dispatch shape), so a shared identity within an auto-chunked
|
|
460
|
+
* dispatch unit is honored exactly as for a configured group.
|
|
461
|
+
* Omitting `resolvedGroups` entirely keeps today's fully permissive behavior (any one
|
|
462
|
+
* shared non-null `group` value is accepted, unchecked against config) — both
|
|
463
|
+
* call sites already load config, so they should always supply it; this
|
|
464
|
+
* default only preserves callers (and old ledgers) that don't.
|
|
465
|
+
*
|
|
466
|
+
* @param {unknown} perAngle
|
|
467
|
+
* @param {{name: string, angles: string[]}[]|null} [resolvedGroups]
|
|
468
|
+
* @returns {string|null}
|
|
469
|
+
*/
|
|
470
|
+
export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
|
|
471
|
+
if (!Array.isArray(perAngle)) return null;
|
|
472
|
+
const configuredGroupOf = new Map();
|
|
473
|
+
for (const g of Array.isArray(resolvedGroups) ? resolvedGroups : []) {
|
|
474
|
+
for (const a of Array.isArray(g?.angles) ? g.angles : []) configuredGroupOf.set(a, g.name);
|
|
475
|
+
}
|
|
476
|
+
const freshAngles = new Set();
|
|
477
|
+
const anglesByIdentity = new Map();
|
|
478
|
+
const anonymousAngles = [];
|
|
479
|
+
for (const { entry, angle, group } of freshEntries(perAngle)) {
|
|
480
|
+
freshAngles.add(angle);
|
|
481
|
+
const identity = reviewerIdentity(entry);
|
|
482
|
+
if (identity) {
|
|
483
|
+
if (!anglesByIdentity.has(identity.id)) anglesByIdentity.set(identity.id, { angles: new Set(), label: identity.label, groups: new Set() });
|
|
484
|
+
const record = anglesByIdentity.get(identity.id);
|
|
485
|
+
record.angles.add(angle);
|
|
486
|
+
record.groups.add(group);
|
|
487
|
+
} else {
|
|
488
|
+
anonymousAngles.push(angle);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const freshAngleCount = freshAngles.size;
|
|
492
|
+
const distinctFreshReviewers = anglesByIdentity.size;
|
|
493
|
+
// Enforce the relation itself, not its cardinality shadow: a padded ledger
|
|
494
|
+
// (duplicate-angle entries) can satisfy distinctReviewers >= freshAngleCount
|
|
495
|
+
// while one identity still covers two fresh angles.
|
|
496
|
+
const details = [];
|
|
497
|
+
for (const [id, { angles, label, groups }] of anglesByIdentity) {
|
|
498
|
+
if (angles.size <= 1) continue;
|
|
499
|
+
// One shared, non-null `group` across every entry for this identity is
|
|
500
|
+
// the grouped-dispatch exception: a single reviewer legitimately covers
|
|
501
|
+
// its whole declared group. Differing or missing `group` values fall
|
|
502
|
+
// back to the one-reviewer-per-angle rule.
|
|
503
|
+
const sameGroup = groups.size === 1 && [...groups][0] !== null;
|
|
504
|
+
if (!sameGroup) {
|
|
505
|
+
details.push(`${label} "${id}" is recorded for fresh angles: ${[...angles].join(", ")}`);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
// resolvedGroups supplied: the claimed group is only honest when every
|
|
509
|
+
// angle it covers is a member of the SAME configured group — a claimed
|
|
510
|
+
// group spanning angles the table splits apart (or never groups) fails
|
|
511
|
+
// closed even though the audit record itself is internally consistent.
|
|
512
|
+
if (configuredGroupOf.size > 0) {
|
|
513
|
+
const configuredGroups = new Set([...angles].map((a) => configuredGroupOf.get(a) ?? null));
|
|
514
|
+
if (configuredGroups.size !== 1 || configuredGroups.has(null)) {
|
|
515
|
+
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`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (anonymousAngles.length > 0) {
|
|
520
|
+
details.push(`fresh angle(s) with no recorded reviewer identity: ${anonymousAngles.join(", ")}`);
|
|
521
|
+
}
|
|
522
|
+
if (details.length === 0) return null;
|
|
523
|
+
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`;
|
|
524
|
+
}
|
|
525
|
+
|
|
119
526
|
/**
|
|
120
527
|
* Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
|
|
121
528
|
* e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
|
|
@@ -125,15 +532,16 @@ export function provenanceConsistencyError(prov) {
|
|
|
125
532
|
* @param {string} angle
|
|
126
533
|
* @returns {string}
|
|
127
534
|
*/
|
|
128
|
-
function baseAngleName(angle) {
|
|
535
|
+
export function baseAngleName(angle) {
|
|
129
536
|
return angle.replace(/-delta-at-.+$/, "");
|
|
130
537
|
}
|
|
131
538
|
|
|
132
539
|
/**
|
|
133
540
|
* Validate a recorded fan-out angle list against a gate's configured angle
|
|
134
541
|
* contract: every mandatory angle must be represented, and — when a pool is
|
|
135
|
-
* supplied — every recorded angle must be a member of it
|
|
136
|
-
*
|
|
542
|
+
* supplied — every recorded angle must be a member of it or of
|
|
543
|
+
* {@link FANIN_SYNTHETIC_ANGLES} (delta-suffixed angles count toward their
|
|
544
|
+
* {@link baseAngleName}). Pure; shared by the write
|
|
137
545
|
* path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
|
|
138
546
|
* `--findings-json` per-angle results) and the merge-evidence read path
|
|
139
547
|
* (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
|
|
@@ -142,7 +550,7 @@ function baseAngleName(angle) {
|
|
|
142
550
|
* @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
|
|
143
551
|
* @param {object} [gateAngleContract]
|
|
144
552
|
* @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
|
|
553
|
+
* @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
554
|
* @returns {{ missingMandatory: string[], foreignAngles: string[] }}
|
|
147
555
|
*/
|
|
148
556
|
export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
|
|
@@ -155,18 +563,40 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
|
|
|
155
563
|
const missingMandatory = mandatoryAngles.filter((a) => !recordedBases.has(a));
|
|
156
564
|
let foreignAngles = [];
|
|
157
565
|
if (Array.isArray(pool) && pool.length > 0) {
|
|
158
|
-
const poolSet = new Set(pool);
|
|
566
|
+
const poolSet = new Set([...pool, ...FANIN_SYNTHETIC_ANGLES]);
|
|
159
567
|
foreignAngles = [...new Set(recorded.filter((a) => !poolSet.has(baseAngleName(a))))];
|
|
160
568
|
}
|
|
161
569
|
return { missingMandatory, foreignAngles };
|
|
162
570
|
}
|
|
163
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Angles the fan-in itself mandates and may synthesize (consolidate-fanin's
|
|
574
|
+
* `--pr-checklist-matrix clean` upsert) without them appearing in any gate's
|
|
575
|
+
* configured `angles` pool. Always legal in the foreign-angle check above —
|
|
576
|
+
* requiring every consumer repo to also list them per-gate would make the two
|
|
577
|
+
* tools contradict the shared contract they implement.
|
|
578
|
+
*/
|
|
579
|
+
export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist-matrix"]);
|
|
580
|
+
|
|
164
581
|
/**
|
|
165
582
|
* Default cap on parallel fan-out reviewers when a caller does not supply one.
|
|
166
583
|
* Mirrors the config default (gates.maxFanoutReviewers).
|
|
167
584
|
*/
|
|
168
585
|
export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
|
|
169
586
|
|
|
587
|
+
// Every sanctioned angle name is a short, hand-authored slug (e.g.
|
|
588
|
+
// "contradiction-lens", "pr-checklist-matrix"); nothing legitimate ever
|
|
589
|
+
// approaches this length. Bounding it here, at the trust boundary this
|
|
590
|
+
// function already owns, fails a pathological artifact closed as malformed —
|
|
591
|
+
// the same place every other angle-result defect is caught — instead of
|
|
592
|
+
// leaving an unbounded reviewer-supplied string to reach the render path,
|
|
593
|
+
// where consolidate-fanin.mjs's per-angle budget marking cannot compress it.
|
|
594
|
+
// This is a malformed-artifact guard, not a comment-budget guarantee: several
|
|
595
|
+
// angles each right at this cap can still exceed the render budget on their
|
|
596
|
+
// headers alone and force the withheld tier — that outcome is the render
|
|
597
|
+
// budget's degradation ladder doing its job, not something this cap prevents.
|
|
598
|
+
const MAX_ANGLE_NAME_LENGTH = 200;
|
|
599
|
+
|
|
170
600
|
/**
|
|
171
601
|
* Validate a single per-angle review result. Returns an error string when the
|
|
172
602
|
* result is malformed, or null when it is well-formed.
|
|
@@ -182,6 +612,9 @@ function validateAngleResult(result) {
|
|
|
182
612
|
if (typeof r.angle !== "string" || r.angle.trim().length === 0) {
|
|
183
613
|
return "angle result is missing a non-empty 'angle'";
|
|
184
614
|
}
|
|
615
|
+
if (r.angle.trim().length > MAX_ANGLE_NAME_LENGTH) {
|
|
616
|
+
return `angle result's 'angle' exceeds ${MAX_ANGLE_NAME_LENGTH} chars`;
|
|
617
|
+
}
|
|
185
618
|
if (typeof r.verdict !== "string" || !VALID_VERDICTS.has(r.verdict)) {
|
|
186
619
|
return `angle '${r.angle}' has invalid verdict (expected clean|findings_present)`;
|
|
187
620
|
}
|
|
@@ -193,8 +626,8 @@ function validateAngleResult(result) {
|
|
|
193
626
|
return `angle '${r.angle}' has a non-object finding`;
|
|
194
627
|
}
|
|
195
628
|
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
|
|
629
|
+
if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(normalizeSeverity(finding.severity))) {
|
|
630
|
+
return `angle '${r.angle}' has a finding with invalid severity (expected ${SEVERITY_ORDER.join("|")})`;
|
|
198
631
|
}
|
|
199
632
|
if (typeof finding.summary !== "string" || finding.summary.trim().length === 0) {
|
|
200
633
|
return `angle '${r.angle}' has a finding without a summary`;
|
|
@@ -224,7 +657,7 @@ function validateAngleResult(result) {
|
|
|
224
657
|
*
|
|
225
658
|
* @param {object} input
|
|
226
659
|
* @param {Array<unknown>} input.angleResults — per-angle review artifacts
|
|
227
|
-
* @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["
|
|
660
|
+
* @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["high"])
|
|
228
661
|
* @returns {{
|
|
229
662
|
* verdict: "clean"|"findings_present"|"blocked",
|
|
230
663
|
* findings: Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>,
|
|
@@ -234,10 +667,14 @@ function validateAngleResult(result) {
|
|
|
234
667
|
*/
|
|
235
668
|
export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities } = {}) {
|
|
236
669
|
const results = Array.isArray(angleResults) ? angleResults : [];
|
|
670
|
+
// Config values normalize through the same alias map as finding severities,
|
|
671
|
+
// so a legacy config spelling ("must-fix", "defer", …) still blocks the
|
|
672
|
+
// renamed tier.
|
|
237
673
|
const blocking = new Set(
|
|
238
|
-
Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
|
|
674
|
+
(Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
|
|
239
675
|
? blockCleanOnFindingSeverities
|
|
240
|
-
: ["
|
|
676
|
+
: ["high"]
|
|
677
|
+
).map((s) => normalizeSeverity(s)),
|
|
241
678
|
);
|
|
242
679
|
|
|
243
680
|
const malformed = [];
|
|
@@ -246,7 +683,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
246
683
|
if (err) malformed.push({ index, reason: err });
|
|
247
684
|
});
|
|
248
685
|
|
|
249
|
-
const bySeverity =
|
|
686
|
+
const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
|
|
250
687
|
/** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
|
|
251
688
|
const findings = [];
|
|
252
689
|
let blockingCount = 0;
|
|
@@ -255,16 +692,17 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
255
692
|
for (const r of results) {
|
|
256
693
|
const angle = r.angle.trim();
|
|
257
694
|
for (const f of r.findings) {
|
|
258
|
-
const
|
|
695
|
+
const severity = /** @type {string} */ (normalizeSeverity(f.severity));
|
|
696
|
+
const isBlocking = blocking.has(severity);
|
|
259
697
|
if (isBlocking) blockingCount += 1;
|
|
260
|
-
bySeverity[
|
|
698
|
+
bySeverity[severity] += 1;
|
|
261
699
|
const entry = {
|
|
262
|
-
severity
|
|
700
|
+
severity,
|
|
263
701
|
angle,
|
|
264
702
|
summary: String(f.summary).trim(),
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
disposition:
|
|
703
|
+
// See deriveDisposition's own doc for the full rule; the fix cycle
|
|
704
|
+
// / operator can override the disposition afterward.
|
|
705
|
+
disposition: deriveDisposition(severity, { isBlocking, locatable: hasLocatableShape(f) }),
|
|
268
706
|
};
|
|
269
707
|
if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
|
|
270
708
|
if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
|
|
@@ -301,10 +739,10 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
301
739
|
/**
|
|
302
740
|
* Map consolidated findings into the `--findings` JSON shape consumed by
|
|
303
741
|
* scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
|
|
304
|
-
* disposition, optional files). Pure.
|
|
742
|
+
* disposition, optional files, optional line). Pure.
|
|
305
743
|
*
|
|
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[]}>}
|
|
744
|
+
* @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string, recommendation?: string, line?: number}>} findings
|
|
745
|
+
* @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[], recommendation?: string, line?: number}>}
|
|
308
746
|
*/
|
|
309
747
|
export function toFindingsLogShape(findings) {
|
|
310
748
|
const list = Array.isArray(findings) ? findings : [];
|
|
@@ -317,12 +755,18 @@ export function toFindingsLogShape(findings) {
|
|
|
317
755
|
if (typeof f.disposition === "string" && f.disposition.trim().length > 0) {
|
|
318
756
|
entry.disposition = f.disposition.trim();
|
|
319
757
|
}
|
|
758
|
+
if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
|
|
759
|
+
entry.recommendation = f.recommendation.trim();
|
|
760
|
+
}
|
|
320
761
|
if (typeof f.file === "string" && f.file.trim().length > 0) {
|
|
321
762
|
entry.files = [f.file.trim()];
|
|
322
763
|
} else if (Array.isArray(f.files)) {
|
|
323
764
|
const files = f.files.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
|
|
324
765
|
if (files.length > 0) entry.files = files;
|
|
325
766
|
}
|
|
767
|
+
if (Number.isInteger(f.line) && f.line > 0) {
|
|
768
|
+
entry.line = f.line;
|
|
769
|
+
}
|
|
326
770
|
return entry;
|
|
327
771
|
});
|
|
328
772
|
}
|
|
@@ -330,6 +774,12 @@ export function toFindingsLogShape(findings) {
|
|
|
330
774
|
/**
|
|
331
775
|
* Plan how a resolved angle set fans out across the reviewer cap. Pure.
|
|
332
776
|
*
|
|
777
|
+
* SUPERSEDED by `scheduleFanoutWaves` (#1601, ADR 0048): the gate fan-out
|
|
778
|
+
* conductor now dispatches wave-by-wave at most `gates.fanout.maxConcurrent`
|
|
779
|
+
* (M) dispatch units per wave, using the wave plan emitted by
|
|
780
|
+
* `write-gate-context.mjs`. This helper is kept only for back-compat (zero
|
|
781
|
+
* non-test callers) and no longer participates in the dispatch path.
|
|
782
|
+
*
|
|
333
783
|
* When `angles.length <= maxReviewers`, all reviewers run in a single parallel
|
|
334
784
|
* batch (no degradation). When it exceeds the cap, the overflow is split into
|
|
335
785
|
* sequential batches of at most `maxReviewers` each, and `degraded` is true so
|