@dev-loops/core 1.0.0-rc.6 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +36 -4
- package/src/cli/primitives.mjs +30 -1
- package/src/config/config.mjs +254 -13
- package/src/config/extension-defaults.yaml +34 -1
- package/src/github/comment-id-guard.mjs +97 -9
- package/src/github/copilot-helpers.mjs +114 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +7 -0
- package/src/loop/agent-stall.mjs +4 -2
- package/src/loop/commit-msg-guard.mjs +168 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +34 -1
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +12 -19
- package/src/loop/issue-refinement-artifact.mjs +186 -42
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +34 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/public-dev-loop-routing.mjs +11 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/retrospective-checkpoint.mjs +59 -1
- package/src/loop/review-dispatch-plan.mjs +448 -9
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +2 -28
- package/src/security/secret-scan.mjs +330 -0
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { scheduleParallelWaves } from "./queue-parallel.mjs";
|
|
37
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Schedule fan-out dispatch units into bounded-concurrency waves (issue #1601).
|
|
@@ -106,30 +107,46 @@ export function backoffMaxConcurrent(maxConcurrent) {
|
|
|
106
107
|
*
|
|
107
108
|
* @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output (fresh angles + re-verifications)
|
|
108
109
|
* @param {number|null} [availableReviewers] — harness remaining reviewer budget; null/non-finite = unknown/unexposed
|
|
109
|
-
* @param {{ completedAngles?: Iterable<string> }} [options] — `completedAngles`:
|
|
110
|
-
* already have a clean per-angle findings artifact stamped for THIS head.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
110
|
+
* @param {{ completedAngles?: Iterable<string>, carriedAngles?: Iterable<string> }} [options] — `completedAngles`:
|
|
111
|
+
* angle names that already have a clean per-angle findings artifact stamped for THIS head.
|
|
112
|
+
* `carriedAngles`: angle names the fail-closed carry-forward seam (resolve-angle-carry-forward.mjs)
|
|
113
|
+
* has proven carried from a prior clean head, so no reviewer re-runs them this round either — that
|
|
114
|
+
* carry-forward resolution runs AFTER this preflight, so a head-bump re-gate must feed its result
|
|
115
|
+
* back in to avoid over-counting. A dispatch unit (group) whose angles are ALL complete-or-carried
|
|
116
|
+
* is excluded from the required count and from `pendingGroups`, so a later session resumes the
|
|
117
|
+
* fan-out instead of restarting it: it re-runs the preflight and dispatches only the groups not
|
|
118
|
+
* already resolved at this head. Membership is matched trim+lowercase (mirrors
|
|
119
|
+
* consolidate-fanin.mjs's own carried-key normalization) so a config/plan case difference in an
|
|
120
|
+
* angle name still excludes the right group instead of silently spending a reviewer on it.
|
|
121
|
+
* @returns {{ ok: boolean, dispatch: boolean, requiredReviewers: number, availableReviewers: number|null, shortfall: number|null, reason: string, verdict: null, executionMode: null, pendingGroups: { name: string, angles: string[] }[], skippedGroups: { name: string, angles: string[] }[], completedAngles: string[], carriedAngles: string[] }}
|
|
115
122
|
*/
|
|
116
|
-
export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles } = {}) {
|
|
123
|
+
export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles, carriedAngles } = {}) {
|
|
117
124
|
const groups = Array.isArray(dispatchGroups) ? dispatchGroups : [];
|
|
118
|
-
const
|
|
119
|
-
Array.isArray(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
125
|
+
const toSet = (iterable) =>
|
|
126
|
+
new Set(Array.isArray(iterable) ? iterable : iterable == null ? [] : [...iterable]);
|
|
127
|
+
const completedSet = toSet(completedAngles);
|
|
128
|
+
const carriedSet = toSet(carriedAngles);
|
|
129
|
+
// Same-head resume + head-bump carry-forward: one reviewer per dispatch unit
|
|
130
|
+
// (a group of N angles is one reviewer's scoped dispatch — see
|
|
131
|
+
// resolveFanoutGroups / countFreshDispatchUnits), but a group already
|
|
132
|
+
// RESOLVED for this round — every one of its angles either has a clean
|
|
133
|
+
// artifact stamped for this head OR is proven carried forward from a prior
|
|
134
|
+
// clean head — needs no reviewer and is excluded from the required count
|
|
135
|
+
// and the pending plan. The conductor dispatches only `pendingGroups`.
|
|
136
|
+
// Membership is matched trim+lowercase (`normalizeAngleKey`) — the same
|
|
137
|
+
// normalization consolidate-fanin.mjs applies to its own carried keys — so a
|
|
138
|
+
// config/plan case difference in an angle name still excludes the group
|
|
139
|
+
// instead of leaving it (and its exempted sibling) silently disagreeing.
|
|
140
|
+
const normalizeAngleKey = (a) => String(a).trim().toLowerCase();
|
|
141
|
+
const completedKeys = new Set([...completedSet].map(normalizeAngleKey));
|
|
142
|
+
const carriedKeys = new Set([...carriedSet].map(normalizeAngleKey));
|
|
131
143
|
const groupIsComplete = (g) =>
|
|
132
|
-
Array.isArray(g?.angles) &&
|
|
144
|
+
Array.isArray(g?.angles) &&
|
|
145
|
+
g.angles.length > 0 &&
|
|
146
|
+
g.angles.every((a) => {
|
|
147
|
+
const key = normalizeAngleKey(a);
|
|
148
|
+
return completedKeys.has(key) || carriedKeys.has(key);
|
|
149
|
+
});
|
|
133
150
|
const pendingGroups = groups.filter((g) => !groupIsComplete(g));
|
|
134
151
|
const skippedGroups = groups.filter((g) => groupIsComplete(g));
|
|
135
152
|
// One reviewer per dispatch unit: a group of N angles is one reviewer's
|
|
@@ -138,7 +155,7 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
|
|
|
138
155
|
const requiredReviewers = pendingGroups.length;
|
|
139
156
|
const verdict = null;
|
|
140
157
|
const executionMode = null;
|
|
141
|
-
const resume = { pendingGroups, skippedGroups, completedAngles: [...completedSet] };
|
|
158
|
+
const resume = { pendingGroups, skippedGroups, completedAngles: [...completedSet], carriedAngles: [...carriedSet] };
|
|
142
159
|
if (typeof availableReviewers !== "number" || !Number.isFinite(availableReviewers)) {
|
|
143
160
|
return { ok: true, dispatch: true, requiredReviewers, availableReviewers: null, shortfall: null, reason: "budget_unknown", verdict, executionMode, ...resume };
|
|
144
161
|
}
|
|
@@ -175,13 +192,33 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
|
|
|
175
192
|
// finding via the fix loop, a question via never being auto-deferred) — it
|
|
176
193
|
// outranks "medium"/"low", which both eventually defer. "nit" trails last:
|
|
177
194
|
// it defers immediately, with no fixer cycle at all.
|
|
178
|
-
export const SEVERITY_ORDER = ["high", "question", "medium", "low", "nit"];
|
|
195
|
+
export const SEVERITY_ORDER = Object.freeze(["high", "question", "medium", "low", "nit"]);
|
|
196
|
+
|
|
197
|
+
// The non-defect subset of SEVERITY_ORDER: a "question" is answered (never
|
|
198
|
+
// fixed or deferred like a defect — see deriveDisposition), and a "nit"
|
|
199
|
+
// always defers regardless of any gate's blockCleanOnFindingSeverities
|
|
200
|
+
// config (see isDefaultDeferrableSeverity) — neither belongs in a
|
|
201
|
+
// defect-only blocking vocabulary. Exported as the single source for that
|
|
202
|
+
// partition so a consumer (e.g. config.mjs's BLOCKING_SEVERITY_SPELLINGS
|
|
203
|
+
// vocabulary contract test) derives "defect severities" as
|
|
204
|
+
// SEVERITY_ORDER minus this set, rather than re-hand-listing "question"/"nit".
|
|
205
|
+
// Object.freeze on a Set only locks its OWN properties, not the add/delete
|
|
206
|
+
// methods that mutate its internal collection — freezing is still applied
|
|
207
|
+
// here for consistency with SEVERITY_ORDER and this file's other frozen
|
|
208
|
+
// exports (GATE_CONFIG_KEY, LEGACY_SEVERITY_ALIASES, etc.), and it does stop
|
|
209
|
+
// a caller from attaching a stray own property to the Set object itself.
|
|
210
|
+
export const NON_DEFECT_SEVERITIES = Object.freeze(new Set(["question", "nit"]));
|
|
179
211
|
|
|
180
212
|
// Marker gate name → gates.<key> config key. Owned here so every caller of
|
|
181
213
|
// resolveFanoutGroups maps the same way; passing the marker name verbatim
|
|
182
214
|
// resolves no groups and silently downgrades pairing enforcement.
|
|
183
215
|
export const GATE_CONFIG_KEY = Object.freeze({ draft_gate: "draft", pre_approval_gate: "preApproval" });
|
|
184
|
-
|
|
216
|
+
// Object.freeze on a Set only locks its OWN properties, not the add/delete
|
|
217
|
+
// methods that mutate its internal collection — freezing is still applied
|
|
218
|
+
// here for consistency with SEVERITY_ORDER and this file's other frozen
|
|
219
|
+
// exports (GATE_CONFIG_KEY, LEGACY_SEVERITY_ALIASES, etc.), and it does stop
|
|
220
|
+
// a caller from attaching a stray own property to the Set object itself.
|
|
221
|
+
export const VALID_SEVERITIES = Object.freeze(new Set(SEVERITY_ORDER));
|
|
185
222
|
|
|
186
223
|
// Pre-rename spellings. Old ledgers, markers, and configs still carry them;
|
|
187
224
|
// every read boundary normalizes through this map. Every SANCTIONED producer
|
|
@@ -241,6 +278,47 @@ export function severityRank(severity) {
|
|
|
241
278
|
return idx === -1 ? SEVERITY_ORDER.length : idx;
|
|
242
279
|
}
|
|
243
280
|
|
|
281
|
+
/**
|
|
282
|
+
* A zero-initialized severity→count map, one key per SEVERITY_ORDER entry, in
|
|
283
|
+
* SEVERITY_ORDER's order. The shared starting point every severity tally in
|
|
284
|
+
* this codebase (consolidateFanin's own `bySeverity`, consolidate-fanin.mjs's
|
|
285
|
+
* `buildAngleMarker`, reconcile-draft-gate.mjs's no-findings placeholder) used
|
|
286
|
+
* to hand-roll separately via `Object.fromEntries(SEVERITY_ORDER.map((s) =>
|
|
287
|
+
* [s, 0]))` — one copy here means a severity added to SEVERITY_ORDER is
|
|
288
|
+
* zero-initialized everywhere at once.
|
|
289
|
+
* @returns {Record<string, number>}
|
|
290
|
+
*/
|
|
291
|
+
export function zeroSeverityCounts() {
|
|
292
|
+
return Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Tally `findings` by (normalized) severity into a {@link zeroSeverityCounts}
|
|
297
|
+
* map. Each finding's severity is normalized through `normalizeSeverity`
|
|
298
|
+
* before counting, so a legacy spelling still lands on its canonical key. A
|
|
299
|
+
* finding whose normalized severity is not a recognized SEVERITY_ORDER member
|
|
300
|
+
* is silently excluded from the tally rather than inflating an unknown key —
|
|
301
|
+
* every routed call site here counts already-validated findings in practice
|
|
302
|
+
* (consolidateFanin validates every result's severity before this runs;
|
|
303
|
+
* buildAngleMarker tallies consolidateFanin's own output), so this guard is a
|
|
304
|
+
* defensive floor against future drift, not an escape hatch for accepting
|
|
305
|
+
* unvalidated severities. `findings` and its entries are NOT nullish-tolerant:
|
|
306
|
+
* a nullish `findings` argument throws (not iterable), and a nullish
|
|
307
|
+
* individual entry throws reading `.severity` — no routed caller passes
|
|
308
|
+
* either shape, so a caller that does gets a loud failure instead of a
|
|
309
|
+
* silently wrong all-zero tally.
|
|
310
|
+
* @param {Iterable<{severity: unknown}>} findings
|
|
311
|
+
* @returns {Record<string, number>}
|
|
312
|
+
*/
|
|
313
|
+
export function tallySeverities(findings) {
|
|
314
|
+
const counts = zeroSeverityCounts();
|
|
315
|
+
for (const f of findings) {
|
|
316
|
+
const severity = /** @type {string} */ (normalizeSeverity(f.severity));
|
|
317
|
+
if (Object.hasOwn(counts, severity)) counts[severity] += 1;
|
|
318
|
+
}
|
|
319
|
+
return counts;
|
|
320
|
+
}
|
|
321
|
+
|
|
244
322
|
/**
|
|
245
323
|
* Merge a severity→count map's legacy-spelled keys into their canonical keys
|
|
246
324
|
* (summing counts) so both the CLI parser and direct programmatic callers of
|
|
@@ -457,7 +535,7 @@ function* freshEntries(perAngle) {
|
|
|
457
535
|
if (typeof entry.carriedFromHead === "string" && entry.carriedFromHead.trim().length > 0) continue;
|
|
458
536
|
const angle = typeof entry.angle === "string" ? entry.angle.trim() : "";
|
|
459
537
|
if (!angle) continue;
|
|
460
|
-
const group =
|
|
538
|
+
const group = trimmedOrNull(entry.group);
|
|
461
539
|
yield { entry, angle, group };
|
|
462
540
|
}
|
|
463
541
|
}
|
|
@@ -665,6 +743,63 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
|
|
|
665
743
|
*/
|
|
666
744
|
export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist-matrix"]);
|
|
667
745
|
|
|
746
|
+
/**
|
|
747
|
+
* Validate a round's RESOLVED angle set — the full angle list the round
|
|
748
|
+
* targeted, independent of any single gate's configured MANDATORY subset —
|
|
749
|
+
* against the evidence actually recorded for it: every resolved angle must
|
|
750
|
+
* have either a per-angle artifact in `recordedAngles` (matched by
|
|
751
|
+
* {@link baseAngleName} plus a case-insensitive compare — same base+lowercase
|
|
752
|
+
* rule consolidate-fanin.mjs applies to its own angle keys) or be
|
|
753
|
+
* named in `carriedAngles` (angle names a caller has already PROVEN carried
|
|
754
|
+
* forward from a prior clean head — never a bare, unverified name; the
|
|
755
|
+
* consolidate-fanin CLI's own `--carried-angles` is only ever populated after
|
|
756
|
+
* its `--carry-forward-plan` proof check, so passing it straight through here
|
|
757
|
+
* keeps that same guarantee).
|
|
758
|
+
*
|
|
759
|
+
* This closes a gap {@link checkFanoutAngleCoverage} leaves open: that check
|
|
760
|
+
* only protects a CALLER-SUPPLIED mandatory subset, so a wrong carry-forward
|
|
761
|
+
* declaration naming only NON-mandatory angles under-dispatches with no
|
|
762
|
+
* mechanical refusal — visible only in the ledger's own carried-angle
|
|
763
|
+
* provenance (see the Gate Review Sub-Loop Contract's Phase 3 backstop
|
|
764
|
+
* paragraph). This function protects every resolved angle, not just the
|
|
765
|
+
* mandatory ones. Pure.
|
|
766
|
+
*
|
|
767
|
+
* @param {unknown} resolvedAngles — the round's full resolved angle-name list
|
|
768
|
+
* @param {object} [evidence]
|
|
769
|
+
* @param {unknown} [evidence.recordedAngles] — array of `{ angle: string, ... }` entries (per-angle artifacts this round consolidated)
|
|
770
|
+
* @param {Iterable<string>} [evidence.carriedAngles] — angle names already proven carried forward
|
|
771
|
+
* @returns {{ missingAngles: string[] }}
|
|
772
|
+
*/
|
|
773
|
+
export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, carriedAngles } = {}) {
|
|
774
|
+
const resolved = Array.isArray(resolvedAngles)
|
|
775
|
+
? [...new Set(
|
|
776
|
+
resolvedAngles
|
|
777
|
+
.map((a) => (typeof a === "string" ? a.trim() : ""))
|
|
778
|
+
.filter((a) => a.length > 0),
|
|
779
|
+
)]
|
|
780
|
+
: [];
|
|
781
|
+
const recorded = Array.isArray(recordedAngles)
|
|
782
|
+
? recordedAngles
|
|
783
|
+
.map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
|
|
784
|
+
.filter((a) => a.length > 0)
|
|
785
|
+
: [];
|
|
786
|
+
// Matched base+lowercase, same as checkFanoutAngleCoverage's callers
|
|
787
|
+
// (consolidate-fanin's realAngleKeys/exemptCarriedKeys) and
|
|
788
|
+
// reviewerBudgetPreflight's normalizeAngleKey: per-angle artifacts are
|
|
789
|
+
// independently authored, so a case difference between a resolved angle
|
|
790
|
+
// name and its recorded/carried evidence must not read as missing.
|
|
791
|
+
const normalizeAngleBase = (a) => baseAngleName(a).toLowerCase();
|
|
792
|
+
const recordedBases = new Set(recorded.map(normalizeAngleBase));
|
|
793
|
+
const carriedBases = new Set(
|
|
794
|
+
[...(carriedAngles ?? [])].map((a) => normalizeAngleBase(String(a).trim())),
|
|
795
|
+
);
|
|
796
|
+
const missingAngles = resolved.filter((a) => {
|
|
797
|
+
const base = normalizeAngleBase(a);
|
|
798
|
+
return !recordedBases.has(base) && !carriedBases.has(base);
|
|
799
|
+
});
|
|
800
|
+
return { missingAngles };
|
|
801
|
+
}
|
|
802
|
+
|
|
668
803
|
/**
|
|
669
804
|
* Default cap on parallel fan-out reviewers when a caller does not supply one.
|
|
670
805
|
* Mirrors the config default (gates.maxFanoutReviewers).
|
|
@@ -770,7 +905,6 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
770
905
|
if (err) malformed.push({ index, reason: err });
|
|
771
906
|
});
|
|
772
907
|
|
|
773
|
-
const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
|
|
774
908
|
/** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
|
|
775
909
|
const findings = [];
|
|
776
910
|
let blockingCount = 0;
|
|
@@ -782,7 +916,6 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
782
916
|
const severity = /** @type {string} */ (normalizeSeverity(f.severity));
|
|
783
917
|
const isBlocking = blocking.has(severity);
|
|
784
918
|
if (isBlocking) blockingCount += 1;
|
|
785
|
-
bySeverity[severity] += 1;
|
|
786
919
|
const entry = {
|
|
787
920
|
severity,
|
|
788
921
|
angle,
|
|
@@ -810,6 +943,9 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
810
943
|
verdict = "clean";
|
|
811
944
|
}
|
|
812
945
|
|
|
946
|
+
// `findings` already carries each entry's normalized severity, so tallying
|
|
947
|
+
// it directly (rather than incrementing a running map inside the loop
|
|
948
|
+
// above) reproduces the same counts via the one shared tally rule.
|
|
813
949
|
return {
|
|
814
950
|
verdict,
|
|
815
951
|
findings,
|
|
@@ -817,7 +953,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
817
953
|
angles: results.length,
|
|
818
954
|
findings: findings.length,
|
|
819
955
|
blocking: blockingCount,
|
|
820
|
-
bySeverity,
|
|
956
|
+
bySeverity: tallySeverities(findings),
|
|
821
957
|
},
|
|
822
958
|
malformed,
|
|
823
959
|
};
|
|
@@ -938,7 +1074,10 @@ export function validateJudgeVerdict(verdict) {
|
|
|
938
1074
|
*
|
|
939
1075
|
* Pure. Fails closed (throws) when a disposition references an out-of-range
|
|
940
1076
|
* index — a judge verdict that names a finding that is not in the ledger is a
|
|
941
|
-
* mismatch, never a silent enrichment
|
|
1077
|
+
* mismatch, never a silent enrichment — and when the dispositions do not
|
|
1078
|
+
* cover every finding: an undisposed finding must never be silently dropped
|
|
1079
|
+
* from the fixer's act list. An empty findings array with an empty
|
|
1080
|
+
* dispositions array is vacuously covered and returns without error.
|
|
942
1081
|
*
|
|
943
1082
|
* @param {Array<object>} findings — the flat consolidated findings array
|
|
944
1083
|
* @param {object} judgeVerdict — the validated judge verdict artifact
|
|
@@ -953,6 +1092,13 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
|
|
|
953
1092
|
throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`);
|
|
954
1093
|
}
|
|
955
1094
|
const target = enriched[d.index];
|
|
1095
|
+
// Reset judge-owned fields before the re-merge: a pre-enriched finding
|
|
1096
|
+
// (already-enriched from a prior round, re-disposed by THIS verdict)
|
|
1097
|
+
// must not let stale judgeCriterion/followUpDraft survive a
|
|
1098
|
+
// defer -> act/reject re-disposition — the merged copy carries only
|
|
1099
|
+
// what the current disposition provides, never prior-round residue.
|
|
1100
|
+
delete target.judgeCriterion;
|
|
1101
|
+
delete target.followUpDraft;
|
|
956
1102
|
target.judgeDisposition = d.disposition;
|
|
957
1103
|
target.judgeRationale = d.rationale;
|
|
958
1104
|
if (typeof d.criterion === "string" && d.criterion.trim().length > 0) {
|
|
@@ -962,6 +1108,21 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
|
|
|
962
1108
|
target.followUpDraft = d.followUpDraft;
|
|
963
1109
|
}
|
|
964
1110
|
}
|
|
1111
|
+
// Coverage is judged against THIS verdict's disposed-index set, not field
|
|
1112
|
+
// presence on the merged copy — an already-enriched ledger (a finding that
|
|
1113
|
+
// already carries judgeDisposition from a prior round) must not let a
|
|
1114
|
+
// verdict that disposes nothing pass silently. validateJudgeVerdict already
|
|
1115
|
+
// rejects duplicate indexes, so the Set is exact.
|
|
1116
|
+
const disposed = new Set(validated.dispositions.map((d) => d.index));
|
|
1117
|
+
const uncovered = enriched.reduce((positions, _f, i) => {
|
|
1118
|
+
if (!disposed.has(i)) positions.push(i);
|
|
1119
|
+
return positions;
|
|
1120
|
+
}, /** @type {number[]} */ ([]));
|
|
1121
|
+
if (uncovered.length > 0) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
`judge verdict does not dispose ${uncovered.length} finding(s) (indexes: ${uncovered.join(", ")}) — fail closed; an undisposed finding must never be silently dropped from the fixer act list`
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
965
1126
|
return { findings: enriched, scopeDrift: validated.scopeDrift };
|
|
966
1127
|
}
|
|
967
1128
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./public-dev-loop-routing-contract.mjs";
|
|
21
21
|
import { normalizeRepoSlug } from "../github/repo-slug.mjs";
|
|
22
22
|
import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
|
|
23
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
23
24
|
import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
|
|
24
25
|
import { resolveGateAngleContract, resolveGateAngles, resolveGateConfig, resolveHumanMergeOnly } from "../config/config.mjs";
|
|
25
26
|
|
|
@@ -222,16 +223,8 @@ function normalizePositiveInt(v) {
|
|
|
222
223
|
return v;
|
|
223
224
|
}
|
|
224
225
|
|
|
225
|
-
function normalizeString(v) {
|
|
226
|
-
return typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function normalizeStringOrNull(v) {
|
|
230
|
-
return v === null || v === undefined ? null : normalizeString(v);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
226
|
function requireString(v, label) {
|
|
234
|
-
const s =
|
|
227
|
+
const s = trimmedOrNull(v);
|
|
235
228
|
if (s === null) throw new Error(`handoff-envelope: ${label} is required and must be a non-empty string`);
|
|
236
229
|
return s;
|
|
237
230
|
}
|
|
@@ -264,12 +257,12 @@ function deriveTarget(bundle, repo) {
|
|
|
264
257
|
target.pr = pr;
|
|
265
258
|
if (Number.isInteger(artifact.issue) && artifact.issue > 0) target.issue = artifact.issue;
|
|
266
259
|
} else if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
|
|
267
|
-
const branch =
|
|
260
|
+
const branch = trimmedOrNull(artifact.branch);
|
|
268
261
|
if (!branch) throw new Error("handoff-envelope: local_branch target must include a non-empty branch name");
|
|
269
262
|
target.branch = branch;
|
|
270
263
|
if (Number.isInteger(artifact.issue) && artifact.issue > 0) target.issue = artifact.issue;
|
|
271
264
|
} else if (kind === DEV_LOOP_TARGET_KIND.LOCAL_PHASE) {
|
|
272
|
-
const phase =
|
|
265
|
+
const phase = trimmedOrNull(artifact.phase);
|
|
273
266
|
const validIssue = Number.isInteger(artifact.issue) && artifact.issue > 0;
|
|
274
267
|
if (!phase && !validIssue) {
|
|
275
268
|
throw new Error("handoff-envelope: local_phase target must include a non-empty phase or a valid positive issue number");
|
|
@@ -340,8 +333,8 @@ export const CANONICAL_SPEC_SOURCE = Object.freeze({
|
|
|
340
333
|
* validateHandoffEnvelope would then reject.
|
|
341
334
|
*/
|
|
342
335
|
function deriveSpecSource(bundle, resolverOutput) {
|
|
343
|
-
const raw =
|
|
344
|
-
??
|
|
336
|
+
const raw = trimmedOrNull(resolverOutput?.canonicalSpecSource)
|
|
337
|
+
?? trimmedOrNull(bundle?.canonicalSpecSource);
|
|
345
338
|
return raw === CANONICAL_SPEC_SOURCE.PHASE_DOC || raw === CANONICAL_SPEC_SOURCE.PR_BODY ? raw : null;
|
|
346
339
|
}
|
|
347
340
|
|
|
@@ -459,7 +452,7 @@ export const WORKTREE_NAMESPACE = "tmp/worktrees/dev-loops";
|
|
|
459
452
|
* @returns {string} Absolute path `<repoRoot>/tmp/worktrees/dev-loops/<kind>-<number>`
|
|
460
453
|
*/
|
|
461
454
|
export function resolveWorktreePath({ repoRoot, kind, number } = {}) {
|
|
462
|
-
const root =
|
|
455
|
+
const root = trimmedOrNull(repoRoot);
|
|
463
456
|
if (!root) throw new Error("resolveWorktreePath: repoRoot is required and must be a non-empty string");
|
|
464
457
|
const k = typeof kind === "string" ? kind.trim().toLowerCase() : "";
|
|
465
458
|
if (k !== DEV_LOOP_TARGET_KIND.ISSUE && k !== DEV_LOOP_TARGET_KIND.PR) {
|
|
@@ -486,11 +479,11 @@ function buildWorktreeSlug(artifact, kind) {
|
|
|
486
479
|
return `pr-${artifact.pr}`;
|
|
487
480
|
}
|
|
488
481
|
if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
|
|
489
|
-
const branch =
|
|
482
|
+
const branch = trimmedOrNull(artifact.branch);
|
|
490
483
|
return branch ? flattenSlugSegment(branch) : null;
|
|
491
484
|
}
|
|
492
485
|
if (kind === DEV_LOOP_TARGET_KIND.LOCAL_PHASE) {
|
|
493
|
-
const phase =
|
|
486
|
+
const phase = trimmedOrNull(artifact.phase);
|
|
494
487
|
const issue = Number.isInteger(artifact.issue) && artifact.issue > 0 ? artifact.issue : null;
|
|
495
488
|
if (phase && issue) return `phase-${issue}-${flattenSlugSegment(phase)}`;
|
|
496
489
|
if (phase) return `phase-${flattenSlugSegment(phase)}`;
|
|
@@ -508,11 +501,11 @@ function normalizeGateState(gateState) {
|
|
|
508
501
|
const gs = gateState ?? {};
|
|
509
502
|
|
|
510
503
|
return {
|
|
511
|
-
currentHeadSha:
|
|
512
|
-
ciStatus:
|
|
504
|
+
currentHeadSha: trimmedOrNull(gs.currentHeadSha) ?? null,
|
|
505
|
+
ciStatus: trimmedOrNull(gs.ciStatus) ?? null,
|
|
513
506
|
unresolvedThreadCount: normalizePositiveInt(gs.unresolvedThreadCount) ?? 0,
|
|
514
507
|
copilotRoundCount: normalizePositiveInt(gs.copilotRoundCount) ?? 0,
|
|
515
|
-
currentSubGate:
|
|
508
|
+
currentSubGate: trimmedOrNull(gs.currentSubGate) ?? undefined,
|
|
516
509
|
};
|
|
517
510
|
}
|
|
518
511
|
|