@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.1

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.
@@ -20,7 +20,7 @@
20
20
  * precondition, and the aggregate naming.
21
21
  */
22
22
 
23
- import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
23
+ import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES, extractReviewCommitSha } from "../github/copilot-helpers.mjs";
24
24
  import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
25
25
  import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
26
26
  import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
@@ -84,6 +84,15 @@ function reviewCommit(entry) {
84
84
  return null;
85
85
  }
86
86
 
87
+ // Copilot-only login extraction for evaluateCopilotConvergence: accepts the
88
+ // `gh pr view` GraphQL shape (author.login) in addition to the REST shape
89
+ // (user.login | login) reviewLogin reads. Scoped to the Copilot-convergence
90
+ // eval so verifyFreshHumanApproval's human-approval matching is unchanged.
91
+ function copilotConvergenceReviewLogin(entry) {
92
+ if (typeof entry?.author?.login === "string" && entry.author.login.length > 0) return entry.author.login;
93
+ return reviewLogin(entry);
94
+ }
95
+
87
96
  /**
88
97
  * Verify a fresh, agent-unforgeable, head-pinned human approval by `approvedBy`.
89
98
  *
@@ -153,13 +162,24 @@ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews =
153
162
  };
154
163
  }
155
164
 
165
+ export const COPILOT_CONVERGENCE_STATE = Object.freeze({
166
+ CURRENT_HEAD_CLEAN: "current_head_clean",
167
+ CURRENT_HEAD_FINDINGS: "current_head_findings",
168
+ NO_CURRENT_HEAD_REVIEW: "no_current_head_review",
169
+ });
170
+
171
+ /** ADR 0012 end states that may satisfy convergence without a current-head review. */
172
+ export const COPILOT_ABSENT_REVIEW_DISPOSITION = Object.freeze({
173
+ ROUND_CAP_CLEAN_FALLBACK: "round_cap_clean_fallback",
174
+ DOCS_ONLY_SUPPRESSION: "docs_only_suppression",
175
+ COPILOT_GATE_DISABLED: "copilot_gate_disabled",
176
+ });
177
+ const SANCTIONED_ABSENT_REVIEW_DISPOSITIONS = new Set(Object.values(COPILOT_ABSENT_REVIEW_DISPOSITION));
178
+
156
179
  /**
157
- * Copilot-convergence merge precondition. Wires the current-head Copilot
158
- * body-disposition detection into the merge gate so the merge wrapper
159
- * and the loop (`copilotBodyFeedbackUnresolved`) read the SAME classification
160
- * (`classifyCopilotReviewBodyDisposition`). The classification is shared, so it
161
- * cannot drift; the POLICY differs by design (the loop self-blocks on 🔵, this
162
- * gate treats 🔵 as conductor-overridable). Fail-closed.
180
+ * Shared Copilot-convergence evaluation for pre-approval entry and merge.
181
+ * Both treat a thread-clean 🔵 as conductor-overridable; unresolved threads
182
+ * still block independently. Fail-closed.
163
183
  *
164
184
  * Only the LATEST Copilot review pinned to `currentHeadSha` is judged, so a
165
185
  * stale non-approval at an earlier head never blocks and a later same-head 🟢
@@ -175,16 +195,28 @@ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews =
175
195
  * threads — the conductor's override is choosing to run the merge on a
176
196
  * thread-clean 🔵.
177
197
  * unrecognized disposition -> BLOCK (fail closed on a Copilot format change).
178
- * 🟢 clean / no current-head Copilot review / stale earlier-head -> PASS.
198
+ * 🟢 clean / headerless -> PASS.
199
+ *
200
+ * Three distinct states (`state` on the result):
201
+ * current_head_clean a current-head review passes (🟢 / headerless / 🔵);
202
+ * current_head_findings a current-head review blocks (🟡 / unrecognized);
203
+ * no_current_head_review no current-head Copilot review exists (none yet, or
204
+ * every review is on an earlier head). This state
205
+ * passes ONLY through a sanctioned disposition recorded
206
+ * for the current head (`absentReviewDisposition`
207
+ * `{ kind, headSha }`, kind in
208
+ * COPILOT_ABSENT_REVIEW_DISPOSITION). Absence alone never
209
+ * converges, and a stale earlier-head review is never
210
+ * inherited as the current-head verdict.
179
211
  *
180
- * @returns {{ ok: boolean, disposition: string|null, reason: string|null }}
212
+ * @returns {{ ok: boolean, state: string|null, disposition: string|null, reason: string|null }}
181
213
  */
