@mmnto/totem 2.2.1 → 2.3.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.
Files changed (43) hide show
  1. package/dist/bot-identity.d.ts +94 -0
  2. package/dist/bot-identity.d.ts.map +1 -0
  3. package/dist/bot-identity.js +118 -0
  4. package/dist/bot-identity.js.map +1 -0
  5. package/dist/capability/review-catch.d.ts.map +1 -1
  6. package/dist/capability/review-catch.js +10 -6
  7. package/dist/capability/review-catch.js.map +1 -1
  8. package/dist/gate-engine.d.ts +8 -2
  9. package/dist/gate-engine.d.ts.map +1 -1
  10. package/dist/gate-engine.js +10 -2
  11. package/dist/gate-engine.js.map +1 -1
  12. package/dist/gate-engine.test.js +112 -1
  13. package/dist/gate-engine.test.js.map +1 -1
  14. package/dist/gate-types.d.ts +41 -1
  15. package/dist/gate-types.d.ts.map +1 -1
  16. package/dist/index.d.ts +6 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/merge-ready.d.ts +157 -0
  21. package/dist/merge-ready.d.ts.map +1 -0
  22. package/dist/merge-ready.js +1256 -0
  23. package/dist/merge-ready.js.map +1 -0
  24. package/dist/merge-ready.test.d.ts +2 -0
  25. package/dist/merge-ready.test.d.ts.map +1 -0
  26. package/dist/merge-ready.test.js +814 -0
  27. package/dist/merge-ready.test.js.map +1 -0
  28. package/dist/orchestration-resolver.d.ts +13 -7
  29. package/dist/orchestration-resolver.d.ts.map +1 -1
  30. package/dist/orchestration-resolver.js +60 -14
  31. package/dist/orchestration-resolver.js.map +1 -1
  32. package/dist/orchestration-resolver.test.js +277 -1
  33. package/dist/orchestration-resolver.test.js.map +1 -1
  34. package/dist/retrospect.d.ts +6 -6
  35. package/dist/selection-manifest.d.ts +2 -2
  36. package/dist/spine/corpus-dispositions.d.ts +30 -30
  37. package/dist/sys/git.d.ts +50 -0
  38. package/dist/sys/git.d.ts.map +1 -1
  39. package/dist/sys/git.js +101 -0
  40. package/dist/sys/git.js.map +1 -1
  41. package/dist/transport-shield.test.js +6 -2
  42. package/dist/transport-shield.test.js.map +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,1256 @@
