@dev-loops/core 1.0.2 → 1.0.4-pre.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 +10 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +213 -65
- package/src/config/config.mjs +473 -43
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +79 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +396 -42
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +296 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +82 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +399 -0
- package/src/loop/pr-gate-coordination.mjs +123 -12
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* finding-cluster.mjs — deterministic fan-in finding clustering for the
|
|
3
|
+
* gate-review sub-loop. Collapses duplicate findings (the SAME root cause
|
|
4
|
+
* reported by more than one reviewer/angle) into one cluster so the judge's
|
|
5
|
+
* relevance disposition and the fixer's act list both reason about root
|
|
6
|
+
* causes, not raw finding count.
|
|
7
|
+
*
|
|
8
|
+
* Pure and offline: no file reads, no network, no state held across calls.
|
|
9
|
+
* Every function deep-clones before enriching and never mutates a caller's
|
|
10
|
+
* array/objects.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { normalizeSeverity, resolveFindingFile } from "./gate-fanin.mjs";
|
|
14
|
+
|
|
15
|
+
// NUL separates the three key components so a value inside one component
|
|
16
|
+
// (e.g. a recommendation that happens to contain a colon or digits matching
|
|
17
|
+
// another component) can never be misread as spanning a boundary.
|
|
18
|
+
const ROOT_CAUSE_KEY_SEPARATOR = "\u0000";
|
|
19
|
+
|
|
20
|
+
/** @param {unknown} value @returns {boolean} */
|
|
21
|
+
function isNonEmptyString(value) {
|
|
22
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The canonical root-cause key for a finding: `(reviewed head, primary
|
|
27
|
+
* location "file:line", normalized remediation target)`. A finding missing
|
|
28
|
+
* ANY of the three components is UNKEYABLE and returns null — it is never
|
|
29
|
+
* grouped with another finding, keyable or not.
|
|
30
|
+
*
|
|
31
|
+
* @param {{file?: unknown, files?: unknown, line?: unknown, recommendation?: unknown}} finding
|
|
32
|
+
* @param {{headSha?: unknown}} [options] — the round's reviewed head; a
|
|
33
|
+
* finding never carries its own head, so this is always caller-supplied.
|
|
34
|
+
* @returns {string|null}
|
|
35
|
+
*/
|
|
36
|
+
export function computeRootCauseKey(finding, { headSha } = {}) {
|
|
37
|
+
if (!finding || typeof finding !== "object") return null;
|
|
38
|
+
if (!isNonEmptyString(headSha)) return null;
|
|
39
|
+
const file = resolveFindingFile(finding);
|
|
40
|
+
if (!isNonEmptyString(file)) return null;
|
|
41
|
+
const line = finding.line;
|
|
42
|
+
if (!Number.isInteger(line) || line < 1) return null;
|
|
43
|
+
const recommendation = finding.recommendation;
|
|
44
|
+
if (!isNonEmptyString(recommendation)) return null;
|
|
45
|
+
|
|
46
|
+
const normalizedRecommendation = recommendation.trim().toLowerCase().replace(/\s+/g, " ");
|
|
47
|
+
return [headSha.trim(), `${file}:${line}`, normalizedRecommendation].join(ROOT_CAUSE_KEY_SEPARATOR);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Group findings by EXACT root-cause key match. Every UNKEYABLE finding
|
|
52
|
+
* (see {@link computeRootCauseKey}) is its own singleton cluster, never
|
|
53
|
+
* grouped with another unkeyable finding. Deterministic: clusters are
|
|
54
|
+
* ordered by `representativeIndex` ascending (first appearance), and a
|
|
55
|
+
* cluster's `memberIndices` are ascending.
|
|
56
|
+
*
|
|
57
|
+
* FAIL-OPEN: a non-array `findings`, a missing/empty/non-string `headSha`,
|
|
58
|
+
* or any internal error returns `{ ok: false, reason, clusters }` where
|
|
59
|
+
* `clusters` is one singleton (`keyable: false`) per ORIGINAL finding in
|
|
60
|
+
* input order — so a caller passes every original finding through to the
|
|
61
|
+
* judge unchanged. Never throws.
|
|
62
|
+
*
|
|
63
|
+
* @param {unknown} findings
|
|
64
|
+
* @param {{headSha?: unknown}} [options]
|
|
65
|
+
* @returns {{ ok: true, clusters: Array<{key: string|null, keyable: boolean, memberIndices: number[], representativeIndex: number}> }
|
|
66
|
+
* | { ok: false, reason: string, clusters: Array<{key: null, keyable: false, memberIndices: number[], representativeIndex: number}> }}
|
|
67
|
+
*/
|
|
68
|
+
export function clusterFindings(findings, options = {}) {
|
|
69
|
+
try {
|
|
70
|
+
if (!Array.isArray(findings)) {
|
|
71
|
+
throw new Error("clusterFindings requires findings to be an array");
|
|
72
|
+
}
|
|
73
|
+
if (!isNonEmptyString(options?.headSha)) {
|
|
74
|
+
throw new Error("clusterFindings requires a non-empty options.headSha");
|
|
75
|
+
}
|
|
76
|
+
const headSha = options.headSha;
|
|
77
|
+
// A Map preserves insertion order, and every finding is visited index
|
|
78
|
+
// ascending below, so the FIRST index to create a cluster entry is
|
|
79
|
+
// always that cluster's smallest (representative) member — clusters is
|
|
80
|
+
// therefore already representativeIndex-ascending with no extra sort.
|
|
81
|
+
const byKey = new Map();
|
|
82
|
+
const clusters = [];
|
|
83
|
+
findings.forEach((finding, index) => {
|
|
84
|
+
const key = computeRootCauseKey(finding, { headSha });
|
|
85
|
+
if (key === null) {
|
|
86
|
+
clusters.push({ key: null, keyable: false, memberIndices: [index], representativeIndex: index });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let cluster = byKey.get(key);
|
|
90
|
+
if (!cluster) {
|
|
91
|
+
cluster = { key, keyable: true, memberIndices: [], representativeIndex: index };
|
|
92
|
+
byKey.set(key, cluster);
|
|
93
|
+
clusters.push(cluster);
|
|
94
|
+
}
|
|
95
|
+
cluster.memberIndices.push(index);
|
|
96
|
+
});
|
|
97
|
+
return { ok: true, clusters };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
103
|
+
clusters: list.map((_finding, index) => ({ key: null, keyable: false, memberIndices: [index], representativeIndex: index })),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Reconstruct the clusters array from findings a producer already stamped
|
|
110
|
+
* with `clusterId` — the LOSSLESS identity consolidate-fanin.mjs computes
|
|
111
|
+
* (via {@link clusterFindings}) on pre-truncation finding text and stamps
|
|
112
|
+
* onto each ledger finding as `finding.clusterId = cluster.representativeIndex`.
|
|
113
|
+
* A consumer reading the ledger back (e.g. judge-pass.mjs) must group by this
|
|
114
|
+
* stamp rather than re-running {@link clusterFindings} on the ledger's own
|
|
115
|
+
* `recommendation`/`file` text, which the ledger pipeline TRUNCATES after
|
|
116
|
+
* clustering — re-deriving from truncated text can merge findings that only
|
|
117
|
+
* share a long truncated prefix, silently discarding the lossless grouping.
|
|
118
|
+
*
|
|
119
|
+
* Every finding must carry an integer `clusterId` (fails closed otherwise —
|
|
120
|
+
* a caller unsure every finding is stamped should fall back to
|
|
121
|
+
* {@link clusterFindings} instead of calling this). Deterministic: clusters
|
|
122
|
+
* are ordered by `representativeIndex` ascending, and each cluster's
|
|
123
|
+
* `memberIndices` are ascending (findings are visited index-ascending).
|
|
124
|
+
*
|
|
125
|
+
* @param {Array<{clusterId?: unknown}>} findings
|
|
126
|
+
* @returns {Array<{key: null, keyable: true, memberIndices: number[], representativeIndex: number}>}
|
|
127
|
+
*/
|
|
128
|
+
export function clustersFromStampedIds(findings) {
|
|
129
|
+
if (!Array.isArray(findings)) {
|
|
130
|
+
throw new TypeError("clustersFromStampedIds requires findings to be an array");
|
|
131
|
+
}
|
|
132
|
+
const byClusterId = new Map();
|
|
133
|
+
findings.forEach((finding, index) => {
|
|
134
|
+
const clusterId = finding?.clusterId;
|
|
135
|
+
if (!Number.isInteger(clusterId)) {
|
|
136
|
+
throw new TypeError(`clustersFromStampedIds requires every finding to carry an integer clusterId (index ${index} does not)`);
|
|
137
|
+
}
|
|
138
|
+
let cluster = byClusterId.get(clusterId);
|
|
139
|
+
if (!cluster) {
|
|
140
|
+
cluster = { key: null, keyable: true, memberIndices: [], representativeIndex: clusterId };
|
|
141
|
+
byClusterId.set(clusterId, cluster);
|
|
142
|
+
}
|
|
143
|
+
cluster.memberIndices.push(index);
|
|
144
|
+
});
|
|
145
|
+
return [...byClusterId.values()].sort((a, b) => a.representativeIndex - b.representativeIndex);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Project every multi-member cluster's REPRESENTATIVE judge disposition
|
|
150
|
+
* (`judgeDisposition`/`judgeRationale`/`judgeCriterion`/`followUpDraft`) onto
|
|
151
|
+
* every member of that cluster — one judge decision per root cause, applied
|
|
152
|
+
* to every finding reporting it. Singleton clusters are unchanged. Pure:
|
|
153
|
+
* deep-clones before enriching, never mutates `enrichedFindings`.
|
|
154
|
+
*
|
|
155
|
+
* Fails closed (throws) when a cluster is malformed or its
|
|
156
|
+
* `memberIndices`/`representativeIndex` reference an out-of-range position.
|
|
157
|
+
*
|
|
158
|
+
* @param {Array<object>} enrichedFindings
|
|
159
|
+
* @param {Array<{memberIndices: number[], representativeIndex: number}>} clusters
|
|
160
|
+
* @returns {Array<object>} a new, deep-cloned array.
|
|
161
|
+
*/
|
|
162
|
+
export function projectClusterDisposition(enrichedFindings, clusters) {
|
|
163
|
+
if (!Array.isArray(enrichedFindings)) {
|
|
164
|
+
throw new TypeError("projectClusterDisposition requires enrichedFindings to be an array");
|
|
165
|
+
}
|
|
166
|
+
if (!Array.isArray(clusters)) {
|
|
167
|
+
throw new TypeError("projectClusterDisposition requires clusters to be an array");
|
|
168
|
+
}
|
|
169
|
+
const result = enrichedFindings.map((finding) => structuredClone(finding));
|
|
170
|
+
for (const cluster of clusters) {
|
|
171
|
+
if (!cluster || !Array.isArray(cluster.memberIndices) || !Number.isInteger(cluster.representativeIndex)) {
|
|
172
|
+
throw new TypeError("projectClusterDisposition requires every cluster to carry memberIndices[] and a representativeIndex");
|
|
173
|
+
}
|
|
174
|
+
if (cluster.representativeIndex < 0 || cluster.representativeIndex >= result.length) {
|
|
175
|
+
throw new RangeError(`projectClusterDisposition: cluster representativeIndex ${cluster.representativeIndex} is out of range (${result.length} findings)`);
|
|
176
|
+
}
|
|
177
|
+
for (const memberIndex of cluster.memberIndices) {
|
|
178
|
+
if (!Number.isInteger(memberIndex) || memberIndex < 0 || memberIndex >= result.length) {
|
|
179
|
+
throw new RangeError(`projectClusterDisposition: cluster memberIndices references out-of-range index ${memberIndex} (${result.length} findings)`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (cluster.memberIndices.length === 0) {
|
|
183
|
+
throw new RangeError("projectClusterDisposition: a cluster must have at least one member (memberIndices is empty)");
|
|
184
|
+
}
|
|
185
|
+
if (!cluster.memberIndices.includes(cluster.representativeIndex)) {
|
|
186
|
+
throw new RangeError(
|
|
187
|
+
`projectClusterDisposition: cluster representativeIndex ${cluster.representativeIndex} is not one of its own memberIndices [${cluster.memberIndices.join(", ")}]`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (cluster.memberIndices.length <= 1) continue; // singleton: shape validated above, nothing to project.
|
|
191
|
+
const representative = result[cluster.representativeIndex];
|
|
192
|
+
for (const memberIndex of cluster.memberIndices) {
|
|
193
|
+
const target = result[memberIndex];
|
|
194
|
+
target.judgeDisposition = representative.judgeDisposition;
|
|
195
|
+
target.judgeRationale = representative.judgeRationale;
|
|
196
|
+
if (representative.judgeCriterion !== undefined) target.judgeCriterion = representative.judgeCriterion;
|
|
197
|
+
else delete target.judgeCriterion;
|
|
198
|
+
if (representative.followUpDraft !== undefined) target.followUpDraft = representative.followUpDraft;
|
|
199
|
+
else delete target.followUpDraft;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reduce an ACT list to at most one finding per acted cluster — the fixer's
|
|
207
|
+
* one-remediation-per-root-cause input. `allFindings` is the SAME array
|
|
208
|
+
* `clusters` was computed against; each act finding is matched back to its
|
|
209
|
+
* original position in it by reference, then to that position's cluster.
|
|
210
|
+
*
|
|
211
|
+
* A cluster's `representativeIndex` is by construction its smallest member
|
|
212
|
+
* index (see {@link clusterFindings}), so — given `actFindings` preserves
|
|
213
|
+
* the round's original relative order, which every producer in this
|
|
214
|
+
* pipeline does — the first member of a cluster encountered here is always
|
|
215
|
+
* either the representative itself, or, when the representative was not
|
|
216
|
+
* itself act-disposed, the earliest-index act member of that cluster: the
|
|
217
|
+
* exact preference rule this function implements, with no extra bookkeeping.
|
|
218
|
+
*
|
|
219
|
+
* A finding whose cluster cannot be resolved (absent from `allFindings`, or
|
|
220
|
+
* no cluster covers its position) passes through unchanged — fail-open,
|
|
221
|
+
* never silently dropped.
|
|
222
|
+
*
|
|
223
|
+
* @param {Array<object>} actFindings
|
|
224
|
+
* @param {Array<{memberIndices: number[], representativeIndex: number}>} clusters
|
|
225
|
+
* @param {Array<object>} allFindings
|
|
226
|
+
* @returns {Array<object>}
|
|
227
|
+
*/
|
|
228
|
+
export function dedupeActListByCluster(actFindings, clusters, allFindings) {
|
|
229
|
+
if (!Array.isArray(actFindings)) {
|
|
230
|
+
throw new TypeError("dedupeActListByCluster requires actFindings to be an array");
|
|
231
|
+
}
|
|
232
|
+
const clusterList = Array.isArray(clusters) ? clusters : [];
|
|
233
|
+
const findingsList = Array.isArray(allFindings) ? allFindings : [];
|
|
234
|
+
|
|
235
|
+
const representativeIndexByPosition = new Map();
|
|
236
|
+
for (const cluster of clusterList) {
|
|
237
|
+
if (!cluster || !Array.isArray(cluster.memberIndices) || !Number.isInteger(cluster.representativeIndex)) continue;
|
|
238
|
+
for (const memberIndex of cluster.memberIndices) {
|
|
239
|
+
representativeIndexByPosition.set(memberIndex, cluster.representativeIndex);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const seenClusters = new Set();
|
|
244
|
+
const kept = [];
|
|
245
|
+
for (const finding of actFindings) {
|
|
246
|
+
const position = findingsList.indexOf(finding);
|
|
247
|
+
const representativeIndex = position === -1 ? undefined : representativeIndexByPosition.get(position);
|
|
248
|
+
if (representativeIndex === undefined) {
|
|
249
|
+
kept.push(finding); // Fail-open: unresolvable membership always passes through.
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (seenClusters.has(representativeIndex)) continue;
|
|
253
|
+
seenClusters.add(representativeIndex);
|
|
254
|
+
kept.push(finding);
|
|
255
|
+
}
|
|
256
|
+
return kept;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A "clean" verdict means no finding at a BLOCKING severity remains open. It is
|
|
261
|
+
* invalid only when a finding at a blocking severity was acted on — that is
|
|
262
|
+
* unresolved blocking work, so the round cannot be clean. Acting on a
|
|
263
|
+
* NON-BLOCKING finding (a medium in the fix window, a low the fixer triages) is
|
|
264
|
+
* expected under a clean verdict per `GATE-EXEC-BLOCKING-ONLY-FIX`: the fix
|
|
265
|
+
* cycle covers non-blocking findings even though they never block clean, so a
|
|
266
|
+
* clean verdict routinely carries non-blocking act findings.
|
|
267
|
+
*
|
|
268
|
+
* Throws a clear Error on `overallVerdict === "clean"` with any act finding at a
|
|
269
|
+
* blocking severity; returns `overallVerdict` unchanged otherwise.
|
|
270
|
+
*
|
|
271
|
+
* @param {unknown} overallVerdict
|
|
272
|
+
* @param {Array<{severity?: unknown}>} actFindings — the round's act-disposed findings.
|
|
273
|
+
* @param {string[]} [blockingSeverities] — the gate's blocking severities (default ["high"]).
|
|
274
|
+
* @returns {unknown} `overallVerdict`, unchanged.
|
|
275
|
+
*/
|
|
276
|
+
export function assertCleanImpliesNoBlockingAct(overallVerdict, actFindings, blockingSeverities) {
|
|
277
|
+
if (!Array.isArray(actFindings)) {
|
|
278
|
+
throw new TypeError("assertCleanImpliesNoBlockingAct requires actFindings to be an array");
|
|
279
|
+
}
|
|
280
|
+
if (overallVerdict !== "clean") return overallVerdict;
|
|
281
|
+
const blocking = new Set(
|
|
282
|
+
(Array.isArray(blockingSeverities) && blockingSeverities.length > 0 ? blockingSeverities : ["high"]).map((s) =>
|
|
283
|
+
normalizeSeverity(s),
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
const offending = actFindings.filter((f) => blocking.has(normalizeSeverity(f?.severity)));
|
|
287
|
+
if (offending.length > 0) {
|
|
288
|
+
const severities = [...new Set(offending.map((f) => normalizeSeverity(f?.severity)))].join(", ");
|
|
289
|
+
throw new Error(
|
|
290
|
+
`clean verdict is invalid with ${offending.length} acted finding(s) at a blocking severity (${severities}): ` +
|
|
291
|
+
`a blocking-severity finding acted on this round cannot be clean. Non-blocking act findings are allowed under ` +
|
|
292
|
+
`a clean verdict (GATE-EXEC-BLOCKING-ONLY-FIX).`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return overallVerdict;
|
|
296
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// GATE-EXEC-FIXER-DISPOSITION-BOUNDARY (skills/docs/gate-review-sub-loop-contract.md):
|
|
2
|
+
// fail closed when a fixer push leaves the review threads it claims to have
|
|
3
|
+
// tackled incomplete (missing evidence, uncontained commit, unreplied, or
|
|
4
|
+
// unresolved). This module is a PURE evaluator — every GitHub/git fact
|
|
5
|
+
// (live thread state, commit containment) is injected as data, never fetched
|
|
6
|
+
// here, so the same decision is shared verbatim across every harness.
|
|
7
|
+
|
|
8
|
+
/** Disposition values a handoff entry may carry. Only "tackled" entries are
|
|
9
|
+
* auto-disposed by evaluateFixerDisposition; every other value keeps its
|
|
10
|
+
* existing judgment path untouched (see the boundary rule's scope note). */
|
|
11
|
+
export const FIXER_DISPOSITION_KIND = Object.freeze({
|
|
12
|
+
TACKLED: "tackled",
|
|
13
|
+
DEFERRED: "deferred",
|
|
14
|
+
REJECTED: "rejected",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const FIXER_DISPOSITION_FAILED_STEP = Object.freeze({
|
|
18
|
+
MISSING_FROM_HANDOFF: "missing_from_handoff",
|
|
19
|
+
COMMIT_NOT_CONTAINED: "commit_not_contained",
|
|
20
|
+
REPLY_MISSING: "reply_missing",
|
|
21
|
+
NOT_RESOLVED: "not_resolved",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// The only legal next action while disposition is incomplete. Kept as a plain
|
|
25
|
+
// literal (not imported from pr-gate-coordination.mjs) so this module stays a
|
|
26
|
+
// dependency-free leaf; pr-gate-coordination.mjs's own PR_CHECKPOINT_ACTION
|
|
27
|
+
// token for this action is asserted (by test) to equal this same string.
|
|
28
|
+
export const COMPLETE_FIXER_DISPOSITION_ACTION = "complete_fixer_disposition";
|
|
29
|
+
|
|
30
|
+
// Broader than pr-gate-coordination's postDraftForbidden: the failure this
|
|
31
|
+
// boundary closes is the NEXT review/gate round opening over a dirty review
|
|
32
|
+
// surface, so every review-(re)request and gate-dispatch token is forbidden
|
|
33
|
+
// too, not just the draft/merge transition tokens. Literal tokens (not
|
|
34
|
+
// imported) for the same dependency-free-leaf reason as above.
|
|
35
|
+
export const FIXER_DISPOSITION_FORBIDDEN_ACTIONS = Object.freeze([
|
|
36
|
+
"run_draft_gate",
|
|
37
|
+
"reconcile_draft_gate",
|
|
38
|
+
"mark_ready_for_review",
|
|
39
|
+
"request_copilot_review",
|
|
40
|
+
"rerequest_copilot_review",
|
|
41
|
+
"run_pre_approval_gate",
|
|
42
|
+
"await_final_human_approval",
|
|
43
|
+
"declare_merge_ready",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function isNonEmptyString(value) {
|
|
47
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate + normalize a raw fixer-disposition handoff. Throws on any
|
|
52
|
+
* structural gap the evaluator must never silently tolerate: a missing
|
|
53
|
+
* headSha, a non-array dispositions field, a missing
|
|
54
|
+
* threadId/fixingCommitSha/disposition on any entry, an unrecognized
|
|
55
|
+
* disposition value, or a duplicate threadId/fingerprint across entries.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} raw
|
|
58
|
+
* @returns {{ headSha: string, dispositions: Array<{ threadId: string, fingerprint: string|null, fixingCommitSha: string, disposition: string, validation: string|null }> }}
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeFixerDispositionHandoff(raw) {
|
|
61
|
+
if (!raw || typeof raw !== "object") {
|
|
62
|
+
throw new Error("Fixer disposition handoff must be an object");
|
|
63
|
+
}
|
|
64
|
+
if (!isNonEmptyString(raw.headSha)) {
|
|
65
|
+
throw new Error("Fixer disposition handoff is missing headSha");
|
|
66
|
+
}
|
|
67
|
+
if (raw.dispositions !== undefined && !Array.isArray(raw.dispositions)) {
|
|
68
|
+
throw new Error("Fixer disposition handoff dispositions must be an array");
|
|
69
|
+
}
|
|
70
|
+
const rawDispositions = raw.dispositions ?? [];
|
|
71
|
+
const seenThreadIds = new Set();
|
|
72
|
+
const seenFingerprints = new Set();
|
|
73
|
+
const dispositions = rawDispositions.map((entry, index) => {
|
|
74
|
+
if (!entry || typeof entry !== "object") {
|
|
75
|
+
throw new Error(`Fixer disposition handoff entry ${index} must be an object`);
|
|
76
|
+
}
|
|
77
|
+
if (!isNonEmptyString(entry.threadId)) {
|
|
78
|
+
throw new Error(`Fixer disposition handoff entry ${index} is missing threadId`);
|
|
79
|
+
}
|
|
80
|
+
const threadId = entry.threadId.trim();
|
|
81
|
+
if (seenThreadIds.has(threadId)) {
|
|
82
|
+
throw new Error(`Fixer disposition handoff has a duplicate threadId: ${threadId}`);
|
|
83
|
+
}
|
|
84
|
+
seenThreadIds.add(threadId);
|
|
85
|
+
if (!isNonEmptyString(entry.fixingCommitSha)) {
|
|
86
|
+
throw new Error(`Fixer disposition handoff entry for thread ${threadId} is missing fixingCommitSha`);
|
|
87
|
+
}
|
|
88
|
+
if (!isNonEmptyString(entry.disposition)) {
|
|
89
|
+
throw new Error(`Fixer disposition handoff entry for thread ${threadId} is missing disposition`);
|
|
90
|
+
}
|
|
91
|
+
const fingerprint = isNonEmptyString(entry.fingerprint) ? entry.fingerprint.trim() : null;
|
|
92
|
+
if (fingerprint !== null) {
|
|
93
|
+
if (seenFingerprints.has(fingerprint)) {
|
|
94
|
+
throw new Error(`Fixer disposition handoff has a duplicate fingerprint: ${fingerprint}`);
|
|
95
|
+
}
|
|
96
|
+
seenFingerprints.add(fingerprint);
|
|
97
|
+
}
|
|
98
|
+
const disposition = entry.disposition.trim().toLowerCase();
|
|
99
|
+
if (!Object.values(FIXER_DISPOSITION_KIND).includes(disposition)) {
|
|
100
|
+
throw new Error(`Fixer disposition handoff entry for thread ${threadId} has an unrecognized disposition: ${disposition}`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
threadId,
|
|
104
|
+
fingerprint,
|
|
105
|
+
fixingCommitSha: entry.fixingCommitSha.trim(),
|
|
106
|
+
disposition,
|
|
107
|
+
validation: isNonEmptyString(entry.validation) ? entry.validation.trim() : null,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
headSha: raw.headSha.trim(),
|
|
112
|
+
dispositions,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatIncompleteReason(incomplete) {
|
|
117
|
+
const parts = incomplete.map((entry) => (
|
|
118
|
+
`thread ${entry.threadId} (expected commit ${entry.expectedCommit ?? "unknown"}, failed step: ${entry.failedStep})`
|
|
119
|
+
));
|
|
120
|
+
return `Fixer disposition is incomplete for ${incomplete.length} tackled thread(s): ${parts.join("; ")}. `
|
|
121
|
+
+ `The only legal next action is ${COMPLETE_FIXER_DISPOSITION_ACTION}.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* PURE evaluator: given a normalized handoff, the live thread state, and an
|
|
126
|
+
* injected commit-containment fact table, decide whether every thread the
|
|
127
|
+
* fixer claims to have tackled is fully disposed (commit contained, replied
|
|
128
|
+
* with that commit's evidence, and resolved). No I/O — every fact the
|
|
129
|
+
* decision needs is a plain argument, which is what keeps this seam shared
|
|
130
|
+
* verbatim across harnesses.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} params
|
|
133
|
+
* @param {{ headSha: string, dispositions: Array<object> }} params.handoff - raw or already-normalized; ALWAYS re-validated via normalizeFixerDispositionHandoff (idempotent on already-normalized input), so a malformed array-bearing handoff can never bypass schema+enum validation just by already carrying an array
|
|
134
|
+
* @param {Array<{ threadId: string, isResolved: boolean, replyBodies?: string[], claimedTackled?: boolean }>} [params.liveThreads]
|
|
135
|
+
* @param {Record<string, boolean>} [params.containment] - fixingCommitSha -> true when the observed PR head contains it
|
|
136
|
+
* @returns {{ ok: boolean, incomplete: Array<{ threadId: string, expectedCommit: string|null, failedStep: string }>, forbiddenActions: string[], nextAction: string|null, reason: string|null }}
|
|
137
|
+
*/
|
|
138
|
+
export function evaluateFixerDisposition({ handoff, liveThreads = [], containment = {} } = {}) {
|
|
139
|
+
const normalizedHandoff = normalizeFixerDispositionHandoff(handoff ?? {});
|
|
140
|
+
const handoffByThreadId = new Map(normalizedHandoff.dispositions.map((entry) => [entry.threadId, entry]));
|
|
141
|
+
const liveByThreadId = new Map(
|
|
142
|
+
(Array.isArray(liveThreads) ? liveThreads : [])
|
|
143
|
+
.filter((entry) => entry && typeof entry.threadId === "string")
|
|
144
|
+
.map((entry) => [entry.threadId, entry]),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// The must-check set: every handoff entry explicitly marked "tackled", plus
|
|
148
|
+
// any live thread independently flagged claimedTackled (e.g. a fixer's own
|
|
149
|
+
// claim the CLI cross-referenced against live GitHub state) that has no
|
|
150
|
+
// handoff counterpart at all — that gap is exactly missing_from_handoff.
|
|
151
|
+
const tackledThreadIds = new Set();
|
|
152
|
+
for (const entry of normalizedHandoff.dispositions) {
|
|
153
|
+
if (entry.disposition === FIXER_DISPOSITION_KIND.TACKLED) {
|
|
154
|
+
tackledThreadIds.add(entry.threadId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const liveThread of liveByThreadId.values()) {
|
|
158
|
+
if (liveThread.claimedTackled === true) {
|
|
159
|
+
tackledThreadIds.add(liveThread.threadId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const incomplete = [];
|
|
164
|
+
for (const threadId of tackledThreadIds) {
|
|
165
|
+
const handoffEntry = handoffByThreadId.get(threadId) ?? null;
|
|
166
|
+
if (!handoffEntry) {
|
|
167
|
+
incomplete.push({ threadId, expectedCommit: null, failedStep: FIXER_DISPOSITION_FAILED_STEP.MISSING_FROM_HANDOFF });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const expectedCommit = handoffEntry.fixingCommitSha;
|
|
171
|
+
// Non-goal enforcement: a SHA alone is never evidence. Only an injected
|
|
172
|
+
// containment[sha] === true (the observed PR head provably contains the
|
|
173
|
+
// commit) can authorize the reply/resolve steps below.
|
|
174
|
+
if (containment?.[expectedCommit] !== true) {
|
|
175
|
+
incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.COMMIT_NOT_CONTAINED });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const liveThread = liveByThreadId.get(threadId) ?? null;
|
|
179
|
+
const replyBodies = Array.isArray(liveThread?.replyBodies) ? liveThread.replyBodies : [];
|
|
180
|
+
const hasEvidencedReply = replyBodies.some((body) => (
|
|
181
|
+
typeof body === "string" && body.toLowerCase().includes(expectedCommit.toLowerCase())
|
|
182
|
+
));
|
|
183
|
+
if (!hasEvidencedReply) {
|
|
184
|
+
incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.REPLY_MISSING });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (liveThread?.isResolved !== true) {
|
|
188
|
+
incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.NOT_RESOLVED });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ok = incomplete.length === 0;
|
|
193
|
+
return {
|
|
194
|
+
ok,
|
|
195
|
+
incomplete,
|
|
196
|
+
forbiddenActions: ok ? [] : [...FIXER_DISPOSITION_FORBIDDEN_ACTIONS],
|
|
197
|
+
nextAction: ok ? null : COMPLETE_FIXER_DISPOSITION_ACTION,
|
|
198
|
+
reason: ok ? null : formatIncompleteReason(incomplete),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -177,10 +177,19 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
|
177
177
|
* @param {AngleReviewSurface} [input.angleSurface] — the angle's declared surface;
|
|
178
178
|
* derived from {@link angleReviewSurface} when omitted.
|
|
179
179
|
* @param {string[]} input.changedFiles — repo-relative paths changed between head
|
|
180
|
-
* A and head B (the delta, NOT the full PR diff against base).
|
|
180
|
+
* A and head B (the delta, NOT the full PR diff against base). For a base-move
|
|
181
|
+
* re-gate the caller passes the MAIN-RELATIVE incremental delta: files changed
|
|
182
|
+
* since head A whose head-B content is genuinely PR-own (differs from
|
|
183
|
+
* origin/main), NOT the raw two-dot A..B delta — so an integrate-only base-move
|
|
184
|
+
* that only replays already-merged main commits contributes an empty delta.
|
|
181
185
|
* @param {string} input.prevVerdict — the angle's verdict at head A. "clean" and
|
|
182
186
|
* "findings_present" are carry-forward-eligible; anything else (e.g.
|
|
183
187
|
* "blocked", missing) is not.
|
|
188
|
+
* @param {boolean} [input.deltaComplete=false] — the caller PROVES `changedFiles`
|
|
189
|
+
* is the complete, successfully-computed delta (git succeeded and, for a
|
|
190
|
+
* base-move, the main-relative reduction ran). Only then does an EMPTY delta
|
|
191
|
+
* mean "nothing PR-own changed" and carry forward; without the proof an empty
|
|
192
|
+
* delta is indistinguishable from an unavailable one and still fails closed.
|
|
184
193
|
* @returns {{ carryForward: boolean, reason: string }}
|
|
185
194
|
*/
|
|
186
195
|
|
|
@@ -207,7 +216,7 @@ export function isDevLoopConfigSourcePath(filePath) {
|
|
|
207
216
|
return DEV_LOOP_CONFIG_SOURCE_RE.test(filePath.trim().replace(/\\/g, "/"));
|
|
208
217
|
}
|
|
209
218
|
|
|
210
|
-
export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
|
|
219
|
+
export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict, deltaComplete = false }) {
|
|
211
220
|
if (!CARRY_FORWARD_ELIGIBLE_VERDICTS.has(prevVerdict)) {
|
|
212
221
|
return {
|
|
213
222
|
carryForward: false,
|
|
@@ -221,7 +230,16 @@ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, pr
|
|
|
221
230
|
if (surface.kind === "unknown") {
|
|
222
231
|
return { carryForward: false, reason: "angle has no declared review surface (fail-closed)" };
|
|
223
232
|
}
|
|
224
|
-
if (!Array.isArray(changedFiles)
|
|
233
|
+
if (!Array.isArray(changedFiles)) {
|
|
234
|
+
return { carryForward: false, reason: "delta is unavailable (fail-closed)" };
|
|
235
|
+
}
|
|
236
|
+
// A PROVEN-complete empty delta (deltaComplete) means the main-relative
|
|
237
|
+
// reduction found NO PR-own change since the prior reviewed head — an
|
|
238
|
+
// integrate-only base-move that only replays already-merged main commits.
|
|
239
|
+
// That carries forward: the zero-file loop below proves the surface untouched.
|
|
240
|
+
// Without that proof an empty delta is indistinguishable from an unavailable
|
|
241
|
+
// one, so it still fails closed.
|
|
242
|
+
if (changedFiles.length === 0 && !deltaComplete) {
|
|
225
243
|
return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
|
|
226
244
|
}
|
|
227
245
|
for (const file of changedFiles) {
|
|
@@ -287,12 +305,22 @@ const COPILOT_REVIEW_SURFACE_KINDS = new Set(["code", "test", "config", "ci"]);
|
|
|
287
305
|
* re-runs, since classifyFile is path-based). Any code/test/config/CI file, an unclassifiable
|
|
288
306
|
* file, or an empty/unavailable delta -> re-run (fresh blocking round required).
|
|
289
307
|
*
|
|
308
|
+
* `deltaComplete` mirrors {@link resolveAngleCarryForward}: when the caller PROVES
|
|
309
|
+
* `changedFiles` is the complete main-relative reduction, an EMPTY delta means an
|
|
310
|
+
* integrate-only base-move touched no Copilot surface and the convergence carries
|
|
311
|
+
* forward. Without the proof an empty delta still fails closed.
|
|
312
|
+
*
|
|
290
313
|
* @param {object} input
|
|
291
314
|
* @param {string[]} input.changedFiles — delta since the converged head
|
|
315
|
+
* @param {boolean} [input.deltaComplete=false] — proof the empty case is a real
|
|
316
|
+
* "nothing PR-own changed", not an unavailable delta
|
|
292
317
|
* @returns {{ carryForward: boolean, reason: string }}
|
|
293
318
|
*/
|
|
294
|
-
export function resolveConvergenceCarryForward({ changedFiles }) {
|
|
295
|
-
if (!Array.isArray(changedFiles)
|
|
319
|
+
export function resolveConvergenceCarryForward({ changedFiles, deltaComplete = false }) {
|
|
320
|
+
if (!Array.isArray(changedFiles)) {
|
|
321
|
+
return { carryForward: false, reason: "delta is unavailable (fail-closed)" };
|
|
322
|
+
}
|
|
323
|
+
if (changedFiles.length === 0 && !deltaComplete) {
|
|
296
324
|
return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
|
|
297
325
|
}
|
|
298
326
|
for (const file of changedFiles) {
|
|
@@ -304,5 +332,10 @@ export function resolveConvergenceCarryForward({ changedFiles }) {
|
|
|
304
332
|
return { carryForward: false, reason: `delta touches Copilot's review surface (${kind}): ${file}` };
|
|
305
333
|
}
|
|
306
334
|
}
|
|
307
|
-
return {
|
|
335
|
+
return {
|
|
336
|
+
carryForward: true,
|
|
337
|
+
reason: changedFiles.length === 0
|
|
338
|
+
? "no PR-own change since the converged head (integrate-only base-move), provably outside Copilot's review surface"
|
|
339
|
+
: "delta is a pure doc/prose bump, provably outside Copilot's review surface",
|
|
340
|
+
};
|
|
308
341
|
}
|