182
- export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [] } = {}) {
214
+ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [], absentReviewDisposition = null } = {}) {
183
215
  const head = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
184
216
  // Head unknown: no current-head review can be pinned. Fail closed (matches
185
217
  // verifyFreshHumanApproval), so this precondition can never pass without a
186
218
  // known head to pin the Copilot disposition to.
187
- if (head.length === 0) return { ok: false, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
219
+ if (head.length === 0) return { ok: false, state: null, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
188
220
 
189
221
  // Mirror summarizeCopilotReviews' current-head finding selection BYTE-FOR-BYTE
190
222
  // so the merge gate and the loop can never diverge (they already share
@@ -200,9 +232,17 @@ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = []
200
232
  let latestDisposition = null;
201
233
  let latestAt = null;
202
234
  for (const entry of Array.isArray(reviews) ? reviews : []) {
203
- const login = reviewLogin(entry);
235
+ // Shape-tolerant Copilot-login + commit extraction: the merge gate feeds
236
+ // REST-shaped reviews (user.login/commit_id) while the gate-ENTRY detector
237
+ // feeds `gh pr view` GraphQL-shaped reviews (author.login/commit.oid). Both
238
+ // call sites route through this one evaluateCopilotConvergence, so it must
239
+ // recognize BOTH shapes to produce ONE convergence verdict. Only Copilot
240
+ // reviews matter here, so broadening the login read to author.login cannot
241
+ // affect verifyFreshHumanApproval (which keeps its own REST-only
242
+ // reviewLogin/reviewCommit).
243
+ const login = copilotConvergenceReviewLogin(entry);
204
244
  if (login === null || !isCopilotLogin(login)) continue;
205
- if (reviewCommit(entry) !== head) continue; // only current-head reviews
245
+ if (extractReviewCommitSha(entry) !== head) continue; // only current-head reviews
206
246
  const state = typeof entry?.state === "string" ? entry.state.toUpperCase() : "";
207
247
  if (state === "PENDING" || !SUBMITTED_REVIEW_STATES.has(state)) continue; // PENDING/unknown never sets the finding
208
248
  const disposition = classifyCopilotReviewBodyDisposition(state, entry?.body);
@@ -219,16 +259,29 @@ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = []
219
259
  }
220
260
  // a null submittedAt once a non-null latest exists is ignored (mirrors summarize)
221
261
  }
222
- if (latestDisposition === null) return { ok: true, disposition: null, reason: null };
262
+ if (latestDisposition === null) {
263
+ const absent = COPILOT_CONVERGENCE_STATE.NO_CURRENT_HEAD_REVIEW;
264
+ const kind = absentReviewDisposition?.kind;
265
+ if (SANCTIONED_ABSENT_REVIEW_DISPOSITIONS.has(kind) && absentReviewDisposition?.headSha === head) {
266
+ return { ok: true, state: absent, disposition: kind, reason: null };
267
+ }
268
+ return {
269
+ ok: false,
270
+ state: absent,
271
+ disposition: null,
272
+ reason: `no current-head Copilot review exists on head ${head}; convergence requires a clean current-head review or a sanctioned disposition recorded for this head (${[...SANCTIONED_ABSENT_REVIEW_DISPOSITIONS].join(", ")})`,
273
+ };
274
+ }
223
275
 
276
+ const findings = COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS;
224
277
  if (latestDisposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED) {
225
- return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
278
+ return { ok: false, state: findings, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
226
279
  }
227
280
  if (latestDisposition === COPILOT_DISPOSITION.UNRECOGNIZED) {
228
- return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
281
+ return { ok: false, state: findings, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
229
282
  }
230
283
  // CLEAN, NONE, and NEEDS_CLOSER_LOOK (🔵, conductor-overridable) pass.
231
- return { ok: true, disposition: latestDisposition, reason: null };
284
+ return { ok: true, state: COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_CLEAN, disposition: latestDisposition, reason: null };
232
285
  }
233
286
 
234
287
  // Disposition blocking precedence, most-blocking first. Used to fold an
@@ -327,6 +380,8 @@ export function evaluateMergePreconditions({
327
380
  comments = [],
328
381
  standingAuthorized = false,
329
382
  stableRelease = false,
383
+ copilotAbsentReviewDisposition = null,
384
+ copilotBodyDisposition = null,
330
385
  } = {}) {
331
386
  const failures = [];
332
387
 
@@ -375,8 +430,15 @@ export function evaluateMergePreconditions({
375
430
 
376
431
  // Copilot-convergence precondition: refuse a current-head Copilot non-approval
377
432
  // body disposition, mirroring the loop's copilotBodyFeedbackUnresolved.
378
- const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews });
379
- if (!copilotConvergence.ok) {
433
+ // A trusted copilot-body-disposition record resolved for the current head
434
+ // (`{ headSha, reviewId, ... }`) clears a current-head finding, as it does
435
+ // at gate entry.
436
+ const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews, absentReviewDisposition: copilotAbsentReviewDisposition });
437
+ const head = typeof currentHeadSha === "string" ? currentHeadSha.trim().toLowerCase() : "";
438
+ const bodyCleared = copilotConvergence.state === COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS
439
+ && head.length > 0
440
+ && typeof copilotBodyDisposition?.headSha === "string" && copilotBodyDisposition.headSha.toLowerCase() === head;
441
+ if (!copilotConvergence.ok && !bodyCleared) {
380
442
  failures.push({ precondition: "copilot_convergence", reason: copilotConvergence.reason });
381
443
  }
382
444
 
@@ -391,9 +453,12 @@ export function evaluateMergePreconditions({
391
453
  failures,
392
454
  mergeClass,
393
455
  approvalVia: decision.authorized ? decision.via : null,
394
- // Audit trace: the current-head Copilot disposition this verdict saw, so a
395
- // merge that ran on a conductor-overridable 🔵 (or any disposition) is
396
- // recorded on the machine-readable result rather than being invisible.
456
+ // Audit trace: which convergence state applied and what settled it (the
457
+ // current-head review disposition, or the sanctioned disposition kind when
458
+ // no current-head review exists), so a merge on a conductor-overridable 🔵
459
+ // or without a current-head review is recorded, never invisible.
460
+ copilotConvergenceState: copilotConvergence.state,
397
461
  copilotDisposition: copilotConvergence.disposition,
462
+ copilotBodyDisposition: bodyCleared ? copilotBodyDisposition : null,
398
463
  };
399
464
  }