1
+ import { isBotReviewerLoginExact } from './bot-identity.js';
2
+ import { TotemError } from './errors.js';
3
+ import { safeExec } from './sys/exec.js';
4
+ /**
5
+ * merge-ready (mmnto-ai/totem#2800): a PreToolUse gate over the Bash and
6
+ * PowerShell tools that reads the five-point deterministic pre-merge floor off
7
+ * GitHub before `gh pr merge` runs.
8
+ *
9
+ * ZERO LLM calls, zero filesystem writes. The only outside state is what `gh`
10
+ * answers, and `gh` reaches this module through the injected {@link GhRunner}
11
+ * seam so a test replaces the network with a checked-in capture (R3, R4).
12
+ *
13
+ * The five predicates, evaluated IN ORDER (the first failure is the reason):
14
+ * 1. `checks` — the head commit's status-check rollup is green
15
+ * 2. `unresolved-bot-threads`— no unresolved, non-outdated review thread whose
16
+ * ROOT comment is one of the known review bots
17
+ * 3. `changes-requested` — no un-superseded CHANGES_REQUESTED review
18
+ * 4. `high-severity-inline` — no HIGH/Major bot inline that CURRENTLY applies
19
+ * to the head commit (`comment.commit.oid`,
20
+ * thread resolution ignored)
21
+ * 5. `merge-state` — GitHub's own `mergeStateStatus` is mergeable
22
+ *
23
+ * THE TIER SPLIT (R1): a predicate that FAILS is `deny` at every tier (the
24
+ * wrapper's tier map decides whether that blocks). A predicate whose INPUT
25
+ * could not be derived — gh missing, an API error, an incomplete page, a head
26
+ * that moved mid-read, `mergeStateStatus: UNKNOWN` — is the UNEVALUABLE class:
27
+ * `deny` under strict, `warn` under pilot, and NEVER `allow`. Every unevaluable
28
+ * path is named on stderr under BOTH tiers; only the exit code differs.
29
+ *
30
+ * THE OVERRIDE: `TOTEM_MERGE_GATE_OVERRIDE=1` yields `allow` plus one stderr
31
+ * audit line naming the repo, the PR, the head sha and the predicates that
32
+ * would have denied. It is read per evaluation (no module-level state).
33
+ */
34
+ /** The gate's registry event name. */
35
+ export const MERGE_READY_EVENT = 'merge-ready';
36
+ /** The module label every verdict cites as its `provenance.source` (never a path). */
37
+ export const MERGE_READY_SOURCE = 'merge-ready gh graphql read';
38
+ /** The audited bypass. Set to exactly `1`; any other value is not an override. */
39
+ export const MERGE_READY_OVERRIDE_ENV = 'TOTEM_MERGE_GATE_OVERRIDE';
40
+ /** Prefix every stderr line this gate emits carries, so a reader can grep one gate out of a hook log. */
41
+ export const MERGE_READY_NOTICE_PREFIX = '[totem merge-ready]';
42
+ /** Page size for every paginated connection in the query. */
43
+ const PAGE_SIZE = 100;
44
+ /**
45
+ * Hard cap on GraphQL round trips per evaluation. A PR that needs more pages
46
+ * than this is UNEVALUABLE (named), never "clean by exhaustion" — the capped-read
47
+ * bar from the charter's errata item 5.
48
+ */
49
+ const MAX_PAGES = 20;
50
+ /** `provenance.matched` (and every quoted evidence fragment) is bounded to this many characters. */
51
+ export const MERGE_READY_EVIDENCE_MAX = 160;
52
+ // ─── The read surface (R2: one GraphQL query, explicit cursors, no REST) ────
53
+ /**
54
+ * The PR fields every predicate reads, as a fragment so the number-keyed and
55
+ * branch-keyed documents cannot drift apart. The three paginated connections
56
+ * each take their own cursor variable, declared by the operation.
57
+ *
58
+ * `comments(first: 10)` is deliberate: only the ROOT comment of a thread is
59
+ * judged (it is the finding; the rest are the discussion), matching how triage
60
+ * reads a thread. The later comments are fetched for evidence, not for a
61
+ * predicate, so a thread with more than ten comments is not an incomplete read.
62
+ */
63
+ const MERGE_READY_FRAGMENT = `fragment MergeReadyPr on PullRequest {
64
+ number
65
+ isDraft
66
+ mergeStateStatus
67
+ headRefOid
68
+ headRefName
69
+ baseRefName
70
+ commits(last: 1) {
71
+ nodes {
72
+ commit {
73
+ oid
74
+ statusCheckRollup {
75
+ state
76
+ contexts(first: ${PAGE_SIZE}, after: $checksAfter) {
77
+ totalCount
78
+ pageInfo { hasNextPage endCursor }
79
+ nodes {
80
+ __typename
81
+ ... on CheckRun { name status conclusion }
82
+ ... on StatusContext { context state }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ reviews(first: ${PAGE_SIZE}, after: $reviewsAfter) {
90
+ pageInfo { hasNextPage endCursor }
91
+ nodes {
92
+ author { login }
93
+ state
94
+ submittedAt
95
+ }
96
+ }
97
+ reviewThreads(first: ${PAGE_SIZE}, after: $threadsAfter) {
98
+ pageInfo { hasNextPage endCursor }
99
+ nodes {
100
+ isResolved
101
+ isOutdated
102
+ comments(first: 10) {
103
+ nodes {
104
+ author { login }
105
+ body
106
+ commit { oid }
107
+ originalCommit { oid }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }`;
113
+ const SHARED_VARS = '$owner: String!, $name: String!, $reviewsAfter: String, $threadsAfter: String, $checksAfter: String';
114
+ /** The read the evaluator sends when the payload names a PR number. Fixtures are captured with THIS string. */
115
+ export const MERGE_READY_QUERY = `query TotemMergeReady($number: Int!, ${SHARED_VARS}) {
116
+ repository(owner: $owner, name: $name) {
117
+ pullRequest(number: $number) { ...MergeReadyPr }
118
+ }
119
+ }
120
+
121
+ ${MERGE_READY_FRAGMENT}`;
122
+ /**
123
+ * The read the evaluator sends when the payload carries a branch and no number
124
+ * (`gh pr merge` with no argument). Same fragment, so the predicates read the
125
+ * same fields; the first page resolves the number, and every later page uses
126
+ * {@link MERGE_READY_QUERY} against that fixed number.
127
+ */
128
+ export const MERGE_READY_BRANCH_QUERY = `query TotemMergeReadyByBranch($branch: String!, ${SHARED_VARS}) {
129
+ repository(owner: $owner, name: $name) {
130
+ pullRequests(headRefName: $branch, first: 1, states: OPEN, orderBy: { field: CREATED_AT, direction: DESC }) {
131
+ nodes { ...MergeReadyPr }
132
+ }
133
+ }
134
+ }
135
+
136
+ ${MERGE_READY_FRAGMENT}`;
137
+ // ─── Payload parsing (fail loud — the engine never default-allows) ──────────
138
+ const REPO_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
139
+ const SHA_40 = /^[0-9a-f]{40}$/i;
140
+ /** Parse + validate the payload. Throws `GATE_INVALID` (the wrapper's fail-closed arm) on anything else. */
141
+ export function parseMergeReadyPayload(payload) {
142
+ const invalid = (why) => {
143
+ throw new TotemError('GATE_INVALID', `merge-ready payload is invalid: ${why}.`, 'Pass --payload \'{"repo":"owner/name","pr":123}\' (or "pr":null with "branch":"<name>").');
144
+ };
145
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
146
+ return invalid('expected a JSON object');
147
+ }
148
+ const raw = payload;
149
+ const repo = typeof raw.repo === 'string' ? raw.repo.trim() : '';
150
+ if (repo === '')
151
+ return invalid('"repo" must be a non-empty "owner/name" string');
152
+ if (!REPO_SLUG.test(repo))
153
+ return invalid(`"repo" must look like owner/name (got "${repo}")`);
154
+ let pr = null;
155
+ if (typeof raw.pr === 'number') {
156
+ if (!Number.isInteger(raw.pr) || raw.pr <= 0)
157
+ return invalid('"pr" must be a positive integer');
158
+ pr = raw.pr;
159
+ }
160
+ else if (raw.pr !== null && raw.pr !== undefined) {
161
+ return invalid('"pr" must be a positive integer or null');
162
+ }
163
+ let branch;
164
+ if (typeof raw.branch === 'string' && raw.branch.trim() !== '') {
165
+ branch = raw.branch.trim();
166
+ }
167
+ else if (raw.branch !== undefined && typeof raw.branch !== 'string') {
168
+ return invalid('"branch" must be a string when present');
169
+ }
170
+ let unresolvedTarget;
171
+ if (typeof raw.unresolvedTarget === 'string' && raw.unresolvedTarget.trim() !== '') {
172
+ unresolvedTarget = raw.unresolvedTarget.trim();
173
+ }
174
+ else if (raw.unresolvedTarget !== undefined && typeof raw.unresolvedTarget !== 'string') {
175
+ return invalid('"unresolvedTarget" must be a string when present');
176
+ }
177
+ if (pr === null && branch === undefined && unresolvedTarget === undefined) {
178
+ return invalid('either "pr" or "branch" must identify the pull request');
179
+ }
180
+ let headSha;
181
+ if (typeof raw.headSha === 'string' && raw.headSha.trim() !== '') {
182
+ const trimmed = raw.headSha.trim();
183
+ if (!SHA_40.test(trimmed))
184
+ return invalid('"headSha" must be a 40-character hex sha');
185
+ headSha = trimmed.toLowerCase();
186
+ }
187
+ else if (raw.headSha !== undefined && typeof raw.headSha !== 'string') {
188
+ return invalid('"headSha" must be a string when present');
189
+ }
190
+ return {
191
+ repo,
192
+ pr,
193
+ ...(branch === undefined ? {} : { branch }),
194
+ ...(headSha === undefined ? {} : { headSha }),
195
+ ...(unresolvedTarget === undefined ? {} : { unresolvedTarget }),
196
+ };
197
+ }
198
+ // ─── Narrow readers over the GraphQL body (no `any`, no silent coercion) ────
199
+ function asObject(value) {
200
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
201
+ ? value
202
+ : null;
203
+ }
204
+ function asArray(value) {
205
+ return Array.isArray(value) ? value : null;
206
+ }
207
+ function asString(value) {
208
+ return typeof value === 'string' ? value : null;
209
+ }
210
+ /**
211
+ * A CheckRun's conclusion vocabulary. `NEUTRAL` and `SKIPPED` are successes (a
212
+ * skipped required check is branch protection's business, not this gate's);
213
+ * every other terminal conclusion — including an absent one on a COMPLETED run
214
+ * — is a failure, because an unreadable outcome must never read as green.
215
+ */
216
+ const SUCCESS_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
217
+ const SUCCESS_CONTEXT_STATES = new Set(['SUCCESS']);
218
+ const PENDING_CONTEXT_STATES = new Set(['PENDING', 'EXPECTED']);
219
+ /**
220
+ * A connection's `pageInfo`, read STRICTLY: the query asks for it on every
221
+ * connection, so an answer without one — or with a `hasNextPage` that is not
222
+ * a boolean — is an unreadable page, never "no more pages". Reading a missing
223
+ * `pageInfo` as complete was the fail-open Greptile named on the reviews and
224
+ * threads connections (mmnto-ai/totem#2844 round 1): a truncated or malformed
225
+ * answer read as a clean, finished list and predicates 2–4 passed on it.
226
+ */
227
+ function readPageInfo(connection) {
228
+ const info = asObject(connection?.pageInfo);
229
+ if (info === null)
230
+ return { ok: false, detail: 'carried no pageInfo' };
231
+ if (typeof info.hasNextPage !== 'boolean') {
232
+ return { ok: false, detail: 'carried a pageInfo whose hasNextPage is not a boolean' };
233
+ }
234
+ return { ok: true, hasNext: info.hasNextPage, cursor: asString(info.endCursor) };
235
+ }
236
+ function readChecks(rollup) {
237
+ const contexts = asObject(rollup?.contexts);
238
+ const entries = [];
239
+ // No rollup at all is the R5 no-checks shape (the caller has already
240
+ // refused a rollup WITHOUT a contexts connection): an empty, complete list.
241
+ if (contexts === null)
242
+ return { entries, hasNext: false, cursor: null };
243
+ const nodes = asArray(contexts.nodes);
244
+ if (nodes === null) {
245
+ return {
246
+ entries,
247
+ hasNext: false,
248
+ cursor: null,
249
+ detail: 'the status-check rollup contexts carried no nodes array',
250
+ };
251
+ }
252
+ for (const node of nodes) {
253
+ const n = asObject(node);
254
+ if (n === null)
255
+ return { entries, hasNext: false, cursor: null, detail: 'a check node was not an object' };
256
+ const typename = asString(n.__typename);
257
+ if (typename === 'CheckRun') {
258
+ const name = asString(n.name) ?? '(unnamed check)';
259
+ const status = asString(n.status) ?? '';
260
+ const conclusion = asString(n.conclusion);
261
+ if (status !== 'COMPLETED') {
262
+ entries.push({ name, kind: 'pending' });
263
+ }
264
+ else if (conclusion !== null && SUCCESS_CONCLUSIONS.has(conclusion)) {
265
+ entries.push({ name, kind: 'success' });
266
+ }
267
+ else {
268
+ entries.push({ name, kind: 'failing' });
269
+ }
270
+ }
271
+ else if (typename === 'StatusContext') {
272
+ const name = asString(n.context) ?? '(unnamed context)';
273
+ const state = asString(n.state) ?? '';
274
+ if (SUCCESS_CONTEXT_STATES.has(state)) {
275
+ entries.push({ name, kind: 'success' });
276
+ }
277
+ else if (PENDING_CONTEXT_STATES.has(state)) {
278
+ entries.push({ name, kind: 'pending' });
279
+ }
280
+ else {
281
+ entries.push({ name, kind: 'failing' });
282
+ }
283
+ }
284
+ else {
285
+ return {
286
+ entries,
287
+ hasNext: false,
288
+ cursor: null,
289
+ detail: `a check node carried an unknown __typename "${typename ?? 'null'}"`,
290
+ };
291
+ }
292
+ }
293
+ const info = readPageInfo(contexts);
294
+ if (!info.ok) {
295
+ return {
296
+ entries,
297
+ hasNext: false,
298
+ cursor: null,
299
+ detail: `the status-check rollup contexts ${info.detail}`,
300
+ };
301
+ }
302
+ return { entries, hasNext: info.hasNext, cursor: info.cursor };
303
+ }
304
+ /**
305
+ * The `reviews` connection, read as STRICTLY as the checks connection above
306
+ * (mmnto-ai/totem#2844 round 1, Greptile): a connection that is missing, has no
307
+ * `nodes` array, has no readable `pageInfo`, or carries a node that is not an
308
+ * object or has no `state` is an UNREADABLE answer, returned with a named
309
+ * `detail` — never an empty, complete list that lets predicate 3 pass on an
310
+ * answer the read did not actually receive. A review whose author is a
311
+ * deleted account (`author: null`) is the one shape SKIPPED rather than
312
+ * refused: it carries no identity to supersede or attribute, so it is never
313
+ * counted as a CHANGES_REQUESTED nobody can clear.
314
+ */
315
+ function readReviews(connection) {
316
+ const entries = [];
317
+ const unreadable = (why) => ({
318
+ entries,
319
+ hasNext: false,
320
+ cursor: null,
321
+ detail: `the reviews connection ${why}`,
322
+ });
323
+ if (connection === null)
324
+ return unreadable('was missing from the answer');
325
+ const nodes = asArray(connection.nodes);
326
+ if (nodes === null)
327
+ return unreadable('carried no nodes array');
328
+ for (const node of nodes) {
329
+ const n = asObject(node);
330
+ if (n === null)
331
+ return unreadable('carried a node that is not an object');
332
+ const state = asString(n.state);
333
+ if (state === null)
334
+ return unreadable('carried a review with no state');
335
+ const login = asString(asObject(n.author)?.login);
336
+ if (login === null)
337
+ continue;
338
+ entries.push({ login, state, submittedAt: asString(n.submittedAt) });
339
+ }
340
+ const info = readPageInfo(connection);
341
+ if (!info.ok)
342
+ return unreadable(info.detail);
343
+ return { entries, hasNext: info.hasNext, cursor: info.cursor };
344
+ }
345
+ /**
346
+ * The `reviewThreads` connection, read with the same strictness as
347
+ * {@link readReviews}: a missing connection, a missing `nodes` array or
348
+ * `pageInfo`, a thread node that is not an object, or a thread whose first
349
+ * comment cannot be read (no `comments.nodes` array, or an empty one — a
350
+ * review thread always has its root comment) is UNREADABLE and named, so
351
+ * predicates 2 and 4 never pass on a list the read did not deliver
352
+ * (mmnto-ai/totem#2844 round 1). A root comment whose author is a deleted
353
+ * account keeps `rootLogin: null` — it is not a known bot, which is a fact
354
+ * about the thread, not an unreadable answer.
355
+ */
356
+ function readThreads(connection) {
357
+ const entries = [];
358
+ const unreadable = (why) => ({
359
+ entries,
360
+ hasNext: false,
361
+ cursor: null,
362
+ detail: `the review threads connection ${why}`,
363
+ });
364
+ if (connection === null)
365
+ return unreadable('was missing from the answer');
366
+ const nodes = asArray(connection.nodes);
367
+ if (nodes === null)
368
+ return unreadable('carried no nodes array');
369
+ for (const node of nodes) {
370
+ const n = asObject(node);
371
+ if (n === null)
372
+ return unreadable('carried a node that is not an object');
373
+ const comments = asArray(asObject(n.comments)?.nodes);
374
+ if (comments === null)
375
+ return unreadable('carried a thread with no comments array');
376
+ const root = asObject(comments[0]);
377
+ if (root === null)
378
+ return unreadable('carried a thread whose root comment is unreadable');
379
+ entries.push({
380
+ isResolved: n.isResolved === true,
381
+ isOutdated: n.isOutdated === true,
382
+ rootLogin: asString(asObject(root.author)?.login),
383
+ rootBody: asString(root.body) ?? '',
384
+ rootCommit: asString(asObject(root.commit)?.oid),
385
+ rootOriginalCommit: asString(asObject(root.originalCommit)?.oid),
386
+ });
387
+ }
388
+ const info = readPageInfo(connection);
389
+ if (!info.ok)
390
+ return unreadable(info.detail);
391
+ return { entries, hasNext: info.hasNext, cursor: info.cursor };
392
+ }
393
+ /** Read one `gh api graphql` response body into a classified page. */
394
+ function parsePage(raw, byBranch) {
395
+ let body;
396
+ // totem-context: NOT a swallowed error — an unreadable response body is the
397
+ // gate's UNEVALUABLE class, and this returns it NAMED, which the caller turns
398
+ // into a `deny` (strict) or a `warn` (pilot) plus a stderr line. Throwing here
399
+ // would surface as the wrapper's generic fail-closed arm and lose the reason;
400
+ // the fail-loud property is kept by never returning a clean read (Tenet 4).
401
+ try {
402
+ body = JSON.parse(raw);
403
+ // totem-context: intentional degradation — see the directive above the try; dual placement so the rule reads either the catch-keyword line or the catch body.
404
+ }
405
+ catch {
406
+ // totem-context: intentional degradation — an unreadable body is the NAMED unevaluable class, never a clean read.
407
+ return { ok: false, detail: 'the gh response was not JSON' };
408
+ }
409
+ const top = asObject(body);
410
+ if (top === null)
411
+ return { ok: false, detail: 'the gh response was not a JSON object' };
412
+ // GraphQL reports authorization, not-found and rate-limit failures in the
413
+ // BODY, at HTTP 200 — an errors array is a failed read, never an empty one.
414
+ const errors = asArray(top.errors);
415
+ if (errors !== null && errors.length > 0) {
416
+ const first = asObject(errors[0]);
417
+ const message = asString(first?.message) ?? 'unnamed GraphQL error';
418
+ return { ok: false, detail: `the GraphQL read returned an error: ${message}` };
419
+ }
420
+ const repository = asObject(asObject(top.data)?.repository);
421
+ if (repository === null)
422
+ return { ok: false, detail: 'the response carried no repository' };
423
+ let pr;
424
+ if (byBranch) {
425
+ const nodes = asArray(asObject(repository.pullRequests)?.nodes) ?? [];
426
+ pr = asObject(nodes[0]);
427
+ if (pr === null)
428
+ return { ok: false, detail: 'no open pull request has that head branch' };
429
+ }
430
+ else {
431
+ pr = asObject(repository.pullRequest);
432
+ if (pr === null)
433
+ return { ok: false, detail: 'the repository has no pull request with that number' };
434
+ }
435
+ const number = typeof pr.number === 'number' ? pr.number : null;
436
+ const headRefOid = asString(pr.headRefOid);
437
+ const mergeStateStatus = asString(pr.mergeStateStatus);
438
+ if (number === null || headRefOid === null || mergeStateStatus === null) {
439
+ return {
440
+ ok: false,
441
+ detail: 'the pull request answer was missing number, headRefOid or mergeStateStatus',
442
+ };
443
+ }
444
+ // Predicate 1 reads the rollup off `commits(last: 1)`. That is only the head
445
+ // commit's rollup if the node IS the head commit, so the identity is checked
446
+ // rather than assumed (mmnto-ai/totem#2800 fold F7): a rollup read off some
447
+ // other commit would be a green light for code that is not what merges.
448
+ const commitNodes = asArray(asObject(pr.commits)?.nodes) ?? [];
449
+ if (commitNodes.length === 0) {
450
+ return {
451
+ ok: false,
452
+ detail: 'the pull request answered no commits — the head commit rollup could not be read',
453
+ };
454
+ }
455
+ const commit = asObject(asObject(commitNodes[0])?.commit);
456
+ const commitOid = asString(commit?.oid);
457
+ if (commitOid === null) {
458
+ return {
459
+ ok: false,
460
+ detail: 'the head commit answered no oid — the rollup cannot be attributed',
461
+ };
462
+ }
463
+ if (commitOid.toLowerCase() !== headRefOid.toLowerCase()) {
464
+ return {
465
+ ok: false,
466
+ detail: `the status-check rollup belongs to ${shortSha(commitOid)}, not the head commit ${shortSha(headRefOid)}`,
467
+ };
468
+ }
469
+ const rollup = asObject(commit?.statusCheckRollup);
470
+ // A rollup that exists but carries no `contexts` connection is an unreadable
471
+ // answer, NOT the zero-checks fact (R5): the fact needs the rollup itself to
472
+ // report an empty context list.
473
+ if (rollup !== null && asObject(rollup.contexts) === null) {
474
+ return {
475
+ ok: false,
476
+ detail: 'the status-check rollup carried no contexts connection — the check list is unreadable',
477
+ };
478
+ }
479
+ const checks = readChecks(rollup);
480
+ if (checks.detail !== undefined)
481
+ return { ok: false, detail: checks.detail };
482
+ // The two review connections are held to the SAME bar as the checks
483
+ // connection: an answer that is missing or malformed is a failed read, named,
484
+ // never an empty list (mmnto-ai/totem#2844 round 1).
485
+ const reviews = readReviews(asObject(pr.reviews));
486
+ if (reviews.detail !== undefined)
487
+ return { ok: false, detail: reviews.detail };
488
+ const threads = readThreads(asObject(pr.reviewThreads));
489
+ if (threads.detail !== undefined)
490
+ return { ok: false, detail: threads.detail };
491
+ return {
492
+ ok: true,
493
+ page: {
494
+ number,
495
+ headRefOid: headRefOid.toLowerCase(),
496
+ mergeStateStatus,
497
+ isDraft: pr.isDraft === true,
498
+ rollupPresent: rollup !== null,
499
+ rollupState: asString(rollup?.state),
500
+ rollupTotalCount: asObject(rollup?.contexts)?.totalCount ?? null,
501
+ checks: checks.entries,
502
+ checksHasNext: checks.hasNext,
503
+ checksCursor: checks.cursor,
504
+ reviews: reviews.entries,
505
+ reviewsHasNext: reviews.hasNext,
506
+ reviewsCursor: reviews.cursor,
507
+ threads: threads.entries,
508
+ threadsHasNext: threads.hasNext,
509
+ threadsCursor: threads.cursor,
510
+ },
511
+ };
512
+ }
513
+ // ─── Severity read (predicate 4) ────────────────────────────────────────────
514
+ /**
515
+ * The severity read is EXACT-BY-MARKER: it matches each bot's own STRUCTURED
516
+ * severity label and nothing else. Prose is never read
517
+ * (mmnto-ai/totem#2800 fold round 2, F4; round 3 F4/F5/F7/F11).
518
+ *
519
+ * Which forms are OBSERVED and which are DOCUMENTED-BUT-UNOBSERVED is stated
520
+ * per arm, because the two are not the same evidence:
521
+ * - **greptile** — a badge image whose alt text is the priority:
522
+ * `<a href="#"><img alt="P1" src="…/badges/p1.svg…" align="top"></a>`.
523
+ * OBSERVED in the corpus: `P1` (high) and `P2` (not high). `P0` is
524
+ * DOCUMENTED-BUT-UNOBSERVED — greptile publishes the `p0.svg` badge, so the
525
+ * arm accepts it; no thread in the window carries one. Single quotes are
526
+ * accepted beside double (`alt='P1'`) — HTML permits either and the read
527
+ * must not turn on the quote style.
528
+ * - **gemini-code-assist** — a priority image whose alt text is the level:
529
+ * `![high](https://www.gstatic.com/codereviewagent/high-priority.svg)`.
530
+ * OBSERVED: `high`. There is NO `critical` arm: the corresponding asset
531
+ * 404s and GCA's published rubric emits `high` as its top level, so an arm
532
+ * for it would be inference, not transcription (round 3, F5).
533
+ * - **CodeRabbit** — an emphasis-wrapped label that OPENS a table cell or a
534
+ * line. CodeRabbit's severity scale is Critical / Major / Minor / Trivial;
535
+ * `Potential issue` is its issue-CLASS label, carried here because the
536
+ * user-level prototype matched it and it marks a blocking finding.
537
+ * OBSERVED: `_🟠 Major_` (high), `_🟡 Minor_` and `_🔵 Trivial_` (not
538
+ * high). DOCUMENTED-BUT-UNOBSERVED: `_🔴 Critical_` and
539
+ * `_⚠️ Potential issue_` — in CodeRabbit's own label vocabulary, absent
540
+ * from this window. The label must FILL its cell: the emphasis plus the
541
+ * cell/line boundary are what separate a LABEL from a word in a sentence.
542
+ *
543
+ * CODE IS NOT A LABEL (round 3, F4). Fenced blocks (``` … ```, ~~~ … ~~~) and
544
+ * inline code spans are stripped before the scan. A bot that QUOTES a marker —
545
+ * a CodeRabbit Minor whose suggestion block quotes this very file, which
546
+ * carries every marker in its docstring — would otherwise read as HIGH and
547
+ * false-deny the gate's own maintenance PR.
548
+ *
549
+ * FALSE-POSITIVE BUDGET (ADR-109: a non-exact-match gate ships a stated budget
550
+ * and the fixture that measures it — the `transport-shield` precedent):
551
+ * **ZERO** high-severity reads over the benign corpus in
552
+ * `gate-fixtures/merge-ready/benign-corpus-bot-inlines.json` — every bot inline
553
+ * thread on mmnto-ai/totem#2820 through mmnto-ai/totem#2839, each carrying the
554
+ * severity its own bot declared — and over the quoted-marker row in
555
+ * `gate-fixtures/merge-ready/synthetic-benign-fenced-marker-quote.json`.
556
+ * `merge-ready.test.ts` asserts the read agrees with every one of those
557
+ * declarations, so a marker that widens into prose or into quoted code fails
558
+ * there. The prose arms this replaced did not hold that budget: the greptile
559
+ * **P2** thread on mmnto-ai/totem#2831 read as HIGH through the word "critical"
560
+ * in its explanation — a false deny on a finding its own author ranked below
561
+ * the bar. A miss in the field is a corpus row plus a marker fix, never a
562
+ * hand-carved exemption; the `--pilot` tier exists for a measurement week, and
563
+ * `TOTEM_MERGE_GATE_OVERRIDE=1` is the audited way past one.
564
+ *
565
+ * Out of scope by design, disclosed: a bot that stops emitting a structured
566
+ * label (or a fourth bot) reads as NOT high — the miss direction, a corpus gap
567
+ * to be closed by observation, never a silent deny; a label a bot places
568
+ * somewhere this read does not look (a summary table, a list item, a
569
+ * blockquote) is the same class; a human quoting a bot's label verbatim would
570
+ * read as high, but predicates 2 and 4 both require the thread's ROOT comment
571
+ * to be a known bot login.
572
+ *
573
+ * THE STRIPPER'S EDGES, measured rather than assumed (round 5, F1/F4/F5/F6).
574
+ * It recognises fenced blocks of any backtick or tilde run, `<pre>` blocks,
575
+ * CommonMark indented blocks, and inline spans of any backtick run. At the
576
+ * edges:
577
+ * - a fence indented under a list item IS stripped, at two spaces or four —
578
+ * the opener and closer both allow leading blanks;
579
+ * - a four-backtick fence closed by three has its CONTENTS stripped (the
580
+ * opener matches three of the four backticks and the fourth reads as the
581
+ * info string), so only the text AFTER the short closer reaches the scan —
582
+ * a marker there reads HIGH, the false-deny direction;
583
+ * - an HTML `<code>` element is NOT stripped at all. A label inside one reads
584
+ * HIGH wherever it carries its own anchor — a `|` cell delimiter, or a line
585
+ * start inside a multi-line element — and reads not-high only when it has
586
+ * neither. False-deny direction.
587
+ * Three edges run the other way, toward ALLOW, by swallowing prose that is not
588
+ * code: a backtick run whose match crosses a paragraph break takes a label
589
+ * sitting between two lone backticks with it; an indented line that follows a
590
+ * prose line is stripped though CommonMark would not open a code block there;
591
+ * and `<pre>` tags quoted inside code spans still act as block delimiters,
592
+ * because the `<pre>` arm runs before the span arm. Measured reach: NONE of
593
+ * the 34 bodies in the checked-in fixtures is affected — every one of them,
594
+ * corpus included, gets the same verdict from its first line alone, and all
595
+ * 16 corpus labels sit on line 0, where no stripper edge can reach them. Each
596
+ * edge is a corpus row plus a stripper fix when one is observed in the field;
597
+ * `TOTEM_MERGE_GATE_OVERRIDE=1` is the audited way past a false deny in the
598
+ * meantime.
599
+ */
600
+ /**
601
+ * The glyphs CodeRabbit puts before an un-emphasised severity label — its four
602
+ * severity dots and the warning sign, with and without the variation selector.
603
+ * Built from code points rather than typed, so this source carries no `\u`
604
+ * escape and no pasted emoji (round 3, F7 narrowed this from "any non-ASCII
605
+ * glyph", which let an em-dash-led line read as a label).
606
+ */
607
+ const CR_LABEL_GLYPHS = [0x1f534, 0x1f7e0, 0x1f7e1, 0x1f535, 0x26a0]
608
+ .map((cp) => String.fromCodePoint(cp))
609
+ .join('');
610
+ /**
611
+ * The variation selector rides AFTER a glyph (`⚠️` is the warning sign plus
612
+ * this), so it is a modifier here rather than a class member: a BARE selector
613
+ * before "major" is not a severity label (round 4, F9).
614
+ */
615
+ const VARIATION_SELECTOR = String.fromCodePoint(0xfe0f);
616
+ const LABEL_GLYPH = `(?:[${CR_LABEL_GLYPHS}][${VARIATION_SELECTOR}]?)`;
617
+ /**
618
+ * The un-emphasised arm is built with the `u` flag so `LABEL_GLYPH` is a
619
+ * CODE-POINT class: without it the astral dots decompose into surrogate halves
620
+ * and a lone surrogate — or a bare variation selector — before "major" matched
621
+ * as if it were a severity glyph (round 4, F9).
622
+ */
623
+ const UNEMPHASISED_LABEL = new RegExp(`(?:^|\\n)[ \\t]*${LABEL_GLYPH}+[ \\t]*(?:critical|major|potential issue)(?![A-Za-z0-9])`, 'iu');
624
+ const HIGH_SEVERITY_MARKERS = [
625
+ // greptile: the badge's alt attribute, P0/P1 only, quoted either way or
626
+ // unquoted (the lookahead is what ends an unquoted attribute value).
627
+ /<img[^>]*\balt=["']?P[01]["']?(?=[\s/>])/i,
628
+ // gemini-code-assist: the priority image's alt text. `high` only.
629
+ /!\[high\]\(/i,
630
+ // CodeRabbit: an emphasis-wrapped severity label that OPENS a table cell or a
631
+ // line. The `[^A-Za-z0-9\n|]*` arms absorb the glyph and the spaces inside the
632
+ // emphasis; requiring the opening emphasis to sit at a cell/line boundary is
633
+ // what separates a LABEL from an emphasised word inside a sentence.
634
+ /(?:^|\n|\|)[ \t]*[_*]{1,2}[^A-Za-z0-9\n|]*(?:critical|major|potential issue)[^A-Za-z0-9\n|]*[_*]{1,2}/i,
635
+ // CodeRabbit's un-emphasised heading form: the severity glyph, then the
636
+ // label, at the start of a line.
637
+ UNEMPHASISED_LABEL,
638
+ ];
639
+ /**
640
+ * The token every stripped code form leaves behind. It MUST be non-whitespace:
641
+ * replacing a span with a space opened a fresh line/cell-start position, and
642
+ * `` `x`_🟠 Major_ `` then read as a label — a regression this stripper itself
643
+ * introduced (round 4, F2). A word of ASCII letters is neither a boundary the
644
+ * anchors accept nor a glyph the un-emphasised arm reads.
645
+ */
646
+ const CODE_PLACEHOLDER = 'CODE';
647
+ /**
648
+ * A body with its CODE replaced by {@link CODE_PLACEHOLDER}: fenced blocks
649
+ * first (they can contain backticks), then `<pre>` blocks, then CommonMark's
650
+ * indented blocks, then inline spans of any backtick run. What is left is the
651
+ * bot's prose and its labels — the only text a severity label can legitimately
652
+ * live in (round 3 F4; round 4 F2/F4). An unterminated fence swallows the rest
653
+ * of the body, which is how a markdown renderer reads it too.
654
+ */
655
+ function withoutCode(body) {
656
+ return body
657
+ .replace(/^[ \t]*(```+|~~~+)[^\n]*\n[\s\S]*?^[ \t]*\1[^\n]*$/gm, CODE_PLACEHOLDER)
658
+ .replace(/^[ \t]*(```|~~~)[\s\S]*$/m, CODE_PLACEHOLDER)
659
+ .replace(/<pre[\s>][\s\S]*?<\/pre>/gi, CODE_PLACEHOLDER)
660
+ .replace(/^(?: {4}|\t)[^\n]*$/gm, CODE_PLACEHOLDER)
661
+ .replace(/(`+)[^`]*?\1/g, CODE_PLACEHOLDER);
662
+ }
663
+ /**
664
+ * Whether a comment body carries a bot's OWN structured high-severity label —
665
+ * one of {@link HIGH_SEVERITY_MARKERS}, read against the body with every code
666
+ * form replaced first ({@link withoutCode}), so a quoted marker never reads
667
+ * as a label and prose is never matched. `true` is "this finding's author
668
+ * ranked it HIGH/Major/P0/P1"; `false` is "no such label in the prose" — which
669
+ * includes a bot that emits no structured label at all (the disclosed miss
670
+ * direction above).
671
+ */
672
+ export function hasHighSeverityMarker(body) {
673
+ const prose = withoutCode(body);
674
+ return HIGH_SEVERITY_MARKERS.some((re) => re.test(prose));
675
+ }
676
+ // ─── Evidence helpers ───────────────────────────────────────────────────────
677
+ /** Bound and sanitize a fragment for a reason / provenance: no control characters, bounded length. */
678
+ function bounded(text) {
679
+ let out = '';
680
+ for (const ch of text) {
681
+ const code = ch.charCodeAt(0);
682
+ out += code < 0x20 || code === 0x7f ? ' ' : ch;
683
+ }
684
+ out = out.replace(/\s+/g, ' ').trim();
685
+ return out.length > MERGE_READY_EVIDENCE_MAX
686
+ ? out.slice(0, MERGE_READY_EVIDENCE_MAX - 1) + '…'
687
+ : out;
688
+ }
689
+ function shortSha(sha) {
690
+ return sha === null ? '(unknown)' : sha.slice(0, 12);
691
+ }
692
+ function graphqlArgs(query, variables) {
693
+ const args = ['api', 'graphql', '-f', `query=${query}`];
694
+ for (const [key, value] of variables) {
695
+ // `-F` sends a typed value (the `$number: Int!` argument requires it); `-f`
696
+ // sends a string. Never a shell string — the runner spawns argv.
697
+ args.push(typeof value === 'number' ? '-F' : '-f', `${key}=${value}`);
698
+ }
699
+ return args;
700
+ }
701
+ /**
702
+ * Run the paginated read. Every failure — a non-zero gh exit, an unparseable
703
+ * body, a GraphQL error, a head that moved between pages, a page budget blown —
704
+ * returns `ok: false` with a NAMED detail. There is no arm that returns a
705
+ * partial read as if it were complete.
706
+ */
707
+ function readPullRequest(payload, runner) {
708
+ const [owner, name] = payload.repo.split('/');
709
+ const state = {
710
+ number: payload.pr ?? 0,
711
+ headSha: '',
712
+ mergeStateStatus: '',
713
+ isDraft: false,
714
+ rollupPresent: false,
715
+ rollupState: null,
716
+ rollupTotalCount: null,
717
+ checks: [],
718
+ reviews: [],
719
+ threads: [],
720
+ pagesRead: 0,
721
+ complete: false,
722
+ };
723
+ let checksDone = false;
724
+ let reviewsDone = false;
725
+ let threadsDone = false;
726
+ let checksCursor = null;
727
+ let reviewsCursor = null;
728
+ let threadsCursor = null;
729
+ for (let page = 0; page < MAX_PAGES; page++) {
730
+ // The branch-keyed document is used ONLY for the first page of a payload
731
+ // with no number; once the number is known every later page targets it, so
732
+ // a PR opened mid-read cannot swap the target under us.
733
+ const byBranch = payload.pr === null && page === 0;
734
+ const query = byBranch ? MERGE_READY_BRANCH_QUERY : MERGE_READY_QUERY;
735
+ const variables = [
736
+ ['owner', owner ?? ''],
737
+ ['name', name ?? ''],
738
+ ];
739
+ if (byBranch) {
740
+ variables.push(['branch', payload.branch ?? '']);
741
+ }
742
+ else {
743
+ variables.push(['number', state.number]);
744
+ }
745
+ if (checksCursor !== null)
746
+ variables.push(['checksAfter', checksCursor]);
747
+ if (reviewsCursor !== null)
748
+ variables.push(['reviewsAfter', reviewsCursor]);
749
+ if (threadsCursor !== null)
750
+ variables.push(['threadsAfter', threadsCursor]);
751
+ const run = runner(graphqlArgs(query, variables));
752
+ state.pagesRead = page + 1;
753
+ if (run.exitCode !== 0) {
754
+ return {
755
+ ok: false,
756
+ detail: `gh api graphql exited ${run.exitCode}: ${bounded(run.stdout)}`,
757
+ pagesRead: state.pagesRead,
758
+ };
759
+ }
760
+ const parsed = parsePage(run.stdout, byBranch);
761
+ if (!parsed.ok)
762
+ return { ok: false, detail: parsed.detail, pagesRead: state.pagesRead };
763
+ const p = parsed.page;
764
+ if (page === 0) {
765
+ state.number = p.number;
766
+ state.headSha = p.headRefOid;
767
+ state.mergeStateStatus = p.mergeStateStatus;
768
+ state.isDraft = p.isDraft;
769
+ state.rollupPresent = p.rollupPresent;
770
+ state.rollupState = p.rollupState;
771
+ state.rollupTotalCount = p.rollupTotalCount;
772
+ }
773
+ else if (p.headRefOid !== state.headSha) {
774
+ // The head moved between reads: everything already accumulated describes
775
+ // a commit that is no longer what would merge.
776
+ return {
777
+ ok: false,
778
+ detail: `the head sha moved during evaluation (${shortSha(state.headSha)} → ${shortSha(p.headRefOid)})`,
779
+ pagesRead: state.pagesRead,
780
+ };
781
+ }
782
+ if (!checksDone) {
783
+ state.checks.push(...p.checks);
784
+ checksDone = !p.checksHasNext;
785
+ checksCursor = p.checksCursor;
786
+ }
787
+ if (!reviewsDone) {
788
+ state.reviews.push(...p.reviews);
789
+ reviewsDone = !p.reviewsHasNext;
790
+ reviewsCursor = p.reviewsCursor;
791
+ }
792
+ if (!threadsDone) {
793
+ state.threads.push(...p.threads);
794
+ threadsDone = !p.threadsHasNext;
795
+ threadsCursor = p.threadsCursor;
796
+ }
797
+ if (checksDone && reviewsDone && threadsDone) {
798
+ // The rollup must ACCOUNT for itself before predicate 1 reads it
799
+ // (mmnto-ai/totem#2800 round 4, F3). `totalCount` is judged as the API
800
+ // typed it — a string "3", a boolean, a negative or a fractional number
801
+ // is an unreadable answer, not a count — and once every page is in, the
802
+ // number of contexts the read MATERIALISED must equal the number the
803
+ // rollup CLAIMED. A claim the read did not deliver is an incomplete read
804
+ // (R2), never a smaller green list.
805
+ if (state.rollupPresent) {
806
+ const claimed = state.rollupTotalCount;
807
+ if (typeof claimed !== 'number' || !Number.isInteger(claimed) || claimed < 0) {
808
+ return {
809
+ ok: false,
810
+ detail: 'the status-check rollup reported a totalCount that is not a non-negative integer (' +
811
+ bounded(typeof claimed === 'string' ? JSON.stringify(claimed) : String(claimed)) +
812
+ ') - the check state is unreadable',
813
+ pagesRead: state.pagesRead,
814
+ };
815
+ }
816
+ if (claimed !== state.checks.length) {
817
+ return {
818
+ ok: false,
819
+ detail: 'the status-check rollup claims ' +
820
+ String(claimed) +
821
+ ' checks but the read materialised ' +
822
+ String(state.checks.length) +
823
+ ' - the check state is unreadable',
824
+ pagesRead: state.pagesRead,
825
+ };
826
+ }
827
+ }
828
+ // R5's zero-checks FACT applies ONLY where the rollup is consistent about
829
+ // it, and that is exactly two shapes: no rollup at all, or a rollup that
830
+ // reports SUCCESS over an empty context list AND counts zero (the count
831
+ // agreement above already holds). Every other shape — a PENDING or
832
+ // FAILURE state with nothing listed, a `state` that is null or not a
833
+ // string — is an unreadable answer, never a green light (fold F7; round 2
834
+ // F3 added the null/non-string state).
835
+ if (state.checks.length === 0 && state.rollupPresent && state.rollupState !== 'SUCCESS') {
836
+ return {
837
+ ok: false,
838
+ detail: state.rollupState === null
839
+ ? 'the status-check rollup listed no checks and reported no readable state - the check state is unreadable'
840
+ : 'the status-check rollup reports ' +
841
+ bounded(state.rollupState) +
842
+ ' but listed no checks - the check state is unreadable',
843
+ pagesRead: state.pagesRead,
844
+ };
845
+ }
846
+ state.complete = true;
847
+ return { ok: true, state };
848
+ }
849
+ }
850
+ return {
851
+ ok: false,
852
+ detail: `the read needed more than ${MAX_PAGES} pages; a capped read is never reported as clean`,
853
+ pagesRead: state.pagesRead,
854
+ };
855
+ }
856
+ /** `mergeStateStatus` values that pass predicate 5. `UNSTABLE` (a non-required check red) is predicate 1's business. */
857
+ const MERGE_STATE_PASS = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE']);
858
+ /** Values that fail predicate 5 outright. `DRAFT` is GitHub's own "not ready to merge". */
859
+ const MERGE_STATE_DENY = new Map([
860
+ ['BEHIND', 'the branch is behind its base — update it before merging'],
861
+ ['DIRTY', 'the merge is conflicted — resolve the conflict before merging'],
862
+ ['BLOCKED', 'branch protection blocks the merge (a required review or check is missing)'],
863
+ ['DRAFT', 'the pull request is still a draft'],
864
+ ]);
865
+ function summarizeChecks(checks) {
866
+ let success = 0;
867
+ let pending = 0;
868
+ let failing = 0;
869
+ for (const c of checks) {
870
+ if (c.kind === 'success')
871
+ success++;
872
+ else if (c.kind === 'pending')
873
+ pending++;
874
+ else
875
+ failing++;
876
+ }
877
+ return { total: checks.length, success, pending, failing };
878
+ }
879
+ /**
880
+ * The authors whose LATEST decision review is CHANGES_REQUESTED. `COMMENTED`
881
+ * reviews are not decisions and never supersede (GitHub's own semantics); a
882
+ * later APPROVED or DISMISSED from the same reviewer does.
883
+ */
884
+ function changesRequestedBy(reviews) {
885
+ const DECISIONS = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
886
+ const latest = new Map();
887
+ for (const r of reviews) {
888
+ if (!DECISIONS.has(r.state))
889
+ continue;
890
+ const at = r.submittedAt ?? '';
891
+ const seen = latest.get(r.login);
892
+ // Ties (or a missing timestamp) fall back to array order, which the API
893
+ // returns chronologically — the later row wins.
894
+ if (seen === undefined || at >= seen.at)
895
+ latest.set(r.login, { state: r.state, at });
896
+ }
897
+ const out = [];
898
+ for (const [login, decision] of latest) {
899
+ if (decision.state === 'CHANGES_REQUESTED')
900
+ out.push(login);
901
+ }
902
+ return out.sort();
903
+ }
904
+ /** Unresolved, non-outdated threads whose ROOT comment is a known review bot. */
905
+ function unresolvedBotThreads(threads) {
906
+ return threads.filter((t) => !t.isResolved &&
907
+ !t.isOutdated &&
908
+ t.rootLogin !== null &&
909
+ isBotReviewerLoginExact(t.rootLogin));
910
+ }
911
+ /**
912
+ * HIGH/Major bot inlines that CURRENTLY apply to the head commit — thread
913
+ * resolution deliberately IGNORED (ruled on mmnto-ai/totem#2800, fold F2).
914
+ *
915
+ * The comparison is `comment.commit.oid`, the commit the finding applies to
916
+ * NOW (GitHub re-points it as the diff moves; it is the REST `commit_id` the
917
+ * charter's predicate (c) names), never `originalCommit.oid`, the commit it was
918
+ * written against — a predicate keyed on the write-time commit goes inert the
919
+ * moment the branch advances, which is exactly the inert state this fold
920
+ * removed.
921
+ *
922
+ * Predicate 4's distinct territory, the one predicate 2 cannot reach: a HIGH
923
+ * finding a human RESOLVED by hand without changing the code. The comment still
924
+ * applies to the head commit, so the floor still refuses the merge. Predicate 2
925
+ * keeps its own rule (unresolved AND non-outdated, any severity) and still
926
+ * fires first when both match.
927
+ */
928
+ function highSeverityInlines(threads, headSha) {
929
+ return threads.filter((t) => t.rootLogin !== null &&
930
+ isBotReviewerLoginExact(t.rootLogin) &&
931
+ t.rootCommit !== null &&
932
+ t.rootCommit.toLowerCase() === headSha &&
933
+ hasHighSeverityMarker(t.rootBody));
934
+ }
935
+ /**
936
+ * Bot HIGH inlines whose `comment.commit` came back NULL — the predicate's
937
+ * input is missing, so whether they apply to the head is UNKNOWN
938
+ * (mmnto-ai/totem#2800 round 2, F8). R2: an unreadable input is never a pass,
939
+ * so these never fall out of the filter above and read as "not on head".
940
+ * They make the evaluation UNEVALUABLE, named on stderr, only when no
941
+ * predicate ahead of them in charter order has already failed: a failing
942
+ * check, an unresolved bot thread, a standing CHANGES_REQUESTED or a
943
+ * placeable HIGH inline is a fact and denies first, and that verdict names
944
+ * its own cause, not these (the PR's review round; see the call site).
945
+ */
946
+ function unreadableCommitHighInlines(threads) {
947
+ return threads.filter((t) => t.rootLogin !== null &&
948
+ isBotReviewerLoginExact(t.rootLogin) &&
949
+ t.rootCommit === null &&
950
+ hasHighSeverityMarker(t.rootBody));
951
+ }
952
+ // ─── The evaluation ─────────────────────────────────────────────────────────
953
+ /** Read `gh --version` through the seam. A failure here IS the "gh unavailable" failure mode. */
954
+ function readGhVersion(runner) {
955
+ const run = runner(['--version']);
956
+ if (run.exitCode !== 0) {
957
+ return {
958
+ ok: false,
959
+ detail: `gh is unavailable or unauthenticated (gh --version exited ${run.exitCode}: ${bounded(run.stdout)})`,
960
+ };
961
+ }
962
+ const match = /gh version (\S+)/.exec(run.stdout);
963
+ return {
964
+ ok: true,
965
+ version: match?.[1] !== undefined ? `gh ${match[1]}` : 'gh (version unknown)',
966
+ };
967
+ }
968
+ /**
969
+ * Evaluate the merge-ready floor. Pure with respect to state: it reads GitHub
970
+ * through the injected runner and returns a verdict, its evidence, and the
971
+ * stderr lines the host must print. It writes nothing.
972
+ */
973
+ export function evaluateMergeReady(payload, options) {
974
+ const parsed = parseMergeReadyPayload(payload);
975
+ const tier = options.tier ?? 'strict';
976
+ const env = options.env ?? process.env;
977
+ const now = options.now ?? (() => new Date());
978
+ const checkedAt = now().toISOString();
979
+ const notices = [];
980
+ const override = env[MERGE_READY_OVERRIDE_ENV] === '1';
981
+ const detail = {
982
+ repo: parsed.repo,
983
+ pr: parsed.pr,
984
+ headSha: null,
985
+ checks: { total: 0, success: 0, pending: 0, failing: 0 },
986
+ threads: { unresolvedBot: 0, pagesRead: 0, complete: false },
987
+ changesRequestedBy: [],
988
+ highInline: 0,
989
+ mergeStateStatus: null,
990
+ evaluatedAt: checkedAt,
991
+ evaluatedBy: 'gh (not read)',
992
+ };
993
+ /** The UNEVALUABLE class: `deny` under strict, `warn` under pilot — never `allow`. */
994
+ const unevaluable = (why) => {
995
+ notices.push(`${MERGE_READY_NOTICE_PREFIX} could not derive the merge floor for ${parsed.repo}#${parsed.pr ?? parsed.branch ?? '?'}: ${why} (tier=${tier} → ${tier === 'pilot' ? 'warn' : 'deny'}).`);
996
+ if (override)
997
+ return allowByOverride(`the read failed: ${why}`);
998
+ return {
999
+ verdict: {
1000
+ disposition: tier === 'pilot' ? 'warn' : 'deny',
1001
+ reason: `merge-ready could not derive: ${why}.`,
1002
+ provenance: {
1003
+ source: MERGE_READY_SOURCE,
1004
+ ref: 'unevaluable',
1005
+ matched: bounded(why),
1006
+ checkedAt,
1007
+ detail: { ...detail },
1008
+ },
1009
+ },
1010
+ detail,
1011
+ notices,
1012
+ };
1013
+ };
1014
+ /** The audited bypass: `allow` plus one line naming what would have denied. */
1015
+ const allowByOverride = (wouldHaveDenied) => {
1016
+ detail.override = true;
1017
+ notices.push(`${MERGE_READY_NOTICE_PREFIX} OVERRIDE (${MERGE_READY_OVERRIDE_ENV}=1): allowing ${parsed.repo}#${parsed.pr ?? detail.pr ?? '?'} at head ${shortSha(detail.headSha)} — would have denied: ${wouldHaveDenied}.`);
1018
+ return {
1019
+ verdict: {
1020
+ disposition: 'allow',
1021
+ reason: `Allowed by ${MERGE_READY_OVERRIDE_ENV}=1 (audited). Would have denied: ${wouldHaveDenied}.`,
1022
+ provenance: {
1023
+ source: MERGE_READY_SOURCE,
1024
+ ref: 'override',
1025
+ matched: bounded(wouldHaveDenied),
1026
+ checkedAt,
1027
+ detail: { ...detail },
1028
+ },
1029
+ },
1030
+ detail,
1031
+ notices,
1032
+ };
1033
+ };
1034
+ // An argument the wrapper could not project into a PR (an unexpanded shell
1035
+ // variable) is UNEVALUABLE before any read: there is nothing to query, and
1036
+ // guessing would judge the wrong PR (mmnto-ai/totem#2800 fold F13). No `gh`
1037
+ // runs on this path.
1038
+ if (parsed.unresolvedTarget !== undefined) {
1039
+ return unevaluable(`shell variable not expanded — pass a literal PR number or URL (the command named "${bounded(parsed.unresolvedTarget)}")`);
1040
+ }
1041
+ const version = readGhVersion(options.runner);
1042
+ if (!version.ok)
1043
+ return unevaluable(version.detail);
1044
+ detail.evaluatedBy = version.version;
1045
+ const read = readPullRequest(parsed, options.runner);
1046
+ if (!read.ok) {
1047
+ detail.threads.pagesRead = read.pagesRead;
1048
+ return unevaluable(read.detail);
1049
+ }
1050
+ const state = read.state;
1051
+ detail.pr = state.number;
1052
+ detail.headSha = state.headSha;
1053
+ detail.mergeStateStatus = state.mergeStateStatus;
1054
+ detail.checks = summarizeChecks(state.checks);
1055
+ detail.threads = {
1056
+ unresolvedBot: unresolvedBotThreads(state.threads).length,
1057
+ pagesRead: state.pagesRead,
1058
+ complete: state.complete,
1059
+ };
1060
+ detail.changesRequestedBy = changesRequestedBy(state.reviews);
1061
+ detail.highInline = highSeverityInlines(state.threads, state.headSha).length;
1062
+ // The predicates are read BEFORE the unreadable-commit arm below: a failure
1063
+ // that stands in charter order ahead of predicate 4 is a fact, and a tier
1064
+ // never softens a fact — the arm that returned first here turned a failing
1065
+ // check plus one unplaceable HIGH inline into the UNEVALUABLE class, which
1066
+ // pilot maps to `warn` (mmnto-ai/totem#2844 round 1, CodeRabbit).
1067
+ const blocked = firstFailure(state, detail);
1068
+ // A bot HIGH inline whose `comment.commit` came back null cannot be placed
1069
+ // against the head, so predicate 4's input is missing for it: unevaluable and
1070
+ // NAMED, never a silent pass (round 2, F8). It still preempts predicate 5 —
1071
+ // predicate 4's missing input comes before predicate 5 in charter order —
1072
+ // but never a failure of predicates 1–3 or a READABLE predicate-4 failure.
1073
+ const unreadable = unreadableCommitHighInlines(state.threads);
1074
+ if ((blocked === null || blocked.predicate === 'merge-state') && unreadable.length > 0) {
1075
+ const first = unreadable[0];
1076
+ return unevaluable(`${unreadable.length} HIGH bot inline(s) carry no commit, so predicate 4 cannot place them against the head — the first is ${first.rootLogin ?? 'a bot'}: "${bounded(first.rootBody)}"`);
1077
+ }
1078
+ // The caller's local head is evidence, not a predicate: a local branch can
1079
+ // legitimately sit ahead of or behind the PR head, and the charter's floor
1080
+ // does not include "your checkout matches". A mismatch is NAMED, never silent.
1081
+ if (parsed.headSha !== undefined && parsed.headSha !== state.headSha) {
1082
+ notices.push(`${MERGE_READY_NOTICE_PREFIX} the payload head sha (${shortSha(parsed.headSha)}) is not the PR head (${shortSha(state.headSha)}); the floor was read against the PR head.`);
1083
+ }
1084
+ // R5: zero checks passes predicate 1 as a FACT, with the count in provenance
1085
+ // and one stderr line. Branch protection owns "must have checks".
1086
+ if (detail.checks.total === 0) {
1087
+ notices.push(`${MERGE_READY_NOTICE_PREFIX} ${parsed.repo}#${state.number} at ${shortSha(state.headSha)} has ZERO status checks${state.rollupPresent ? '' : ' (no rollup on the head commit)'} — predicate 1 passes as a fact; branch protection, not this gate, decides whether zero checks may merge.`);
1088
+ }
1089
+ if (override) {
1090
+ return allowByOverride(blocked === null
1091
+ ? 'nothing (every predicate passed)'
1092
+ : `${blocked.predicate} — ${blocked.evidence}`);
1093
+ }
1094
+ // `mergeStateStatus: UNKNOWN` means GitHub has not computed mergeability yet:
1095
+ // an input that failed to derive, not a fact — the UNEVALUABLE class.
1096
+ if (blocked === null && state.mergeStateStatus === 'UNKNOWN') {
1097
+ return unevaluable('GitHub has not computed mergeability yet (mergeStateStatus: UNKNOWN) — retry');
1098
+ }
1099
+ if (blocked === null && !MERGE_STATE_PASS.has(state.mergeStateStatus)) {
1100
+ return unevaluable(`mergeStateStatus "${bounded(state.mergeStateStatus)}" is not a value this gate reads`);
1101
+ }
1102
+ if (blocked !== null) {
1103
+ return {
1104
+ verdict: {
1105
+ disposition: 'deny',
1106
+ reason: `${parsed.repo}#${state.number} is not merge-ready: ${blocked.evidence}.`,
1107
+ provenance: {
1108
+ source: MERGE_READY_SOURCE,
1109
+ ref: blocked.predicate,
1110
+ matched: bounded(blocked.evidence),
1111
+ checkedAt,
1112
+ detail: { ...detail },
1113
+ },
1114
+ },
1115
+ detail,
1116
+ notices,
1117
+ };
1118
+ }
1119
+ const botReviewSeen = state.threads.some((t) => t.rootLogin !== null && isBotReviewerLoginExact(t.rootLogin));
1120
+ const factClause = botReviewSeen ? '' : ' No bot review present — predicates 2–4 pass as a fact.';
1121
+ return {
1122
+ verdict: {
1123
+ disposition: 'allow',
1124
+ reason: `${parsed.repo}#${state.number} at ${shortSha(state.headSha)} meets the merge-ready floor (${detail.checks.success}/${detail.checks.total} checks green, mergeStateStatus ${state.mergeStateStatus}).${factClause}`,
1125
+ provenance: {
1126
+ source: MERGE_READY_SOURCE,
1127
+ ref: 'merge-ready',
1128
+ matched: state.headSha,
1129
+ checkedAt,
1130
+ detail: { ...detail },
1131
+ },
1132
+ },
1133
+ detail,
1134
+ notices,
1135
+ };
1136
+ }
1137
+ /** The FIRST predicate that fails, in charter order, or null when the floor is met. */
1138
+ function firstFailure(state, detail) {
1139
+ // 1. checks
1140
+ if (detail.checks.failing > 0) {
1141
+ const names = state.checks
1142
+ .filter((c) => c.kind === 'failing')
1143
+ .map((c) => c.name)
1144
+ .join(', ');
1145
+ return {
1146
+ predicate: 'checks',
1147
+ evidence: `${detail.checks.failing} of ${detail.checks.total} status checks are failing (${bounded(names)})`,
1148
+ };
1149
+ }
1150
+ if (detail.checks.pending > 0) {
1151
+ const names = state.checks
1152
+ .filter((c) => c.kind === 'pending')
1153
+ .map((c) => c.name)
1154
+ .join(', ');
1155
+ return {
1156
+ predicate: 'checks',
1157
+ evidence: `${detail.checks.pending} of ${detail.checks.total} status checks are still running (${bounded(names)})`,
1158
+ };
1159
+ }
1160
+ // 2. unresolved, non-outdated bot threads
1161
+ const unresolved = unresolvedBotThreads(state.threads);
1162
+ if (unresolved.length > 0) {
1163
+ const first = unresolved[0];
1164
+ return {
1165
+ predicate: 'unresolved-bot-threads',
1166
+ evidence: `${unresolved.length} unresolved bot review thread(s) — the first is ${first.rootLogin ?? 'a bot'}: "${bounded(first.rootBody)}"`,
1167
+ };
1168
+ }
1169
+ // 3. un-superseded CHANGES_REQUESTED
1170
+ if (detail.changesRequestedBy.length > 0) {
1171
+ return {
1172
+ predicate: 'changes-requested',
1173
+ evidence: `CHANGES_REQUESTED stands from ${detail.changesRequestedBy.join(', ')}`,
1174
+ };
1175
+ }
1176
+ // 4. HIGH/Major bot inline on the head commit
1177
+ const high = highSeverityInlines(state.threads, state.headSha);
1178
+ if (high.length > 0) {
1179
+ const first = high[0];
1180
+ return {
1181
+ predicate: 'high-severity-inline',
1182
+ evidence: `${high.length} HIGH/Major bot inline(s) still applying to the head commit — the first is ${first.rootLogin ?? 'a bot'}: "${bounded(first.rootBody)}"`,
1183
+ };
1184
+ }
1185
+ // 5. GitHub's own mergeability
1186
+ const denyReason = MERGE_STATE_DENY.get(state.mergeStateStatus);
1187
+ if (denyReason !== undefined) {
1188
+ return {
1189
+ predicate: 'merge-state',
1190
+ evidence: `mergeStateStatus is ${state.mergeStateStatus} — ${denyReason}`,
1191
+ };
1192
+ }
1193
+ return null;
1194
+ }
1195
+ // ─── The registry entry ─────────────────────────────────────────────────────
1196
+ /**
1197
+ * The production gh seam: spawn `gh` with an argv (never a shell string) and
1198
+ * report `{ stdout, exitCode }`. A spawn failure (gh absent) and a non-zero
1199
+ * exit are the SAME shape here — both are "the read did not answer", which the
1200
+ * evaluator turns into the named UNEVALUABLE class.
1201
+ *
1202
+ * When gh writes its diagnosis to stderr and nothing to stdout (the missing
1203
+ * binary, an auth failure), the stderr text is returned as `stdout` so the
1204
+ * reason can name what gh said; the exit code is what decides.
1205
+ */
1206
+ export function makeGhRunner(timeoutMs = 30_000, execute = safeExec) {
1207
+ return (args) => {
1208
+ // totem-context: NOT a swallowed error — the seam's contract is
1209
+ // `{ stdout, exitCode }`, so a spawn failure (gh absent) and a non-zero exit
1210
+ // must arrive at the evaluator the SAME way: as a read that did not answer,
1211
+ // which becomes a NAMED unevaluable verdict carrying what gh said. Throwing
1212
+ // instead would erase that text and land in the wrapper's generic
1213
+ // fail-closed arm; nothing here can return a clean read (Tenet 4).
1214
+ try {
1215
+ return {
1216
+ stdout: execute('gh', args, { timeout: timeoutMs, trim: false }),
1217
+ exitCode: 0,
1218
+ };
1219
+ // totem-context: intentional degradation — see the directive above the try; dual placement so the rule reads either the catch-keyword line or the catch body.
1220
+ }
1221
+ catch (err) {
1222
+ // totem-context: intentional degradation — a gh that did not answer is the NAMED unevaluable class, carrying what gh said; never a clean read.
1223
+ const fields = err;
1224
+ const stdout = fields.stdout?.trim();
1225
+ const stderr = fields.stderr?.trim();
1226
+ const text = stdout !== undefined && stdout !== ''
1227
+ ? stdout
1228
+ : stderr !== undefined && stderr !== ''
1229
+ ? stderr
1230
+ : err instanceof Error
1231
+ ? err.message
1232
+ : String(err);
1233
+ const status = typeof fields.status === 'number' ? fields.status : 1;
1234
+ return { stdout: text, exitCode: status === 0 ? 1 : status };
1235
+ }
1236
+ };
1237
+ }
1238
+ /**
1239
+ * The registry evaluator. Builds the production runner lazily (so no gh is
1240
+ * spawned unless this gate actually runs), evaluates, and prints the gate's
1241
+ * stderr lines through the injected sink.
1242
+ */
1243
+ export const mergeReadyEvaluator = (payload, _totemDir, context) => {
1244
+ const runner = context?.ghRunner ?? makeGhRunner();
1245
+ const evaluation = evaluateMergeReady(payload, {
1246
+ runner,
1247
+ tier: context?.tier,
1248
+ env: context?.env,
1249
+ });
1250
+ const write = context?.writeStderr ?? ((line) => void process.stderr.write(line));
1251
+ for (const notice of evaluation.notices) {
1252
+ write(`${notice}\n`);
1253
+ }
1254
+ return evaluation.verdict;
1255
+ };
1256
+ //# sourceMappingURL=merge-ready.js.map