@dev-loops/core 1.0.0-rc.3 → 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 +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 +519 -43
- 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 +28 -11
- 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 +479 -29
- package/src/loop/handoff-envelope.mjs +37 -16
- 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/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/config/config.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { normalizeSeverity } from "../loop/gate-fanin.mjs";
|
|
2
3
|
import { execFileSync } from "node:child_process";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { parse as parseYaml } from "yaml";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { z } from "zod";
|
|
8
|
+
import { classifyFile } from "../analysis/diff-analyzer.mjs";
|
|
9
|
+
import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
|
|
7
10
|
|
|
8
11
|
// ============================================================================
|
|
9
12
|
// Sub-schemas
|
|
@@ -118,12 +121,24 @@ const RefinementConfig = z.strictObject({
|
|
|
118
121
|
roles: z.array(z.string().trim().min(1)).describe("Review lenses the refinement fan-out dispatches.").optional(),
|
|
119
122
|
});
|
|
120
123
|
|
|
124
|
+
// Per-angle surface scope: how much of the gate-context bundle an angle
|
|
125
|
+
// actually needs. "full" (default) is today's omniscient briefing;
|
|
126
|
+
// "changed-files" drops the adjacent-code bundle AND the invariant prefix's
|
|
127
|
+
// "Changed files + adjacent-code summary" section (the diff itself still
|
|
128
|
+
// carries every changed file); "docs-only" narrows further to doc-file
|
|
129
|
+
// hunks only. Resolution (resolveGateAngleScope) fails open to "full" for an
|
|
130
|
+
// unknown/missing value — a narrow scope is an opt-in cost saving, never a
|
|
131
|
+
// silently-enforced information cut.
|
|
132
|
+
export const GATE_ANGLE_SCOPES = Object.freeze(["full", "changed-files", "docs-only"]);
|
|
133
|
+
|
|
121
134
|
// One review angle: a bare string is sugar for `{ name }`. An object may also
|
|
122
135
|
// set `mandatory` (always runs, survives dynamic pruning — was
|
|
123
136
|
// gates.<gate>.mandatoryAngles), `enabled: false` (drops it from the resolved
|
|
124
|
-
// list — was gates.<gate>.excludeAngles, D3),
|
|
137
|
+
// list — was gates.<gate>.excludeAngles, D3), `persona`/`prompt`/`model`/
|
|
125
138
|
// `tier` (was the top-level `personas` map + angle-keyed
|
|
126
|
-
// `models.roles`/`models.roleTiers`, D4: model > tier > built-in precedence)
|
|
139
|
+
// `models.roles`/`models.roleTiers`, D4: model > tier > built-in precedence),
|
|
140
|
+
// and `scope` (AC3: the surface briefing variant this angle needs — see
|
|
141
|
+
// GATE_ANGLE_SCOPES).
|
|
127
142
|
// This is the ONE identity for a gate-review angle (was five separate places
|
|
128
143
|
// — see the config-schema RFC). `mergeConfigLayers` merges these arrays BY
|
|
129
144
|
// `name` across config layers (D3), so a later layer can add or disable a
|
|
@@ -144,11 +159,51 @@ const GateAngleEntry = z.preprocess(
|
|
|
144
159
|
prompt: z.string().min(1).optional().describe("Short focused instruction for the reviewer agent — what to look for and how to judge this angle."),
|
|
145
160
|
model: z.string().trim().min(1).optional().describe("Concrete model override for this angle (highest precedence)."),
|
|
146
161
|
tier: z.string().trim().min(1).optional().describe("Model tier alias for this angle (used when `model` is absent)."),
|
|
162
|
+
scope: z.enum(GATE_ANGLE_SCOPES).optional().describe("Surface scope this angle needs: full (default), changed-files (diff without the adjacent-code bundle or its changed-files/adjacent-file summary section), or docs-only (doc-file hunks only). Unknown/omitted resolves to full."),
|
|
147
163
|
}),
|
|
148
164
|
);
|
|
149
165
|
|
|
166
|
+
// Diff-class kinds a tier's `match` can name — exactly classifyFile()'s
|
|
167
|
+
// output range (../analysis/diff-analyzer.mjs), so a tier config can never
|
|
168
|
+
// name a kind the classifier could not produce.
|
|
169
|
+
const GateTierMatchKind = z.enum(["code", "docs", "config", "test", "ci", "unknown"]);
|
|
170
|
+
|
|
171
|
+
// A tier's match conditions: EVERY changed file's kind must be in `kinds`
|
|
172
|
+
// (when set) AND the change must stay within `maxFiles`/`maxLines` (when
|
|
173
|
+
// set). At least one condition is required — a bare `{}` would match every
|
|
174
|
+
// diff unconditionally, which is never the intent of an explicit tier entry.
|
|
175
|
+
const GateTierMatch = z
|
|
176
|
+
.strictObject({
|
|
177
|
+
kinds: z.array(GateTierMatchKind).min(1).describe("Changed-file kinds this tier matches; every changed file's classifyFile() kind must be in this set.").optional(),
|
|
178
|
+
maxFiles: z.number().int().min(1).describe("Match only when the change touches at most this many files.").optional(),
|
|
179
|
+
maxLines: z.number().int().min(1).describe("Match only when the change stays within this many changed lines.").optional(),
|
|
180
|
+
})
|
|
181
|
+
.superRefine((match, ctx) => {
|
|
182
|
+
if (match.kinds === undefined && match.maxFiles === undefined && match.maxLines === undefined) {
|
|
183
|
+
ctx.addIssue({
|
|
184
|
+
code: z.ZodIssueCode.custom,
|
|
185
|
+
message: "match must set at least one of kinds, maxFiles, maxLines",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// One diff-class angle tier: a fixed angle set applied instead of dynamic
|
|
191
|
+
// subtractive/additive reduction when `match` holds. See resolveGateTier.
|
|
192
|
+
const GateTier = z.strictObject({
|
|
193
|
+
name: z.string().trim().min(1).describe("Tier name; surfaces as the tier:<name> resolution reason."),
|
|
194
|
+
match: GateTierMatch.describe("Diff-class conditions that select this tier."),
|
|
195
|
+
angles: z.array(z.string().trim().min(1)).min(1).describe("Angle set this tier resolves to when matched; unioned with the gate's mandatory angles."),
|
|
196
|
+
});
|
|
197
|
+
|
|
150
198
|
const GateDynamicConfig = z.strictObject({
|
|
151
|
-
|
|
199
|
+
// Diff-driven dynamic angle selection is ON by default (#1579): a fresh
|
|
200
|
+
// install narrows the angle pool to what the diff-classifier recommends.
|
|
201
|
+
// mandatory:true angles stay a hard always-run floor; fallbackToAll fires
|
|
202
|
+
// when classification is ambiguous, degrading to the full static pool. Set
|
|
203
|
+
// subtractive:false to restore the full static angle pool (the gate:full label
|
|
204
|
+
// only forces per-angle dispatch of the still-pruned set, not the full pool —
|
|
205
|
+
// combine both for the original full static fan-out).
|
|
206
|
+
subtractive: z.boolean().default(true).describe("Enable diff-driven dynamic angle PRUNING for this gate (ON by default; set false to restore the full static angle pool). Was gates.<gate>.dynamicAngles."),
|
|
152
207
|
// Additive counterpart to the subtractive path (#1048): when true, the
|
|
153
208
|
// context-builder may also ADD catalog angles — from resolveAnglePool()
|
|
154
209
|
// (gates.anglePool, or else the union of the persona registry and this
|
|
@@ -169,13 +224,96 @@ const GateConfig = z.strictObject({
|
|
|
169
224
|
dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
|
|
170
225
|
required: z.boolean().default(true).describe("Whether this gate must run."),
|
|
171
226
|
requireCi: z.boolean().default(true).describe("Per-gate CI prerequisite (default true): the gate requires green CI on the current head; false opts this gate out of the CI precondition entirely, including a real failure."),
|
|
227
|
+
// Defect severities only (high/medium/low, plus their pre-rename spellings)
|
|
228
|
+
// — "question"/"nit" are non-defect categories that never block a clean
|
|
229
|
+
// verdict by severity: a question's own answered/never-deferred contract
|
|
230
|
+
// and a nit's immediate-defer disposition already decide its fate, so
|
|
231
|
+
// admitting either here would let a config block on a severity the
|
|
232
|
+
// disposition pass simultaneously auto-resolves.
|
|
172
233
|
blockCleanOnFindingSeverities: z
|
|
173
|
-
.array(z.enum(["must-fix", "worth-fixing-now", "defer"]))
|
|
234
|
+
.array(z.enum(["high", "medium", "low", "must-fix", "worth-fixing-now", "nice-to-have", "defer"]))
|
|
174
235
|
.min(1)
|
|
175
|
-
.default(["
|
|
176
|
-
.describe("
|
|
236
|
+
.default(["high"])
|
|
237
|
+
.describe("Defect finding severities that block a clean gate verdict (high/medium/low only — \"question\"/\"nit\" are non-defect categories and never block by severity). \"must-fix\" is the deprecated legacy spelling of \"high\", \"worth-fixing-now\" of \"medium\", and \"nice-to-have\"/\"defer\" of \"low\"; consumers normalize them."),
|
|
238
|
+
// Per-gate medium fix window (#1581): an open medium finding stays in the
|
|
239
|
+
// in-gate fix loop through this many rounds of THIS gate's chain and is
|
|
240
|
+
// deferred (replied-to + resolved) from the next round on. Defaults to 3
|
|
241
|
+
// (the built-in MEDIUM_FIX_WINDOW fallback in
|
|
242
|
+
// scripts/github/_gate-finding-surface.mjs). high is exempt: it never
|
|
243
|
+
// defers and forces per-gate continuation until the gate round cap escalates.
|
|
244
|
+
// No schema-level `.default()`: resolveGateConfig applies the built-in
|
|
245
|
+
// fallback (3) only after checking BOTH this key and the deprecated
|
|
246
|
+
// `worthFixingNowFixWindow` alias. A schema-level default would fill this
|
|
247
|
+
// key on every config LAYER independently (each layer is parsed through
|
|
248
|
+
// this schema on its own before merging), permanently shadowing a layer
|
|
249
|
+
// that sets only the deprecated alias.
|
|
250
|
+
mediumFixWindow: z.number().int().nonnegative().optional().describe("Per-gate medium fix window: an open medium finding stays in the in-gate fix loop through this many rounds of this gate's chain before deferral. high is exempt (never defers). Default 3."),
|
|
251
|
+
// Deprecated alias for `mediumFixWindow` (pre-rename key); accepted on read
|
|
252
|
+
// and normalized in resolveGateConfig so an unmigrated config still behaves
|
|
253
|
+
// identically. `mediumFixWindow` wins when both are set.
|
|
254
|
+
worthFixingNowFixWindow: z.number().int().nonnegative().optional().describe("Deprecated alias for mediumFixWindow (pre-rename key name); mediumFixWindow wins when both are set."),
|
|
255
|
+
// Ordered, first-match-wins diff-class angle tiers (see resolveGateTier).
|
|
256
|
+
// Absent/empty = tiers never apply, so a gate that never sets this key keeps
|
|
257
|
+
// today's dynamic-subtractive/additive/full-pool resolution unchanged.
|
|
258
|
+
tiers: z.array(GateTier).min(1).describe("Ordered, first-match-wins diff-class angle tiers for this gate. When the first-matching tier's angle set is inside the gate's angle pool, it replaces dynamic angle reduction for that diff class.").optional(),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// One named group of angles dispatched together onto a single reviewer under
|
|
262
|
+
// grouped fan-out (AC6). `name` is recorded as the shared reviewer's
|
|
263
|
+
// provenance `group` (see resolveFanoutGroups / fanoutReviewerPairingError).
|
|
264
|
+
const FanoutGroup = z.strictObject({
|
|
265
|
+
name: z.string().trim().min(1).describe("Group name; recorded as the shared reviewer's provenance `group` when this group dispatches."),
|
|
266
|
+
angles: z.array(z.string().trim().min(1)).min(1).describe("Angle names batched onto one reviewer when this group resolves."),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Angle-dispatch fan-out policy (AC6 + #1601 two-knob dispatch bounds). The
|
|
270
|
+
// grouped default batches related angles from a static table onto one
|
|
271
|
+
// reviewer per group, cutting the fixed per-reviewer briefing cost when
|
|
272
|
+
// several angles read the same surface; `per-angle` keeps the original
|
|
273
|
+
// one-reviewer-per-angle fan-out (bypasses configured groups). `gate:full` no
|
|
274
|
+
// longer restores per-angle dispatch (ADR 0047 superseded by 0048): it forces
|
|
275
|
+
// the full angle set upstream (resolveGateTier) and dispatches GROUPED here.
|
|
276
|
+
// Two orthogonal bounds (issue #1601):
|
|
277
|
+
// maxAnglesPerGroup (N, default 3, min 1) — after configured-groups
|
|
278
|
+
// matching, leftover ungrouped angles auto-chunk into dispatch units of
|
|
279
|
+
// ≤N instead of singletons. mode: per-angle bypasses the table entirely
|
|
280
|
+
// maxConcurrent (M, default 4, min 1) — the conductor dispatches at most M
|
|
281
|
+
// dispatch units per wave (scheduleFanoutWaves via scheduleParallelWaves).
|
|
282
|
+
// An angle resolved for a round but not named in any configured group joins
|
|
283
|
+
// the auto-chunked leftover pool — `groups` need only list the angles worth
|
|
284
|
+
// batching explicitly.
|
|
285
|
+
const FanoutConfig = z.strictObject({
|
|
286
|
+
mode: z.enum(["grouped", "per-angle"]).default("grouped").describe("Angle dispatch mode: grouped batches related angles onto one reviewer each (default); per-angle bypasses the configured-groups table and emits one singleton unit per angle (the original full-scrutiny shape). per-angle is equivalent to maxAnglesPerGroup: 1 in dispatch unit size ONLY when no configured multi-angle group matches a resolved angle; otherwise per-angle bypasses configured groups while maxAnglesPerGroup: 1 honors them (matched first, never split)."),
|
|
287
|
+
groups: z.array(FanoutGroup).optional().describe("Static named angle groups consulted in grouped mode. An angle absent from every group joins the auto-chunked leftover pool (chunked into units of ≤maxAnglesPerGroup)."),
|
|
288
|
+
maxAnglesPerGroup: z.number().int().min(1).default(3).describe("Max angles per auto-chunked dispatch unit for leftover ungrouped angles (default 3, min 1). Configured groups are matched first and never split by this knob; mode: per-angle bypasses the table entirely (one singleton per angle)."),
|
|
289
|
+
maxConcurrent: z.number().int().min(1).default(4).describe("Max dispatch units (groups) the conductor dispatches concurrently per wave (default 4, min 1). The wave plan is emitted by write-gate-context.mjs via scheduleFanoutWaves (scheduleParallelWaves)."),
|
|
177
290
|
});
|
|
178
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Two `gates.fanout.groups` entries sharing one `name` would resolve to two
|
|
294
|
+
* dispatch units with the same reviewer-sentinel scope (resolveFanoutGroups
|
|
295
|
+
* keys the scope by group name) — reject at config-validation time rather
|
|
296
|
+
* than let it degrade silently at dispatch time. Applied via `.superRefine`
|
|
297
|
+
* where `FanoutConfig` is used (zod v4 rejects `.partial()` on a schema that
|
|
298
|
+
* already carries a refinement), not on `FanoutConfig` itself.
|
|
299
|
+
* @param {{ groups?: Array<{ name: string }> }} val
|
|
300
|
+
* @param {import("zod").RefinementCtx} ctx
|
|
301
|
+
*/
|
|
302
|
+
function rejectDuplicateFanoutGroupNames(val, ctx) {
|
|
303
|
+
if (!Array.isArray(val.groups)) return;
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
for (const [index, group] of val.groups.entries()) {
|
|
306
|
+
if (seen.has(group.name)) {
|
|
307
|
+
ctx.addIssue({
|
|
308
|
+
code: z.ZodIssueCode.custom,
|
|
309
|
+
path: ["groups", index, "name"],
|
|
310
|
+
message: `duplicate gates.fanout.groups name "${group.name}"`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
seen.add(group.name);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
179
317
|
const GatesConfig = z.strictObject({
|
|
180
318
|
draft: GateConfig.optional(),
|
|
181
319
|
// `requireCi` is honored on both gates: default true keeps CI a precondition,
|
|
@@ -206,16 +344,22 @@ const GatesConfig = z.strictObject({
|
|
|
206
344
|
// is active. Default false (opt-in): closing this loophole is additive and
|
|
207
345
|
// does not change behavior for existing ledgers that carry no provenance.
|
|
208
346
|
requireFanoutProvenance: z.boolean().default(false),
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
maxFanoutReviewers
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
//
|
|
347
|
+
// SUPERSEDED by gates.fanout.maxConcurrent (#1601, ADR 0048): the conductor
|
|
348
|
+
// now dispatches wave-by-wave at most M dispatch units per wave via
|
|
349
|
+
// scheduleFanoutWaves (the wave plan emitted by write-gate-context.mjs), so
|
|
350
|
+
// maxFanoutReviewers no longer governs fan-out dispatch. Kept for back-compat
|
|
351
|
+
// (zero non-test callers in the dispatch path); a consumer setting it gets
|
|
352
|
+
// no dispatch effect. See gates.fanout.maxConcurrent for the active cap.
|
|
353
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).default(8).describe("SUPERSEDED by gates.fanout.maxConcurrent (#1601, ADR 0048): no longer governs fan-out dispatch — the conductor dispatches wave-by-wave at most gates.fanout.maxConcurrent (M) dispatch units per wave via scheduleFanoutWaves (the wave plan emitted by write-gate-context.mjs). Kept for back-compat; setting it has no dispatch effect."),
|
|
354
|
+
// #1462 GATE-EXEC-PRIME is MANDATORY (not a flag): every gate fan-out primes the
|
|
355
|
+
// byte-identical briefing prefix before the reviewers read it — see
|
|
217
356
|
// skills/docs/gate-review-sub-loop-contract.md.
|
|
218
|
-
|
|
357
|
+
// Post the consolidated gate fan-out findings as a SECOND visible,
|
|
358
|
+
// marker-tagged PR comment. Default false (opt-in): the round's verdict
|
|
359
|
+
// review already carries every finding (GATE-COMMENT-SINGLE-SURFACE), so this
|
|
360
|
+
// comment renders each finding's text a second time. The disposition ledger
|
|
361
|
+
// is written regardless. See skills/docs/gate-review-sub-loop-contract.md.
|
|
362
|
+
postFindingsComments: z.boolean().default(false),
|
|
219
363
|
// Explicit global lens catalog override for additive angle selection
|
|
220
364
|
// (gates.<gate>.dynamic.additive, #1048). GLOBAL, not per-gate (D1): one
|
|
221
365
|
// repo-wide catalog for additive selection. When absent, resolveAnglePool()
|
|
@@ -229,6 +373,9 @@ const GatesConfig = z.strictObject({
|
|
|
229
373
|
// (reject); set false to warn instead of fail. See resolveRejectForeignAngles
|
|
230
374
|
// / skills/docs/gate-review-sub-loop-contract.md.
|
|
231
375
|
rejectForeignAngles: z.boolean().default(true),
|
|
376
|
+
// Grouped vs per-angle fan-out dispatch policy + static grouping table
|
|
377
|
+
// (AC6). GLOBAL, not per-gate — see resolveFanoutGroups.
|
|
378
|
+
fanout: FanoutConfig.superRefine(rejectDuplicateFanoutGroupNames).optional(),
|
|
232
379
|
});
|
|
233
380
|
|
|
234
381
|
const AutonomyConfig = z.strictObject({
|
|
@@ -586,10 +733,11 @@ const FileGatesConfig = z.strictObject({
|
|
|
586
733
|
spike: GateConfig.partial().describe("Relaxed spike gate profile; applies only to spike-mode work.").optional(),
|
|
587
734
|
requireFanoutEvidence: z.boolean().describe("Require fan-out/fan-in review evidence on gate verdicts; inline single-agent verdicts are rejected except under the strict light-mode exception (under-threshold scope, no gate:full label, recorded inline reason).").optional(),
|
|
588
735
|
requireFanoutProvenance: z.boolean().describe("Additionally require recorded, internally-consistent fan-out provenance (distinct reviewer count + per-angle dispatch).").optional(),
|
|
589
|
-
maxFanoutReviewers: z.number().int().min(1).max(64).describe("
|
|
590
|
-
postFindingsComments: z.boolean().describe("
|
|
736
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).describe("SUPERSEDED by gates.fanout.maxConcurrent (#1601, ADR 0048): no longer governs fan-out dispatch — the conductor dispatches wave-by-wave at most gates.fanout.maxConcurrent (M) dispatch units per wave via scheduleFanoutWaves (the wave plan emitted by write-gate-context.mjs). Kept for back-compat; setting it has no dispatch effect.").optional(),
|
|
737
|
+
postFindingsComments: z.boolean().describe("Also post consolidated gate findings as a second marker-tagged PR comment, duplicating the verdict review's own findings (default false).").optional(),
|
|
591
738
|
anglePool: z.array(z.string().trim().min(1)).describe("Explicit global lens catalog for additive angle selection (global, not per-gate).").optional(),
|
|
592
739
|
rejectForeignAngles: z.boolean().describe("Reject fan-out provenance naming angles outside the gate's configured pool (default true).").optional(),
|
|
740
|
+
fanout: FanoutConfig.partial().superRefine(rejectDuplicateFanoutGroupNames).describe("Grouped vs per-angle fan-out dispatch policy + static grouping table (global, not per-gate).").optional(),
|
|
593
741
|
});
|
|
594
742
|
|
|
595
743
|
// ============================================================================
|
|
@@ -759,10 +907,13 @@ const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
|
|
|
759
907
|
/**
|
|
760
908
|
* Normalize one raw `gates.<gate>.angles[]` entry (string sugar or object,
|
|
761
909
|
* possibly hand-built and never zod-validated — e.g. a test config object) to
|
|
762
|
-
* `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier? }`.
|
|
763
|
-
* null for a malformed/empty entry so callers can filter it out.
|
|
910
|
+
* `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope? }`.
|
|
911
|
+
* Returns null for a malformed/empty entry so callers can filter it out. An
|
|
912
|
+
* invalid `scope` (not one of GATE_ANGLE_SCOPES) is dropped rather than
|
|
913
|
+
* kept verbatim — resolveGateAngleScope's fail-open default only ever needs
|
|
914
|
+
* to handle an ABSENT field, never a foreign value.
|
|
764
915
|
* @param {unknown} a
|
|
765
|
-
* @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string}|null}
|
|
916
|
+
* @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}|null}
|
|
766
917
|
*/
|
|
767
918
|
function normalizeAngleEntry(a) {
|
|
768
919
|
if (typeof a === "string") {
|
|
@@ -779,6 +930,7 @@ function normalizeAngleEntry(a) {
|
|
|
779
930
|
if (typeof a.prompt === "string" && a.prompt.length > 0) entry.prompt = a.prompt;
|
|
780
931
|
if (typeof a.model === "string" && a.model.trim().length > 0) entry.model = a.model.trim();
|
|
781
932
|
if (typeof a.tier === "string" && a.tier.trim().length > 0) entry.tier = a.tier.trim();
|
|
933
|
+
if (typeof a.scope === "string" && GATE_ANGLE_SCOPES.includes(a.scope.trim())) entry.scope = a.scope.trim();
|
|
782
934
|
return entry;
|
|
783
935
|
}
|
|
784
936
|
return null;
|
|
@@ -788,7 +940,7 @@ function normalizeAngleEntry(a) {
|
|
|
788
940
|
* Normalize a raw `gates.<gate>.angles` array into full entry objects,
|
|
789
941
|
* dropping malformed entries.
|
|
790
942
|
* @param {unknown} raw
|
|
791
|
-
* @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string}>}
|
|
943
|
+
* @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}>}
|
|
792
944
|
*/
|
|
793
945
|
function normalizeAngleEntries(raw) {
|
|
794
946
|
if (!Array.isArray(raw)) return [];
|
|
@@ -834,6 +986,27 @@ function findAngleEntry(config, name) {
|
|
|
834
986
|
return null;
|
|
835
987
|
}
|
|
836
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Resolve a gate angle's declared surface scope (AC3, #1572): "full"
|
|
991
|
+
* (default), "changed-files", or "docs-only" — see GATE_ANGLE_SCOPES. Unlike
|
|
992
|
+
* {@link findAngleEntry} (which searches every gate in a fixed priority
|
|
993
|
+
* order because persona/prompt resolution has no gate context), this looks up
|
|
994
|
+
* the entry within the ONE named gate — an angle's scope is meaningful only
|
|
995
|
+
* for the specific gate pass building its briefing. Fails open to "full" for
|
|
996
|
+
* an angle with no configured entry, a disabled entry, or an
|
|
997
|
+
* unknown/malformed `scope` value: a narrow scope is an opt-in cost saving,
|
|
998
|
+
* never a silently-enforced information cut.
|
|
999
|
+
* @param {DevLoopConfig} config
|
|
1000
|
+
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1001
|
+
* @param {string} name
|
|
1002
|
+
* @returns {"full"|"changed-files"|"docs-only"}
|
|
1003
|
+
*/
|
|
1004
|
+
export function resolveGateAngleScope(config, gate, name) {
|
|
1005
|
+
const entries = normalizeAngleEntries(config?.gates?.[gate]?.angles);
|
|
1006
|
+
const found = entries.find((e) => e.name === name && e.enabled !== false);
|
|
1007
|
+
return found?.scope ?? "full";
|
|
1008
|
+
}
|
|
1009
|
+
|
|
837
1010
|
/**
|
|
838
1011
|
* Resolve a tier alias to its per-harness concrete model, or `null`
|
|
839
1012
|
* (`inherit`/unmapped/absent → no override). Deep-merges the alias mapping so
|
|
@@ -1233,6 +1406,20 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1233
1406
|
data = { ...data, strategy: "tracker-first" };
|
|
1234
1407
|
}
|
|
1235
1408
|
|
|
1409
|
+
// Removed `gates.primeSharedPrefix` (#1462): GATE-EXEC-PRIME cache priming is
|
|
1410
|
+
// now mandatory, not a knob. The schema is strictObject, so a stale key would
|
|
1411
|
+
// otherwise drop the WHOLE gates layer as invalid. Strip it before validation
|
|
1412
|
+
// with a deprecation warning — old configs keep loading; priming happens
|
|
1413
|
+
// unconditionally regardless of the removed value.
|
|
1414
|
+
if (data?.gates && Object.prototype.hasOwnProperty.call(data.gates, "primeSharedPrefix")) {
|
|
1415
|
+
warnings.push(
|
|
1416
|
+
`gates.primeSharedPrefix is removed (#1462): cache priming is now mandatory, not configurable. ` +
|
|
1417
|
+
`Remove it from ${path.basename(filePath)}; the key is ignored.`
|
|
1418
|
+
);
|
|
1419
|
+
const { primeSharedPrefix: _removed, ...gatesRest } = data.gates;
|
|
1420
|
+
data = { ...data, gates: gatesRest };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1236
1423
|
// Validate the file's structure before merging. Pre-existing behavior
|
|
1237
1424
|
// (unrelated to the #1404 angle-entry redesign): a schema violation ANYWHERE
|
|
1238
1425
|
// in this layer's file drops the WHOLE layer (errors is populated, `merged`
|
|
@@ -1608,7 +1795,7 @@ export function resolveRefinement(config) {
|
|
|
1608
1795
|
*
|
|
1609
1796
|
* @param {DevLoopConfig} config
|
|
1610
1797
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1611
|
-
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean }}
|
|
1798
|
+
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, tiers: Array<{name: string, match: object, angles: string[]}> }}
|
|
1612
1799
|
*/
|
|
1613
1800
|
export function resolveGateConfig(config, gate) {
|
|
1614
1801
|
const gateConfig = config?.gates?.[gate];
|
|
@@ -1623,11 +1810,19 @@ export function resolveGateConfig(config, gate) {
|
|
|
1623
1810
|
mandatoryAngles: entries.filter((e) => e.enabled !== false && e.mandatory === true).map((e) => e.name),
|
|
1624
1811
|
required: gateConfig?.required ?? true,
|
|
1625
1812
|
requireCi: gateConfig?.requireCi ?? true,
|
|
1626
|
-
dynamicAngles: gateConfig?.dynamic?.subtractive ??
|
|
1813
|
+
dynamicAngles: gateConfig?.dynamic?.subtractive ?? true,
|
|
1627
1814
|
additiveAngles: gateConfig?.dynamic?.additive ?? false,
|
|
1815
|
+
// Normalized + deduped at the resolve boundary so every consumer (envelope,
|
|
1816
|
+
// verdict poster, fan-in, viewer) sees canonical spellings only; a
|
|
1817
|
+
// half-migrated ["must-fix","low","defer"] collapses to two entries.
|
|
1628
1818
|
blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
|
|
1629
|
-
? [...gateConfig.blockCleanOnFindingSeverities]
|
|
1630
|
-
: ["
|
|
1819
|
+
? [...new Set(gateConfig.blockCleanOnFindingSeverities.map((s) => normalizeSeverity(s)))]
|
|
1820
|
+
: ["high"],
|
|
1821
|
+
// `mediumFixWindow` wins; `worthFixingNowFixWindow` is the deprecated
|
|
1822
|
+
// pre-rename key, still honored so an unmigrated config keeps its
|
|
1823
|
+
// configured window rather than silently reverting to the default.
|
|
1824
|
+
mediumFixWindow: gateConfig?.mediumFixWindow ?? gateConfig?.worthFixingNowFixWindow ?? 3,
|
|
1825
|
+
tiers: gateConfig?.tiers ?? [],
|
|
1631
1826
|
};
|
|
1632
1827
|
}
|
|
1633
1828
|
|
|
@@ -1650,10 +1845,11 @@ export function resolveRequireFanoutEvidence(config) {
|
|
|
1650
1845
|
}
|
|
1651
1846
|
|
|
1652
1847
|
/**
|
|
1653
|
-
*
|
|
1654
|
-
* requireFanoutProvenance
|
|
1655
|
-
*
|
|
1656
|
-
* is
|
|
1848
|
+
* ABSOLUTE minimum distinct reviewer count for a fanout_fanin ledger to
|
|
1849
|
+
* satisfy requireFanoutProvenance; the effective read-time floor scales to
|
|
1850
|
+
* max(this, the ledger's fresh-angle count). A floor of 2 is the smallest
|
|
1851
|
+
* count that is not a single agent; it raises the bar but does not prove
|
|
1852
|
+
* independence (provenance is self-reported — see the honest caveat in
|
|
1657
1853
|
* skills/docs/gate-review-sub-loop-contract.md).
|
|
1658
1854
|
*/
|
|
1659
1855
|
export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
|
|
@@ -1687,21 +1883,21 @@ export function resolveRejectForeignAngles(config) {
|
|
|
1687
1883
|
}
|
|
1688
1884
|
|
|
1689
1885
|
/**
|
|
1690
|
-
* Resolve whether the consolidated gate fan-out findings should be posted
|
|
1691
|
-
* visible, marker-tagged PR comment.
|
|
1886
|
+
* Resolve whether the consolidated gate fan-out findings should ALSO be posted
|
|
1887
|
+
* as a second visible, marker-tagged PR comment.
|
|
1692
1888
|
*
|
|
1693
|
-
* Returns
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1698
|
-
* skills/docs/gate-review-sub-loop-contract.md.
|
|
1889
|
+
* Returns false unless `gates.postFindingsComments` is explicitly set to true.
|
|
1890
|
+
* The round's verdict review is already the findings surface
|
|
1891
|
+
* (`GATE-COMMENT-SINGLE-SURFACE`), so this comment is opt-in duplication; the
|
|
1892
|
+
* `=== true` test keeps that opt-in semantics for programmatically-built config
|
|
1893
|
+
* objects that bypass schema defaulting. The disposition ledger is written
|
|
1894
|
+
* regardless. See skills/docs/gate-review-sub-loop-contract.md.
|
|
1699
1895
|
*
|
|
1700
1896
|
* @param {DevLoopConfig} config
|
|
1701
1897
|
* @returns {boolean}
|
|
1702
1898
|
*/
|
|
1703
1899
|
export function resolveGatePostFindingsComments(config) {
|
|
1704
|
-
return config?.gates?.postFindingsComments
|
|
1900
|
+
return config?.gates?.postFindingsComments === true;
|
|
1705
1901
|
}
|
|
1706
1902
|
|
|
1707
1903
|
/**
|
|
@@ -1812,14 +2008,187 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
|
|
|
1812
2008
|
return { mode: "full_fanout", reason: "over_threshold", threshold };
|
|
1813
2009
|
}
|
|
1814
2010
|
if (Array.isArray(inlineFindingSeverities) && inlineFindingSeverities.length > 0) {
|
|
1815
|
-
|
|
1816
|
-
|
|
2011
|
+
// Both sides normalize legacy spellings so a "defer" finding still
|
|
2012
|
+
// compares against a "low" blocking entry and vice versa.
|
|
2013
|
+
const blocking = new Set(resolveGateConfig(config, gate).blockCleanOnFindingSeverities.map((s) => normalizeSeverity(s)));
|
|
2014
|
+
if (inlineFindingSeverities.some((s) => blocking.has(normalizeSeverity(s)))) {
|
|
1817
2015
|
return { mode: "full_fanout", reason: "escalated", threshold };
|
|
1818
2016
|
}
|
|
1819
2017
|
}
|
|
1820
2018
|
return { mode: "inline", reason: "under_threshold", threshold };
|
|
1821
2019
|
}
|
|
1822
2020
|
|
|
2021
|
+
/**
|
|
2022
|
+
* Default auto-chunk size for ungrouped angles (issue #1601). Mirrors the
|
|
2023
|
+
* zod default on `gates.fanout.maxAnglesPerGroup`.
|
|
2024
|
+
*/
|
|
2025
|
+
export const DEFAULT_MAX_ANGLES_PER_GROUP = 3;
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* Default concurrent-dispatch-unit cap per wave (issue #1601). Mirrors the
|
|
2029
|
+
* zod default on `gates.fanout.maxConcurrent`; consumed by
|
|
2030
|
+
* `scheduleFanoutWaves` (@dev-loops/core/loop/gate-fanin).
|
|
2031
|
+
*/
|
|
2032
|
+
export const DEFAULT_FANOUT_MAX_CONCURRENT = 4;
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Resolve `gates.fanout.maxAnglesPerGroup` (issue #1601, default 3, min 1).
|
|
2036
|
+
* The number of ungrouped angles auto-chunked into one dispatch unit.
|
|
2037
|
+
* Defensive, independent of zod: a non-integer or sub-1 value falls back to
|
|
2038
|
+
* the built-in default so a malformed raw merged config (which zod may have
|
|
2039
|
+
* rejected at load time while still returning it) never crashes Phase 2.
|
|
2040
|
+
* @param {DevLoopConfig} config
|
|
2041
|
+
* @returns {number}
|
|
2042
|
+
*/
|
|
2043
|
+
export function resolveMaxAnglesPerGroup(config) {
|
|
2044
|
+
const n = config?.gates?.fanout?.maxAnglesPerGroup;
|
|
2045
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return DEFAULT_MAX_ANGLES_PER_GROUP;
|
|
2046
|
+
return n;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Resolve `gates.fanout.maxConcurrent` (issue #1601, default 4, min 1). The
|
|
2051
|
+
* max dispatch units (groups) the conductor dispatches concurrently per wave.
|
|
2052
|
+
* Defensive, independent of zod (same rationale as resolveMaxAnglesPerGroup).
|
|
2053
|
+
* @param {DevLoopConfig} config
|
|
2054
|
+
* @returns {number}
|
|
2055
|
+
*/
|
|
2056
|
+
export function resolveFanoutMaxConcurrent(config) {
|
|
2057
|
+
const m = config?.gates?.fanout?.maxConcurrent;
|
|
2058
|
+
if (typeof m !== "number" || !Number.isInteger(m) || m < 1) return DEFAULT_FANOUT_MAX_CONCURRENT;
|
|
2059
|
+
return m;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* Resolve grouped fan-out dispatch (AC6 + #1601 two-knob dispatch bounds):
|
|
2064
|
+
* map a round's resolved review angles onto the dispatch units it actually
|
|
2065
|
+
* dispatches.
|
|
2066
|
+
*
|
|
2067
|
+
* Dispatch shape precedence (first match wins):
|
|
2068
|
+
* 1. `gates.fanout.mode === "per-angle"` → bypasses configured groups; one
|
|
2069
|
+
* singleton unit per angle (the original one-reviewer-per-angle fan-out;
|
|
2070
|
+
* NOT equivalent to maxAnglesPerGroup: 1 when configured groups match)
|
|
2071
|
+
* 2. otherwise (default `grouped`) → configured `gates.fanout.groups` are
|
|
2072
|
+
* matched first (unchanged), then the leftover ungrouped angles are
|
|
2073
|
+
* auto-chunked into dispatch units of ≤ `maxAnglesPerGroup` (default 3)
|
|
2074
|
+
* instead of singletons.
|
|
2075
|
+
*
|
|
2076
|
+
* `gate:full` (`options.fullLabel`) NO LONGER restores per-angle dispatch
|
|
2077
|
+
* (ADR 0047 superseded by 0048): `gate:full` keeps forcing the full angle set
|
|
2078
|
+
* UPSTREAM (resolveGateTier returns `gate_full_label`, so resolveGateAnglesDynamic
|
|
2079
|
+
* skips diff-class tier reduction) and dispatches GROUPED here. The `fullLabel`
|
|
2080
|
+
* parameter is retained on the signature (callers thread it) but no longer
|
|
2081
|
+
* changes the dispatch shape — it is a no-op here, kept only to avoid a breaking
|
|
2082
|
+
* API change to the exported resolver; its angle-set effect lives upstream.
|
|
2083
|
+
*
|
|
2084
|
+
* A configured group is included only when at least one of its angles is in
|
|
2085
|
+
* `resolvedAngles` this round — an unmatched group is dropped, never emitted
|
|
2086
|
+
* empty. Configured groups are NEVER split by `maxAnglesPerGroup` (the knob
|
|
2087
|
+
* chunks only the leftover ungrouped pool). Each reviewer still writes ONE
|
|
2088
|
+
* artifact per angle at the existing per-angle paths; grouping only changes how
|
|
2089
|
+
* many reviewers are dispatched, not the artifact shape (see
|
|
2090
|
+
* skills/docs/gate-review-sub-loop-contract.md).
|
|
2091
|
+
*
|
|
2092
|
+
* Auto-chunk unit names are deterministic and stable (issue #1601): a
|
|
2093
|
+
* single-angle leftover chunk is named by its angle (collisions with an emitted
|
|
2094
|
+
* group name disambiguated to `angle:<name>`, preserving the pre-#1601
|
|
2095
|
+
* singleton convention); a multi-angle chunk is named `group:<a>+<b>+<c>` from
|
|
2096
|
+
* its deterministically-ordered members. Unit names key reviewer-sentinel
|
|
2097
|
+
* scopes and provenance `group`, so they must be unique — a chunk whose base
|
|
2098
|
+
* name still collides gets a `#2`/`#3`/… suffix.
|
|
2099
|
+
*
|
|
2100
|
+
* Defensive, independent of zod: `loadDevLoopConfig` returns the raw merged
|
|
2101
|
+
* config even when schema validation fails (on ANY layer, not necessarily
|
|
2102
|
+
* `gates.fanout` itself), so a malformed `gates.fanout.groups` entry can
|
|
2103
|
+
* reach here. A non-object entry, a non-array/blank `angles`, or a
|
|
2104
|
+
* blank/duplicate `name` is dropped (its angles fall through to the leftover
|
|
2105
|
+
* auto-chunk pool) rather than thrown — mirroring the sibling
|
|
2106
|
+
* `normalizeAngleEntries` convention: this resolver degrades to a smaller
|
|
2107
|
+
* grouping table, never crashes the conductor's Phase 2 planning.
|
|
2108
|
+
* `resolvedAngles` is deduplicated up front so a duplicated entry (e.g. a
|
|
2109
|
+
* hand-built `--angles` list) never mints two dispatch units sharing one name.
|
|
2110
|
+
*
|
|
2111
|
+
* @param {DevLoopConfig} config
|
|
2112
|
+
* @param {"draft"|"preApproval"|"spike"} gate unused today — fan-out grouping
|
|
2113
|
+
* is a global policy (`gates.fanout`), not per-gate; accepted for symmetry
|
|
2114
|
+
* with the other `resolveGate*(config, gate, ...)` resolvers.
|
|
2115
|
+
* @param {string[]} resolvedAngles this round's resolved angle names
|
|
2116
|
+
* @param {{ fullLabel?: boolean }} [options] — retained for API stability;
|
|
2117
|
+
* no longer changes the dispatch shape (see `gate:full` note above).
|
|
2118
|
+
* @returns {{ name: string, angles: string[] }[]}
|
|
2119
|
+
*/
|
|
2120
|
+
export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel = false } = {}) {
|
|
2121
|
+
const angles = Array.isArray(resolvedAngles)
|
|
2122
|
+
? [...new Set(resolvedAngles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim()))]
|
|
2123
|
+
: [];
|
|
2124
|
+
const perAngleGroups = () => angles.map((name) => ({ name, angles: [name] }));
|
|
2125
|
+
// per-angle: bypass configured groups and emit one singleton unit per
|
|
2126
|
+
// angle (the original one-reviewer-per-angle fan-out). gate:full no longer
|
|
2127
|
+
// takes this branch (ADR 0047 superseded by 0048): fullLabel is a no-op here.
|
|
2128
|
+
const fanout = config?.gates?.fanout ?? {};
|
|
2129
|
+
if (fanout.mode === "per-angle") return perAngleGroups();
|
|
2130
|
+
const angleSet = new Set(angles);
|
|
2131
|
+
const rawGroups = Array.isArray(fanout.groups) ? fanout.groups : [];
|
|
2132
|
+
const configuredGroups = [];
|
|
2133
|
+
const seenGroupNames = new Set();
|
|
2134
|
+
for (const group of rawGroups) {
|
|
2135
|
+
if (!group || typeof group !== "object" || Array.isArray(group)) continue;
|
|
2136
|
+
const name = typeof group.name === "string" ? group.name.trim() : "";
|
|
2137
|
+
if (name.length === 0 || seenGroupNames.has(name)) continue;
|
|
2138
|
+
const groupAngles = Array.isArray(group.angles)
|
|
2139
|
+
? [...new Set(group.angles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim()))]
|
|
2140
|
+
: [];
|
|
2141
|
+
if (groupAngles.length === 0) continue;
|
|
2142
|
+
seenGroupNames.add(name);
|
|
2143
|
+
configuredGroups.push({ name, angles: groupAngles });
|
|
2144
|
+
}
|
|
2145
|
+
const grouped = new Set();
|
|
2146
|
+
const result = [];
|
|
2147
|
+
for (const group of configuredGroups) {
|
|
2148
|
+
const members = group.angles.filter((a) => angleSet.has(a) && !grouped.has(a));
|
|
2149
|
+
if (members.length === 0) continue;
|
|
2150
|
+
for (const a of members) grouped.add(a);
|
|
2151
|
+
result.push({ name: group.name, angles: members });
|
|
2152
|
+
}
|
|
2153
|
+
// Issue #1601: leftover ungrouped angles auto-chunk into dispatch units of
|
|
2154
|
+
// ≤ maxAnglesPerGroup (default 3) instead of singletons. Configured groups
|
|
2155
|
+
// are matched first and never split by this knob (only the leftover pool is
|
|
2156
|
+
// chunked). Deterministic order (input order) + stable unit names.
|
|
2157
|
+
const usedNames = new Set(result.map((g) => g.name));
|
|
2158
|
+
const leftover = angles.filter((name) => !grouped.has(name));
|
|
2159
|
+
const maxAnglesPerGroup = resolveMaxAnglesPerGroup(config);
|
|
2160
|
+
for (let i = 0; i < leftover.length; i += maxAnglesPerGroup) {
|
|
2161
|
+
const chunk = leftover.slice(i, i + maxAnglesPerGroup);
|
|
2162
|
+
const unitName = stableAutoChunkUnitName(chunk, usedNames);
|
|
2163
|
+
usedNames.add(unitName);
|
|
2164
|
+
result.push({ name: unitName, angles: chunk });
|
|
2165
|
+
}
|
|
2166
|
+
return result;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Deterministic, stable dispatch-unit name for an auto-chunked leftover
|
|
2171
|
+
* unit (issue #1601). A single-angle chunk keeps the pre-#1601 singleton
|
|
2172
|
+
* convention (the angle name, disambiguated to `angle:<name>` on collision
|
|
2173
|
+
* with an emitted group name); a multi-angle chunk is named
|
|
2174
|
+
* `group:<a>+<b>+<c>` from its deterministically-ordered members, with a
|
|
2175
|
+
* `#N` suffix when even that base collides. Pure.
|
|
2176
|
+
* @param {string[]} chunk — non-empty, deterministically ordered
|
|
2177
|
+
* @param {Set<string>} usedNames — already-emitted unit names (mutated by caller)
|
|
2178
|
+
* @returns {string}
|
|
2179
|
+
*/
|
|
2180
|
+
function stableAutoChunkUnitName(chunk, usedNames) {
|
|
2181
|
+
if (chunk.length === 1) {
|
|
2182
|
+
const name = chunk[0];
|
|
2183
|
+
return usedNames.has(name) ? `angle:${name}` : name;
|
|
2184
|
+
}
|
|
2185
|
+
const base = `group:${chunk.join("+")}`;
|
|
2186
|
+
if (!usedNames.has(base)) return base;
|
|
2187
|
+
let k = 2;
|
|
2188
|
+
while (usedNames.has(`${base}#${k}`)) k++;
|
|
2189
|
+
return `${base}#${k}`;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1823
2192
|
/**
|
|
1824
2193
|
* Resolve review angles for a specific gate from the merged dev-loop config.
|
|
1825
2194
|
*
|
|
@@ -1904,14 +2273,79 @@ export function resolveGateAngleContract(config, gate) {
|
|
|
1904
2273
|
return { mandatoryAngles, pool };
|
|
1905
2274
|
}
|
|
1906
2275
|
|
|
2276
|
+
/**
|
|
2277
|
+
* Resolve the diff-class angle tier for a gate from its configured, ordered
|
|
2278
|
+
* `gates.<gate>.tiers` list (first-match-wins). Pure and synchronous — the
|
|
2279
|
+
* single source of truth for tier selection, consulted at the top of
|
|
2280
|
+
* `resolveGateAnglesDynamic` before any dynamic subtractive/additive
|
|
2281
|
+
* reduction runs.
|
|
2282
|
+
*
|
|
2283
|
+
* FAIL CLOSED at every uncertain step: the `gate:full` label, no tiers
|
|
2284
|
+
* configured, an unavailable/malformed scope, a changed dev-loop
|
|
2285
|
+
* config-source file (`isDevLoopConfigSourcePath`), or an unclassifiable
|
|
2286
|
+
* changed file (`classifyFile` returns "unknown") all resolve to `tier: null`
|
|
2287
|
+
* rather than a guess. A matched tier's angle set is additionally validated
|
|
2288
|
+
* against the gate's angle pool (`resolveGateAngleContract`) — ANY tier angle
|
|
2289
|
+
* outside a non-null pool voids the whole match (no partial intersection): a
|
|
2290
|
+
* typo'd tier angle is caught here, not by silently dropping reviewers at
|
|
2291
|
+
* gate time.
|
|
2292
|
+
*
|
|
2293
|
+
* @param {DevLoopConfig} config
|
|
2294
|
+
* @param {"draft"|"preApproval"|"spike"} gate
|
|
2295
|
+
* @param {object} facts
|
|
2296
|
+
* @param {string[]} [facts.changedFiles] — repo-relative changed file paths for this diff
|
|
2297
|
+
* @param {number} [facts.filesChanged] — count of changed files
|
|
2298
|
+
* @param {number} [facts.linesChanged] — count of changed lines (added + deleted)
|
|
2299
|
+
* @param {boolean} [facts.hasFullLabel] — `gate:full` label present on the PR
|
|
2300
|
+
* @returns {{ tier: string|null, angles: string[]|null, reason: string }}
|
|
2301
|
+
*/
|
|
2302
|
+
export function resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel = false } = {}) {
|
|
2303
|
+
if (hasFullLabel) {
|
|
2304
|
+
return { tier: null, angles: null, reason: "gate_full_label" };
|
|
2305
|
+
}
|
|
2306
|
+
const tiers = resolveGateConfig(config, gate).tiers;
|
|
2307
|
+
if (tiers.length === 0) {
|
|
2308
|
+
return { tier: null, angles: null, reason: "no_tiers_configured" };
|
|
2309
|
+
}
|
|
2310
|
+
if (
|
|
2311
|
+
!Array.isArray(changedFiles) || changedFiles.length === 0 ||
|
|
2312
|
+
!Number.isFinite(filesChanged) || !Number.isFinite(linesChanged)
|
|
2313
|
+
) {
|
|
2314
|
+
return { tier: null, angles: null, reason: "scope_unavailable" };
|
|
2315
|
+
}
|
|
2316
|
+
if (changedFiles.some((f) => isDevLoopConfigSourcePath(f))) {
|
|
2317
|
+
return { tier: null, angles: null, reason: "config_source_delta" };
|
|
2318
|
+
}
|
|
2319
|
+
const kinds = changedFiles.map((f) => classifyFile(f));
|
|
2320
|
+
if (kinds.some((k) => k === "unknown")) {
|
|
2321
|
+
return { tier: null, angles: null, reason: "unclassifiable_file" };
|
|
2322
|
+
}
|
|
2323
|
+
const matched = tiers.find((t) => {
|
|
2324
|
+
const match = t.match ?? {};
|
|
2325
|
+
if (Array.isArray(match.kinds) && !kinds.every((k) => match.kinds.includes(k))) return false;
|
|
2326
|
+
if (typeof match.maxFiles === "number" && filesChanged > match.maxFiles) return false;
|
|
2327
|
+
if (typeof match.maxLines === "number" && linesChanged > match.maxLines) return false;
|
|
2328
|
+
return true;
|
|
2329
|
+
});
|
|
2330
|
+
if (!matched) {
|
|
2331
|
+
return { tier: null, angles: null, reason: "no_tier_match" };
|
|
2332
|
+
}
|
|
2333
|
+
const { mandatoryAngles, pool } = resolveGateAngleContract(config, gate);
|
|
2334
|
+
if (pool !== null && matched.angles.some((a) => !pool.includes(a))) {
|
|
2335
|
+
return { tier: null, angles: null, reason: "angle_outside_pool" };
|
|
2336
|
+
}
|
|
2337
|
+
return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
|
|
2338
|
+
}
|
|
2339
|
+
|
|
1907
2340
|
/**
|
|
1908
2341
|
* Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
|
|
1909
2342
|
*
|
|
1910
2343
|
* Uses diff analysis helpers (from ../analysis/*) to filter the
|
|
1911
2344
|
* configured angle list down to only angles relevant to the change set.
|
|
1912
2345
|
*
|
|
1913
|
-
* When `dynamicAngles` is disabled (
|
|
1914
|
-
* angle list (same as
|
|
2346
|
+
* When `dynamicAngles` is disabled (opt-out via `dynamic.subtractive: false`,
|
|
2347
|
+
* see #1579), returns the full configured angle list (same as
|
|
2348
|
+
* `resolveGateAngles`); no diff also falls back to the full static pool.
|
|
1915
2349
|
*
|
|
1916
2350
|
* When `additiveAngles` is also enabled (default off, see #1048), catalog
|
|
1917
2351
|
* angles from `resolveAnglePool()` (`gates.anglePool`, or else the union of
|
|
@@ -1919,13 +2353,55 @@ export function resolveGateAngleContract(config, gate) {
|
|
|
1919
2353
|
* by change-category heuristics but absent from the gate's configured pool
|
|
1920
2354
|
* may also be added; `excludeAngles` remains a hard ceiling on additions.
|
|
1921
2355
|
*
|
|
2356
|
+
* Diff-class angle tiers (`gates.<gate>.tiers`, see `resolveGateTier`) are
|
|
2357
|
+
* consulted FIRST, ahead of any subtractive/additive reduction below: when the
|
|
2358
|
+
* diff's changed-file scope matches a configured tier, that tier's angle set
|
|
2359
|
+
* (unioned with mandatory angles) is returned directly and the
|
|
2360
|
+
* subtractive/additive machinery below is skipped entirely. No tier match
|
|
2361
|
+
* (including "no tiers configured") falls through to the existing behavior
|
|
2362
|
+
* unchanged.
|
|
2363
|
+
*
|
|
1922
2364
|
* @param {import("./types.js").DevLoopConfig} config
|
|
1923
2365
|
* @param {"draft"|"preApproval"} gate
|
|
1924
2366
|
* @param {object} [options]
|
|
1925
2367
|
* @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
|
|
2368
|
+
* @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
|
|
1926
2369
|
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
|
|
1927
2370
|
*/
|
|
1928
|
-
export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
2371
|
+
export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false } = {}) {
|
|
2372
|
+
// Tier scope facts: changedFiles/filesChanged from T0 (file-level), linesChanged
|
|
2373
|
+
// from T1 (hunk-level) reused for its real added+deleted line count rather than
|
|
2374
|
+
// T0's/analyzeDiff's own inferred-category path, which reports a fake 0 line
|
|
2375
|
+
// count for an unambiguous (e.g. docs-only) diff — see analyzeT1/analyzeDiff.
|
|
2376
|
+
let changedFiles;
|
|
2377
|
+
let filesChanged;
|
|
2378
|
+
let linesChanged;
|
|
2379
|
+
if (diff) {
|
|
2380
|
+
const { analyzeT0, analyzeT1 } = await import("../analysis/diff-analyzer.mjs");
|
|
2381
|
+
const t0 = analyzeT0(diff.nameStatusOutput);
|
|
2382
|
+
changedFiles = t0.files;
|
|
2383
|
+
filesChanged = changedFiles.length;
|
|
2384
|
+
if (diff.diffOutput) {
|
|
2385
|
+
const lineStats = analyzeT1(diff.diffOutput, t0).lineStats;
|
|
2386
|
+
linesChanged = lineStats.added + lineStats.deleted;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
const tierResult = resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel });
|
|
2390
|
+
if (tierResult.tier) {
|
|
2391
|
+
const configuredAngles = resolveGateAngles(config, gate) ?? [];
|
|
2392
|
+
const tierAngleSet = new Set(tierResult.angles);
|
|
2393
|
+
const skippedAngles = configuredAngles.filter((a) => !tierAngleSet.has(a));
|
|
2394
|
+
return {
|
|
2395
|
+
recommendedAngles: tierResult.angles,
|
|
2396
|
+
skippedAngles,
|
|
2397
|
+
reasons: Object.fromEntries(skippedAngles.map((a) => [a, `tier:${tierResult.tier}`])),
|
|
2398
|
+
fallbackToAll: false,
|
|
2399
|
+
dynamicAnglesActive: true,
|
|
2400
|
+
addedAngles: [],
|
|
2401
|
+
addedReasons: {},
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
|
|
1929
2405
|
const gateConfig = resolveGateConfig(config, gate);
|
|
1930
2406
|
const staticAngles = resolveGateAngles(config, gate);
|
|
1931
2407
|
if (staticAngles === null) {
|