@dev-loops/core 1.0.2 → 1.0.3

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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Sanctioned merge-wrapper decision logic. Pure, no I/O.
3
+ *
4
+ * The CLI wrapper `scripts/github/merge-pr.mjs` gathers live GitHub facts
5
+ * (mergeable state, CI rollup, gate evidence, size-budget outcome, reviews,
6
+ * comments) and feeds them here. This module owns the FAIL-CLOSED decisions:
7
+ * - is `--human-approved-by` a real GitHub login;
8
+ * - which merge class the PR is in (drain vs escalated);
9
+ * - whether a fresh, agent-unforgeable, head-pinned human approval exists;
10
+ * - and the aggregate precondition verdict that names each failing precondition.
11
+ *
12
+ * It reuses, never re-derives, the existing precondition set:
13
+ * `resolveSizeBudgetHumanApprovalRequired` (size-budget-merge-gate),
14
+ * `findBlockingTitleMarkers` (pr-title-markers), and the detect-checkpoint-evidence
15
+ * `preMergeGateCheck` bundle (draft/pre-approval verdicts, threads, runner lock,
16
+ * fan-out provenance). This module adds ONLY the human-approver identity, the
17
+ * merge-class split, and the aggregate naming.
18
+ */
19
+
20
+ import { isCopilotLogin } from "../github/copilot-helpers.mjs";
21
+ import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
22
+ import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
23
+ import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
24
+
25
+ // A GitHub login: 1-39 chars, alphanumeric or single internal hyphens, never
26
+ // leading/trailing hyphen. This rejects a bare boolean, empty/whitespace, and
27
+ // free text, so `--human-approved-by` is a real login, not a boolean or free text.
28
+ const GITHUB_LOGIN_RE = /^[A-Za-z0-9](?:-?[A-Za-z0-9])*$/;
29
+
30
+ /** True when `login` is shaped like a real GitHub login (fails closed on non-string). */
31
+ export function isValidGithubLogin(login) {
32
+ return typeof login === "string" && login.length >= 1 && login.length <= 39 && GITHUB_LOGIN_RE.test(login);
33
+ }
34
+
35
+ export const MERGE_CLASS = Object.freeze({ DRAIN: "drain", ESCALATED: "escalated" });
36
+
37
+ /**
38
+ * Classify the merge. An `escalate`/`block` size outcome, a T1-touching diff,
39
+ * or an explicit stable-release merge is ESCALATED — a standing authorization
40
+ * never satisfies it; it needs a fresh per-merge operator approval. Everything
41
+ * else is a normal DRAIN merge.
42
+ */
43
+ export function resolveMergeClass({ sizeOutcome = null, touchesT1 = false, stableRelease = false } = {}) {
44
+ if (stableRelease === true) return MERGE_CLASS.ESCALATED;
45
+ if (sizeOutcome === "escalate" || sizeOutcome === "block") return MERGE_CLASS.ESCALATED;
46
+ if (touchesT1 === true) return MERGE_CLASS.ESCALATED;
47
+ return MERGE_CLASS.DRAIN;
48
+ }
49
+
50
+ function escapeRegex(value) {
51
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ }
53
+
54
+ function reviewLogin(entry) {
55
+ if (typeof entry?.user?.login === "string" && entry.user.login.length > 0) return entry.user.login;
56
+ if (typeof entry?.login === "string" && entry.login.length > 0) return entry.login;
57
+ return null;
58
+ }
59
+
60
+ // A non-human login: the Copilot reviewer/bot (bracket-free `Copilot`
61
+ // variants, caught by isCopilotLogin) or any GitHub App bot login, which
62
+ // GitHub renders with a `[bot]` suffix (e.g. `github-actions[bot]`). Such a
63
+ // login can never satisfy the human-approval requirement. (A `[bot]` login can
64
+ // never be the named `--human-approved-by <login>` either — isValidGithubLogin
65
+ // rejects the brackets — so this only ever fires on a review/comment AUTHOR.)
66
+ function isNonHumanLogin(login) {
67
+ return isCopilotLogin(login) || /\[bot\]$/i.test(login);
68
+ }
69
+
70
+ // A non-human review/comment author. Beyond the login-shape check, a GitHub Bot
71
+ // account carries `user.type === "Bot"` even when its login has no `[bot]`
72
+ // suffix — reject it by that authoritative type so a bracket-free bot login can
73
+ // never satisfy the named fresh approver.
74
+ function isNonHumanAuthor(entry, login) {
75
+ return isNonHumanLogin(login) || (typeof entry?.type === "string" && entry.type.toLowerCase() === "bot");
76
+ }
77
+
78
+ function reviewCommit(entry) {
79
+ if (typeof entry?.commit_id === "string" && entry.commit_id.length > 0) return entry.commit_id;
80
+ if (typeof entry?.commitId === "string" && entry.commitId.length > 0) return entry.commitId;
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Verify a fresh, agent-unforgeable, head-pinned human approval by `approvedBy`.
86
+ *
87
+ * Two accepted records, in preference order:
88
+ * 1. a genuine `APPROVED` review by `approvedBy` whose `commit_id` equals the
89
+ * current head SHA — GitHub forbids approving your own PR, so an `APPROVED`
90
+ * review can only have come from a real human distinct from the author;
91
+ * 2. else a head-pinned operator comment marker `approve merge <headSha>`
92
+ * authored by `approvedBy` — the solo-operator path, since an author cannot
93
+ * leave an `APPROVED` review on their own agent-authored PR.
94
+ *
95
+ * FAILS CLOSED (returns `{ satisfied: false }`) when the approval is stale (on
96
+ * an earlier commit), agent/bot-authored (a Copilot-login review/comment never
97
+ * satisfies), from a login other than `approvedBy`, or absent. Because both
98
+ * records are pinned to the current head SHA, this re-gates on every head bump.
99
+ *
100
+ * @returns {{ satisfied: boolean, via: "approved_review"|"comment_marker"|null, reason: string|null }}
101
+ */
102
+ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews = [], comments = [] } = {}) {
103
+ if (!isValidGithubLogin(approvedBy)) {
104
+ return { satisfied: false, via: null, reason: "approvedBy is not a valid GitHub login" };
105
+ }
106
+ if (typeof currentHeadSha !== "string" || currentHeadSha.trim().length === 0) {
107
+ return { satisfied: false, via: null, reason: "current head SHA is unknown" };
108
+ }
109
+ const head = currentHeadSha.trim();
110
+
111
+ // Reduce to each login's LATEST submitted review (reviews arrive oldest-first,
112
+ // so the last occurrence wins — matching resolveHumanReviewDecision). A login
113
+ // whose APPROVED review was later superseded by a COMMENTED / CHANGES_REQUESTED
114
+ // / DISMISSED review no longer satisfies: only their latest state counts.
115
+ const latestReviewByLogin = new Map();
116
+ for (const entry of Array.isArray(reviews) ? reviews : []) {
117
+ const login = reviewLogin(entry);
118
+ if (login === null) continue;
119
+ latestReviewByLogin.set(login, entry);
120
+ }
121
+ const approverReview = latestReviewByLogin.get(approvedBy);
122
+ if (
123
+ approverReview
124
+ && !isNonHumanAuthor(approverReview, approvedBy) // agent/bot review never satisfies
125
+ && (typeof approverReview.state === "string" ? approverReview.state : null) === "APPROVED"
126
+ && reviewCommit(approverReview) === head // head-pinned — a stale/earlier-commit approval never satisfies
127
+ ) {
128
+ return { satisfied: true, via: "approved_review", reason: null };
129
+ }
130
+
131
+ // The marker must OPEN its line (after only leading whitespace / list / quote
132
+ // markers) so a negating operator comment never reads as approval: an
133
+ // unanchored `approve merge <head>` would also match `disapprove merge <head>`
134
+ // and `not approve merge <head>` — a fail-open on the merge-authorization
135
+ // path. The trailing `(?:\b|$)` keeps `<head>abc` from matching `<head>`.
136
+ const markerRe = new RegExp(`^[ \\t>*-]*approve\\s+merge\\s+${escapeRegex(head)}(?:\\b|$)`, "im");
137
+ for (const entry of Array.isArray(comments) ? comments : []) {
138
+ const login = reviewLogin(entry);
139
+ const body = typeof entry?.body === "string" ? entry.body : "";
140
+ if (login === null || isNonHumanAuthor(entry, login)) continue; // agent/bot comment never satisfies
141
+ if (login !== approvedBy) continue; // wrong login
142
+ if (!markerRe.test(body)) continue; // not a head-pinned marker for this head
143
+ return { satisfied: true, via: "comment_marker", reason: null };
144
+ }
145
+
146
+ return {
147
+ satisfied: false,
148
+ via: null,
149
+ reason: `no fresh ${approvedBy} approval on head ${head} (need an APPROVED review or an "approve merge ${head}" operator comment)`,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Decide whether merge is authorized given the class, the standing
155
+ * authorization signal, and any fresh per-merge approval.
156
+ *
157
+ * DRAIN: satisfied by a recorded standing authorization OR a fresh approval.
158
+ * ESCALATED: a standing authorization does NOT satisfy it — a fresh per-merge
159
+ * operator approval is required.
160
+ *
161
+ * @returns {{ authorized: boolean, via: string|null, reason: string|null }}
162
+ */
163
+ export function resolveMergeApprovalDecision({ mergeClass, standingAuthorized = false, freshApproval = null } = {}) {
164
+ const fresh = freshApproval != null && freshApproval.satisfied === true;
165
+ if (mergeClass === MERGE_CLASS.ESCALATED) {
166
+ if (fresh) return { authorized: true, via: freshApproval.via, reason: null };
167
+ return {
168
+ authorized: false,
169
+ via: null,
170
+ reason: `escalated/stable-release merge requires a fresh per-merge operator approval; a standing authorization does not satisfy it${freshApproval?.reason ? ` (${freshApproval.reason})` : ""}`,
171
+ };
172
+ }
173
+ if (standingAuthorized === true) return { authorized: true, via: "standing_authorization", reason: null };
174
+ if (fresh) return { authorized: true, via: freshApproval.via, reason: null };
175
+ return {
176
+ authorized: false,
177
+ via: null,
178
+ reason: `drain merge requires a recorded standing authorization or a fresh operator approval${freshApproval?.reason ? ` (${freshApproval.reason})` : ""}`,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Resolve CI-green from a `gh pr view --json statusCheckRollup` payload.
184
+ *
185
+ * Delegates to the canonical loop-safe normalizer `deriveLoopCiStatusFromRollup`,
186
+ * which EXCLUDES the loop-derived `gate-evidence` / `gate-evidence-runner` checks
187
+ * (detect-checkpoint-evidence validates those separately, so a cancelled/failing
188
+ * derived check must not block a merge whose real CI is green) and treats a
189
+ * completed-but-no-conclusion or otherwise-unreadable entry as non-success.
190
+ * Fails closed: only a real `success` is green; pending/failure/unavailable are
191
+ * not. An empty or no-CI rollup normalizes to `none`, which is NOT green (a PR
192
+ * with no visible CI does not auto-satisfy this precondition).
193
+ */
194
+ export function resolveCiGreenFromRollup(rollup) {
195
+ if (!Array.isArray(rollup)) return { green: false, reason: "CI status rollup unavailable" };
196
+ const { status, excludedFailureDetails } = deriveLoopCiStatusFromRollup(rollup);
197
+ if (status === "success") return { green: true, reason: null };
198
+ return {
199
+ green: false,
200
+ reason: `CI is not green on the current head (status=${status})`,
201
+ ...(Array.isArray(excludedFailureDetails) && excludedFailureDetails.length > 0 ? { excludedFailureDetails } : {}),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Aggregate every merge precondition into one fail-closed verdict, naming the
207
+ * specific failing precondition(s). The CLI resolves the live facts and passes
208
+ * them in; this stays pure so each branch is unit-testable.
209
+ *
210
+ * @returns {{ ok: boolean, failures: Array<{ precondition: string, reason: string }>, mergeClass: string, approvalVia: string|null }}
211
+ */
212
+ export function evaluateMergePreconditions({
213
+ humanApprovedBy,
214
+ mergeable = null,
215
+ mergeStateStatus = null,
216
+ ciGreen = null,
217
+ title = null,
218
+ gateEvidence = null,
219
+ sizeOutcome = null,
220
+ // Default null (not false): a missing/absent T1 signal must reach
221
+ // resolveSizeBudgetHumanApprovalRequired as a non-boolean so it fails closed,
222
+ // rather than being coerced to "T1 untouched".
223
+ touchesT1 = null,
224
+ unresolvedChangesRequestedCount = null,
225
+ currentHeadSha = null,
226
+ reviews = [],
227
+ comments = [],
228
+ standingAuthorized = false,
229
+ stableRelease = false,
230
+ } = {}) {
231
+ const failures = [];
232
+
233
+ if (!isValidGithubLogin(humanApprovedBy)) {
234
+ failures.push({ precondition: "human_approver", reason: "--human-approved-by must be a real GitHub login (not empty, a boolean, or free text)" });
235
+ }
236
+
237
+ if (mergeable !== "MERGEABLE" || (typeof mergeStateStatus === "string" && ["DIRTY", "BEHIND", "UNKNOWN"].includes(mergeStateStatus.toUpperCase()))) {
238
+ failures.push({ precondition: "mergeable", reason: `PR is not conflict-free with base (mergeable=${mergeable ?? "unknown"}, mergeStateStatus=${mergeStateStatus ?? "unknown"}); expected mergeable=MERGEABLE` });
239
+ }
240
+
241
+ // Fail closed on anything but an explicit { green: true } — a `false`, null, or
242
+ // malformed ciGreen must NOT slip past this fail-closed aggregate.
243
+ if (!ciGreen || ciGreen.green !== true) {
244
+ failures.push({ precondition: "ci_green", reason: (ciGreen && ciGreen.reason) ? ciGreen.reason : "CI status could not be resolved for the current head" });
245
+ }
246
+
247
+ // findBlockingTitleMarkers returns [] for a non-string title, so an absent or
248
+ // malformed title payload would silently pass this fail-closed gate — refuse it.
249
+ if (typeof title !== "string" || title.trim().length === 0) {
250
+ failures.push({ precondition: "title_markers", reason: "PR title is missing or unreadable; cannot verify it is free of merge-blocking markers" });
251
+ } else {
252
+ const titleMarkers = findBlockingTitleMarkers(title);
253
+ if (titleMarkers.length > 0) {
254
+ failures.push({ precondition: "title_markers", reason: `PR title carries merge-blocking marker(s): ${titleMarkers.join(", ")}` });
255
+ }
256
+ }
257
+
258
+ if (!gateEvidence || gateEvidence.ok !== true) {
259
+ const reason = gateEvidence && Array.isArray(gateEvidence.failures) && gateEvidence.failures.length > 0
260
+ ? gateEvidence.failures.join("; ")
261
+ : "draft_gate / current-head pre_approval_gate evidence is missing or unverified";
262
+ failures.push({ precondition: "gate_evidence", reason });
263
+ }
264
+
265
+ // Computed once, ahead of the size gate, so both preconditions draw "valid
266
+ // human approval" from the one shared resolver instead of two divergent
267
+ // checks (verifyFreshHumanApproval already owns the comment token, head
268
+ // pinning, and bot exclusion; the size gate no longer re-derives it from
269
+ // reviewDecision alone).
270
+ const freshApproval = verifyFreshHumanApproval({ approvedBy: humanApprovedBy, currentHeadSha, reviews, comments });
271
+
272
+ if (resolveSizeBudgetHumanApprovalRequired({ sizeOutcome, touchesT1, humanApprovalSatisfied: freshApproval.satisfied, unresolvedChangesRequestedCount }) === true) {
273
+ failures.push({ precondition: "size_budget_human_approval", reason: "size-budget requires a human APPROVED review OR a head-pinned \"approve merge <headSha>\" operator comment, with zero unresolved CHANGES_REQUESTED, for this escalated/T1 PR" });
274
+ }
275
+
276
+ const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
277
+ const decision = resolveMergeApprovalDecision({ mergeClass, standingAuthorized, freshApproval });
278
+ if (!decision.authorized) {
279
+ failures.push({ precondition: "merge_approval", reason: decision.reason });
280
+ }
281
+
282
+ return { ok: failures.length === 0, failures, mergeClass, approvalVia: decision.authorized ? decision.via : null };
283
+ }
@@ -8,6 +8,7 @@ import {
8
8
  MISSING_AC_DOD_MATRIX_FINDING,
9
9
  MISSING_EXPLICIT_NON_GOALS_FINDING,
10
10
  } from "./issue-refinement-artifact.mjs";
11
+ import { COMPLETE_FIXER_DISPOSITION_ACTION, FIXER_DISPOSITION_FORBIDDEN_ACTIONS } from "./fixer-disposition.mjs";
11
12
 
12
13
  export const PR_CHECKPOINT = Object.freeze({
13
14
  DRAFT_REVIEW: "draft_review",
@@ -70,6 +71,11 @@ export const PR_CHECKPOINT_ACTION = Object.freeze({
70
71
  REPORT_DONE: "report_done",
71
72
  RUN_UI_E2E_SUITE: "run_ui_e2e_suite",
72
73
  RECORD_DESIGNER_REVIEW: "record_designer_review",
74
+ // GATE-EXEC-FIXER-DISPOSITION-BOUNDARY: the only legal next action while a
75
+ // fixer's claimed-tackled threads have incomplete disposition. Value is the
76
+ // shared literal fixer-disposition.mjs's pure evaluator also returns as
77
+ // `nextAction` (asserted equal by test — see fixer-disposition.test.mjs).
78
+ COMPLETE_FIXER_DISPOSITION: COMPLETE_FIXER_DISPOSITION_ACTION,
73
79
  });
74
80
 
75
81
  function normalizeGateComment(summary = null) {
@@ -908,6 +914,49 @@ function evaluatePrGateCoordinationCore(input = {}) {
908
914
  });
909
915
  }
910
916
 
917
+ // GATE-EXEC-FIXER-DISPOSITION-BOUNDARY (skills/docs/gate-review-sub-loop-contract.md):
918
+ // a caller-supplied fixerDisposition input records whether every thread a
919
+ // fixer claims to have tackled since the last push is fully disposed
920
+ // (commit contained, replied with that commit's evidence, resolved, and
921
+ // re-verified live — see fixer-disposition.mjs's pure evaluator). Present
922
+ // and NOT complete fails this boundary CLOSED regardless of
923
+ // unresolvedThreadCount or lifecycleState — the failure this closes is a
924
+ // review round opening over a dirty surface even when the thread count
925
+ // itself reads clean (bogus/uncontained evidence), so it must run ahead of
926
+ // every lifecycle-state branch below, not be derived from one.
927
+ const fixerDisposition = input.fixerDisposition && typeof input.fixerDisposition === "object"
928
+ ? input.fixerDisposition
929
+ : null;
930
+ if (fixerDisposition && fixerDisposition.complete !== true) {
931
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION]);
932
+ pushUnique(forbiddenActions, FIXER_DISPOSITION_FORBIDDEN_ACTIONS);
933
+ const incompleteThreads = Array.isArray(fixerDisposition.incomplete) ? fixerDisposition.incomplete : [];
934
+ const reasonParts = incompleteThreads.map((entry) => (
935
+ `thread ${entry.threadId} (expected commit ${entry.expectedCommit ?? "unknown"}, failed step: ${entry.failedStep})`
936
+ ));
937
+ return buildResult({
938
+ repo: input.repo ?? null,
939
+ pr: Number.isInteger(input.pr) ? input.pr : null,
940
+ currentHeadSha,
941
+ lifecycleState: effectiveLifecycleState,
942
+ loopDisposition: DISPOSITION.UNRESOLVED_FEEDBACK,
943
+ gateBoundary: PR_CHECKPOINT.FEEDBACK_RESOLUTION,
944
+ draftGateAlreadySatisfied,
945
+ draftGate,
946
+ preApprovalGate,
947
+ allowedNextActions,
948
+ forbiddenActions,
949
+ nextAction: PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION,
950
+ reason: reasonParts.length > 0
951
+ ? `GATE-EXEC-FIXER-DISPOSITION-BOUNDARY forbids every review/gate-dispatch action for ${incompleteThreads.length} tackled thread(s) with incomplete disposition: ${reasonParts.join("; ")}. The only legal next action is ${PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION}.`
952
+ : `GATE-EXEC-FIXER-DISPOSITION-BOUNDARY forbids every review/gate-dispatch action until fixer disposition is complete and re-verified. The only legal next action is ${PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION}.`,
953
+ mergeStateStatus,
954
+ conflictFiles,
955
+ refinementArtifact,
956
+ copilotReviewRoundCount,
957
+ });
958
+ }
959
+
911
960
  // UI e2e auto-scoping precondition. Path-triggered + fail-closed:
912
961
  // if the PR's changed files touch a rendered artifact (a deck under
913
962
  // docs/articles|presentations, or the inspect-run viewer source), it MUST be
@@ -50,9 +50,11 @@ export const DEFAULT_STATE_LOGICAL_MAP = Object.freeze({
50
50
  issue_intake: LOGICAL_COLUMN.NEXT_UP,
51
51
  refinement: LOGICAL_COLUMN.NEXT_UP,
52
52
  no_pr: LOGICAL_COLUMN.NEXT_UP,
53
- pr_draft: LOGICAL_COLUMN.NEXT_UP,
54
53
 
55
54
  // In Progress — active implementation / review / feedback resolution
55
+ // A draft PR exists, so a runner owns the item; it is no longer pickable.
56
+ // This reconciles with the outer `implementation` lifecycle mapping.
57
+ pr_draft: LOGICAL_COLUMN.IN_PROGRESS,
56
58
  implementation: LOGICAL_COLUMN.IN_PROGRESS,
57
59
  // Tolerated alias for `implementation` (conceptual name);
58
60
  // the queue driver passes the real `implementation` lifecycle state.
@@ -125,8 +127,9 @@ export function deriveReconcileColumn(facts = {}) {
125
127
  // Merged PR (item is a PR, or issue's linked PR merged) => Done.
126
128
  if (prState === "MERGED") return LOGICAL_COLUMN.DONE;
127
129
  if (itemKind === "issue" && issueState === "CLOSED") return LOGICAL_COLUMN.DONE;
128
- // Open, ready (non-draft) PR => In Progress.
129
- if (prState === "OPEN" && prIsDraft === false) return LOGICAL_COLUMN.IN_PROGRESS;
130
+ // Any OPEN linked PR (draft or ready) => In Progress: a runner owns the item,
131
+ // so it must never be advertised in the pickup queue.
132
+ if (prState === "OPEN") return LOGICAL_COLUMN.IN_PROGRESS;
130
133
  // Otherwise leave the item untouched (Backlog / Next Up ordering preserved).
131
134
  return null;
132
135
  }