@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,814 @@
1
+ /**
2
+ * merge-ready (mmnto-ai/totem#2800) — the gate's invariants, driven by the
3
+ * checked-in fixtures under `gate-fixtures/merge-ready/`.
4
+ *
5
+ * FOUR of those fixtures are REAL `gh api graphql` captures taken with the
6
+ * exported {@link MERGE_READY_QUERY} (R4): three PR captures —
7
+ * mmnto-ai/liquid-city#363 (green rollup, one unresolved HIGH inline),
8
+ * mmnto-ai/totem-strategy#1251, and mmnto-ai/totem#2827 (the comment that
9
+ * re-pointed to the head) — plus the benign corpus, every bot inline thread on
10
+ * mmnto-ai/totem#2820-2839, which measures the severity read's ADR-109
11
+ * false-positive budget. The other 44 are synthetic and labelled `synthetic-`
12
+ * in their names — one per invariant the captures cannot exercise (all three
13
+ * PRs are merged, so GitHub answers `mergeStateStatus: UNKNOWN` for each and no
14
+ * capture can carry a BEHIND / DIRTY / BLOCKED head, or a resolved HIGH thread);
15
+ * the five PR-round-1 rows (mmnto-ai/totem#2844) cover the strict connection
16
+ * reads and the predicate order ahead of the unreadable-commit arm.
17
+ * The README beside them lists every file with its sha256 and instant, and a
18
+ * test recomputes those receipts from the files on disk.
19
+ *
20
+ * The network never runs here: every test injects the {@link GhRunner} seam.
21
+ */
22
+ import { createHash } from 'node:crypto';
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { describe, expect, it } from 'vitest';
27
+ import { TotemError } from './errors.js';
28
+ import { evaluateMergeReady, hasHighSeverityMarker, MERGE_READY_BRANCH_QUERY, MERGE_READY_OVERRIDE_ENV, MERGE_READY_QUERY, MERGE_READY_SOURCE, mergeReadyEvaluator, parseMergeReadyPayload, } from './merge-ready.js';
29
+ const FIXTURE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'gate-fixtures', 'merge-ready');
30
+ function loadFixture(name) {
31
+ return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf-8'));
32
+ }
33
+ /** A runner backed by a fixture: `--version` first, then one page per graphql call. */
34
+ function runnerFor(fixture) {
35
+ const calls = [];
36
+ let next = 0;
37
+ const runner = (args) => {
38
+ calls.push(args);
39
+ if (args.length === 1 && args[0] === '--version') {
40
+ return { stdout: fixture.ghVersion, exitCode: fixture.ghVersionExitCode ?? 0 };
41
+ }
42
+ const page = fixture.pages[next++];
43
+ if (page === undefined) {
44
+ throw new Error(`fixture exhausted after ${next - 1} pages — the read asked for one more`);
45
+ }
46
+ return {
47
+ stdout: page.body !== undefined ? JSON.stringify(page.body) : (page.stdout ?? ''),
48
+ exitCode: page.exitCode,
49
+ };
50
+ };
51
+ return { runner, calls };
52
+ }
53
+ /** Evaluate a fixture against a payload; the tier defaults to strict. */
54
+ function evaluate(name, overrides = {}) {
55
+ const fixture = loadFixture(name);
56
+ const { runner, calls } = runnerFor(fixture);
57
+ const payload = overrides.payload ?? {
58
+ repo: fixture.repo ?? 'mmnto-ai/totem',
59
+ pr: fixture.pr ?? 4242,
60
+ };
61
+ const evaluation = evaluateMergeReady(payload, {
62
+ runner,
63
+ tier: overrides.tier,
64
+ env: overrides.env ?? {},
65
+ });
66
+ return { ...evaluation, calls };
67
+ }
68
+ // ─── The README is a RECEIPT, not a claim (round 3, F1) ─────────────────────
69
+ //
70
+ // The corpus row once carried the hash the capture script printed BEFORE
71
+ // `prettier --write` reformatted the JSON, so the README described bytes that
72
+ // never reached a commit. A receipt nobody re-derives is a claim; this test
73
+ // re-derives every one of them from the files on disk.
74
+ describe('merge-ready — the fixture README matches the fixtures', () => {
75
+ const CAPTURES = [
76
+ 'benign-corpus-bot-inlines.json',
77
+ 'liquid-city-363.json',
78
+ 'totem-2827.json',
79
+ 'totem-strategy-1251.json',
80
+ ];
81
+ it('every sha256 in the README is the sha256 of the file it names', () => {
82
+ const readme = fs.readFileSync(path.join(FIXTURE_DIR, 'README.md'), 'utf-8');
83
+ const files = fs
84
+ .readdirSync(FIXTURE_DIR)
85
+ .filter((f) => f.endsWith('.json'))
86
+ .sort();
87
+ const drift = [];
88
+ const unlisted = [];
89
+ for (const file of files) {
90
+ const row = new RegExp('`' + file.replace(/\./g, '\\.') + '`[^\\n]*?`([0-9a-f]{64})`');
91
+ const match = row.exec(readme);
92
+ if (match === null) {
93
+ unlisted.push(file);
94
+ continue;
95
+ }
96
+ const actual = createHash('sha256')
97
+ .update(fs.readFileSync(path.join(FIXTURE_DIR, file)))
98
+ .digest('hex');
99
+ if (match[1] !== actual) {
100
+ drift.push(`${file}: README says ${match[1].slice(0, 12)}…, file is ${actual.slice(0, 12)}…`);
101
+ }
102
+ }
103
+ expect(unlisted, 'fixtures with no README row').toEqual([]);
104
+ expect(drift, 'README receipts that no longer match their files').toEqual([]);
105
+ });
106
+ it('the capture / synthetic split is what the README and this header say', () => {
107
+ const files = fs
108
+ .readdirSync(FIXTURE_DIR)
109
+ .filter((f) => f.endsWith('.json'))
110
+ .sort();
111
+ const synthetic = files.filter((f) => f.startsWith('synthetic-'));
112
+ const captures = files.filter((f) => !f.startsWith('synthetic-'));
113
+ expect(captures.sort()).toEqual(CAPTURES);
114
+ expect(synthetic.length).toBe(44);
115
+ expect(files.length).toBe(48);
116
+ });
117
+ it('the README query sha is the sha of the exported query (round 4, F7)', () => {
118
+ // A receipt for the QUERY, re-derived like the file receipts: change the
119
+ // query without re-capturing and this row stops matching, which is exactly
120
+ // when the fixtures stop describing what the evaluator would receive.
121
+ const readme = fs.readFileSync(path.join(FIXTURE_DIR, 'README.md'), 'utf-8');
122
+ const actual = createHash('sha256')
123
+ .update(Buffer.from(MERGE_READY_QUERY, 'utf-8'))
124
+ .digest('hex');
125
+ const claimed = /sha256 of that query string at capture time:\s*\n?`([0-9a-f]{64})`/.exec(readme);
126
+ expect(claimed, 'the README no longer states a query sha').not.toBeNull();
127
+ expect(claimed[1]).toBe(actual);
128
+ });
129
+ });
130
+ // ─── The three real PR captures (R4) ────────────────────────────────────────
131
+ describe('merge-ready — the checked-in captures', () => {
132
+ it('mmnto-ai/liquid-city#363 (capture): green rollup, one unresolved HIGH bot inline → deny', () => {
133
+ const e = evaluate('liquid-city-363.json');
134
+ expect(e.verdict.disposition).toBe('deny');
135
+ expect(e.verdict.provenance.ref).toBe('unresolved-bot-threads');
136
+ expect(e.verdict.provenance.source).toBe(MERGE_READY_SOURCE);
137
+ expect(e.detail.checks).toEqual({ total: 6, success: 6, pending: 0, failing: 0 });
138
+ expect(e.detail.threads.unresolvedBot).toBe(1);
139
+ // The root comment is GCA's, spelled WITHOUT the `[bot]` suffix on the
140
+ // GraphQL surface — the trap `bot-identity.ts` exists to hold.
141
+ expect(e.verdict.reason).toContain('gemini-code-assist');
142
+ expect(e.detail.highInline).toBe(1);
143
+ expect(e.detail.headSha).toBe('d09ce906b1a12125e089b77e668bea25f1bdef2d');
144
+ expect(e.detail.evaluatedBy).toBe('gh 2.99.0');
145
+ });
146
+ it('mmnto-ai/totem-strategy#1251 (capture): clean predicates, UNKNOWN mergeability → unevaluable', () => {
147
+ const strict = evaluate('totem-strategy-1251.json');
148
+ expect(strict.verdict.disposition).toBe('deny');
149
+ expect(strict.verdict.provenance.ref).toBe('unevaluable');
150
+ expect(strict.detail.checks.total).toBe(3);
151
+ expect(strict.detail.threads.unresolvedBot).toBe(0);
152
+ expect(strict.notices.join('\n')).toMatch(/could not derive/);
153
+ const pilot = evaluate('totem-strategy-1251.json', { tier: 'pilot' });
154
+ expect(pilot.verdict.disposition).toBe('warn');
155
+ expect(pilot.notices.join('\n')).toMatch(/tier=pilot/);
156
+ });
157
+ it('mmnto-ai/totem#2827 (capture): the comment re-pointed to the head while originalCommit stayed behind', () => {
158
+ // The corpus witness for fold F2. GitHub moves `comment.commit.oid` to the
159
+ // commit the finding CURRENTLY applies to; `originalCommit.oid` never
160
+ // moves. Here they differ and only the current one is the head — so a
161
+ // predicate keyed on the write-time commit reads this real HIGH finding as
162
+ // "not on head" and goes inert. Read straight off the capture, not asserted
163
+ // through the evaluator, so it stays a statement about GitHub's data.
164
+ const fixture = loadFixture('totem-2827.json');
165
+ const body = fixture.pages[0].body;
166
+ const pr = body.data.repository.pullRequest;
167
+ const root = pr.reviewThreads.nodes[0].comments.nodes[0];
168
+ expect(root.commit.oid).toBe(pr.headRefOid);
169
+ expect(root.originalCommit.oid).not.toBe(pr.headRefOid);
170
+ const e = evaluate('totem-2827.json');
171
+ expect(e.detail.highInline).toBe(1);
172
+ expect(e.verdict.disposition).toBe('deny');
173
+ });
174
+ it('sends the exported query, as argv, with no shell string anywhere', () => {
175
+ const e = evaluate('liquid-city-363.json');
176
+ expect(e.calls[0]).toEqual(['--version']);
177
+ const graphql = e.calls[1];
178
+ expect(graphql.slice(0, 3)).toEqual(['api', 'graphql', '-f']);
179
+ expect(graphql[3]).toBe(`query=${MERGE_READY_QUERY}`);
180
+ expect(graphql).toContain('owner=mmnto-ai');
181
+ expect(graphql).toContain('name=liquid-city');
182
+ expect(graphql).toContain('number=363');
183
+ // A typed variable for `$number: Int!`, a string for the rest.
184
+ expect(graphql[graphql.indexOf('number=363') - 1]).toBe('-F');
185
+ });
186
+ });
187
+ // ─── Predicate 1: checks ────────────────────────────────────────────────────
188
+ describe('merge-ready — predicate 1 (checks)', () => {
189
+ it('a failing check denies and names it', () => {
190
+ const e = evaluate('synthetic-failing-check.json');
191
+ expect(e.verdict.disposition).toBe('deny');
192
+ expect(e.verdict.provenance.ref).toBe('checks');
193
+ expect(e.verdict.reason).toContain('Totem Lint');
194
+ expect(e.detail.checks).toEqual({ total: 2, success: 1, pending: 0, failing: 1 });
195
+ });
196
+ it('a still-running check denies and names it', () => {
197
+ const e = evaluate('synthetic-pending-check.json');
198
+ expect(e.verdict.disposition).toBe('deny');
199
+ expect(e.verdict.provenance.ref).toBe('checks');
200
+ expect(e.verdict.reason).toMatch(/still running/);
201
+ expect(e.detail.checks.pending).toBe(1);
202
+ });
203
+ it('R5 — zero checks passes predicate 1 as a FACT, with the count and ONE stderr line', () => {
204
+ const e = evaluate('synthetic-zero-checks.json');
205
+ expect(e.verdict.disposition).toBe('allow');
206
+ expect(e.detail.checks).toEqual({ total: 0, success: 0, pending: 0, failing: 0 });
207
+ const zeroLines = e.notices.filter((n) => n.includes('ZERO status checks'));
208
+ expect(zeroLines).toHaveLength(1);
209
+ expect(zeroLines[0]).toMatch(/branch protection/i);
210
+ });
211
+ // ─── The rollup must BELONG to the head commit (fold F7) ────────────────
212
+ //
213
+ // Predicate 1 reads `commits(last: 1)`. Every way that node can fail to be
214
+ // the head commit's green rollup is UNEVALUABLE — a green light read off
215
+ // another commit, or off an unreadable answer, is the one outcome this gate
216
+ // must never produce.
217
+ it('a rollup that belongs to another commit is unevaluable, never a green light', () => {
218
+ const e = evaluate('synthetic-rollup-commit-mismatch.json');
219
+ expect(e.verdict.disposition).toBe('deny');
220
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
221
+ expect(e.verdict.reason).toMatch(/rollup belongs to/i);
222
+ expect(evaluate('synthetic-rollup-commit-mismatch.json', { tier: 'pilot' }).verdict.disposition).toBe('warn');
223
+ });
224
+ it('a PR that answered no commits is unevaluable', () => {
225
+ const e = evaluate('synthetic-no-commits.json');
226
+ expect(e.verdict.disposition).toBe('deny');
227
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
228
+ expect(e.verdict.reason).toMatch(/no commits/i);
229
+ });
230
+ it('a rollup with no contexts connection is unevaluable, NOT the zero-checks fact', () => {
231
+ const e = evaluate('synthetic-rollup-no-contexts.json');
232
+ expect(e.verdict.disposition).toBe('deny');
233
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
234
+ expect(e.verdict.reason).toMatch(/no contexts connection/i);
235
+ expect(e.notices.some((n) => n.includes('ZERO status checks'))).toBe(false);
236
+ });
237
+ it('a rollup reporting PENDING while listing zero checks is unevaluable, not R5', () => {
238
+ const e = evaluate('synthetic-rollup-state-without-checks.json');
239
+ expect(e.verdict.disposition).toBe('deny');
240
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
241
+ expect(e.verdict.reason).toMatch(/listed no checks/i);
242
+ expect(e.notices.some((n) => n.includes('ZERO status checks'))).toBe(false);
243
+ });
244
+ it('a rollup that CLAIMS checks while listing none is unevaluable, not R5 (round 3, F9)', () => {
245
+ // SUCCESS over an empty list is only the zero-checks fact when the rollup
246
+ // also says the count is zero. `totalCount: 3` with nothing listed is a
247
+ // truncated answer, not a PR without checks.
248
+ const e = evaluate('synthetic-rollup-count-without-checks.json');
249
+ expect(e.verdict.disposition).toBe('deny');
250
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
251
+ expect(e.verdict.reason).toMatch(/claims 3 checks but the read materialised 0/i);
252
+ expect(e.notices.some((n) => n.includes('ZERO status checks'))).toBe(false);
253
+ expect(evaluate('synthetic-rollup-count-without-checks.json', { tier: 'pilot' }).verdict.disposition).toBe('warn');
254
+ });
255
+ it('a totalCount that is not a non-negative integer is unevaluable (round 4, F3)', () => {
256
+ // The count is judged AS THE API TYPED IT. A `> 0` test on an untyped value
257
+ // let a string "3", a boolean and a negative fall through to the
258
+ // zero-checks fact and ALLOW.
259
+ for (const file of [
260
+ 'synthetic-rollup-count-string.json',
261
+ 'synthetic-rollup-count-negative.json',
262
+ 'synthetic-rollup-count-boolean.json',
263
+ ]) {
264
+ const e = evaluate(file);
265
+ expect(e.verdict.disposition, file).toBe('deny');
266
+ expect(e.verdict.provenance.ref, file).toBe('unevaluable');
267
+ expect(e.verdict.reason, file).toMatch(/not a non-negative integer/i);
268
+ expect(e.notices.some((n) => n.includes('ZERO status checks')), file).toBe(false);
269
+ expect(evaluate(file, { tier: 'pilot' }).verdict.disposition, file).toBe('warn');
270
+ }
271
+ });
272
+ it('a claimed count the read did not materialise is an incomplete read (round 4, F3)', () => {
273
+ // `totalCount: 3` with ONE node listed used to read as "1/1 green" and
274
+ // allow. The claim and the delivery must agree, or the read is incomplete.
275
+ const e = evaluate('synthetic-rollup-count-mismatch.json');
276
+ expect(e.verdict.disposition).toBe('deny');
277
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
278
+ expect(e.verdict.reason).toMatch(/claims 3 checks but the read materialised 1/i);
279
+ expect(evaluate('synthetic-rollup-count-mismatch.json', { tier: 'pilot' }).verdict.disposition).toBe('warn');
280
+ });
281
+ it('a rollup whose state is null or not a string is unevaluable, not R5 (round 2, F3)', () => {
282
+ // R5's fact needs the rollup to be CONSISTENT about having no checks: absent
283
+ // entirely, or SUCCESS over an empty list. A state that cannot be read is
284
+ // not a green light — before this it fell through the fact and ALLOWED.
285
+ for (const file of [
286
+ 'synthetic-rollup-state-null.json',
287
+ 'synthetic-rollup-state-non-string.json',
288
+ ]) {
289
+ const e = evaluate(file);
290
+ expect(e.verdict.disposition, file).toBe('deny');
291
+ expect(e.verdict.provenance.ref, file).toBe('unevaluable');
292
+ expect(e.verdict.reason, file).toMatch(/unreadable/i);
293
+ expect(e.notices.some((n) => n.includes('ZERO status checks')), file).toBe(false);
294
+ expect(evaluate(file, { tier: 'pilot' }).verdict.disposition, file).toBe('warn');
295
+ }
296
+ });
297
+ });
298
+ // ─── Predicate 2 + 4: bot threads and severity ──────────────────────────────
299
+ describe('merge-ready — predicates 2 and 4 (bot threads)', () => {
300
+ it('an unresolved, non-outdated bot thread denies', () => {
301
+ const e = evaluate('synthetic-unresolved-bot-thread.json');
302
+ expect(e.verdict.disposition).toBe('deny');
303
+ expect(e.verdict.provenance.ref).toBe('unresolved-bot-threads');
304
+ expect(e.detail.threads.unresolvedBot).toBe(1);
305
+ });
306
+ it('resolved, outdated and HUMAN threads never deny (the bot filter is the identity list)', () => {
307
+ const e = evaluate('synthetic-resolved-outdated-human-threads.json');
308
+ expect(e.verdict.disposition).toBe('allow');
309
+ expect(e.detail.threads.unresolvedBot).toBe(0);
310
+ expect(e.detail.highInline).toBe(0);
311
+ });
312
+ it('a HIGH inline whose comment applies to an OLDER commit does not deny', () => {
313
+ const e = evaluate('synthetic-stale-commit-high-inline.json');
314
+ expect(e.verdict.disposition).toBe('allow');
315
+ expect(e.detail.highInline).toBe(0);
316
+ });
317
+ it('a RESOLVED HIGH inline that still applies to the HEAD commit denies at predicate 4', () => {
318
+ // Predicate 4's own territory (ruled, fold F2): a human resolved the thread
319
+ // without changing the code, so predicate 2 passes and the finding still
320
+ // applies to what would merge. Resolution is IGNORED here by design.
321
+ const e = evaluate('synthetic-head-commit-high-inline.json');
322
+ expect(e.verdict.disposition).toBe('deny');
323
+ expect(e.verdict.provenance.ref).toBe('high-severity-inline');
324
+ expect(e.detail.threads.unresolvedBot).toBe(0); // predicate 2 did NOT fire
325
+ expect(e.detail.highInline).toBe(1);
326
+ });
327
+ it('reads comment.commit (where the finding applies NOW), never originalCommit', () => {
328
+ // The falsifier for the inert predicate this fold removed: both fixtures
329
+ // carry the SAME `originalCommit` (an older sha) and differ only in
330
+ // `commit.oid`. A predicate keyed on the write-time commit cannot tell them
331
+ // apart — it would allow both.
332
+ const stale = evaluate('synthetic-stale-commit-high-inline.json');
333
+ const current = evaluate('synthetic-head-commit-high-inline.json');
334
+ expect(stale.verdict.disposition).toBe('allow');
335
+ expect(current.verdict.disposition).toBe('deny');
336
+ });
337
+ it('CodeRabbit "Potential issue" reads as a HIGH marker (fold F9)', () => {
338
+ const e = evaluate('synthetic-coderabbit-potential-issue.json');
339
+ expect(e.verdict.disposition).toBe('deny');
340
+ expect(e.verdict.provenance.ref).toBe('high-severity-inline');
341
+ expect(hasHighSeverityMarker('_Potential issue_ the error is dropped')).toBe(true);
342
+ expect(hasHighSeverityMarker('nit: rename this local')).toBe(false);
343
+ });
344
+ });
345
+ describe('merge-ready — the severity read over the benign corpus', () => {
346
+ const corpus = JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, 'benign-corpus-bot-inlines.json'), 'utf-8'));
347
+ it('the corpus is the whole window, not a hand-picked subset', () => {
348
+ expect(corpus.threads.length).toBe(16);
349
+ expect(corpus.prsThatAnswered).toBe(10);
350
+ // Every one of the three review bots is represented.
351
+ expect(new Set(corpus.threads.map((t) => t.bot))).toEqual(new Set(['greptile-apps', 'coderabbitai', 'gemini-code-assist']));
352
+ });
353
+ it('BUDGET: the read agrees with every bot own severity label — zero false reads', () => {
354
+ const disagreements = corpus.threads.filter((t) => hasHighSeverityMarker(t.body) !== t.expectedHigh);
355
+ expect(disagreements.map((t) => `${t.thread} (${t.declaredLabel})`), 'the severity read disagreed with a bot own declaration').toEqual([]);
356
+ expect(corpus.threads.filter((t) => t.expectedHigh).length).toBe(8);
357
+ });
358
+ it('states the re-pointing counts the README claims (round 3, F2)', () => {
359
+ // `comment.commit` moves as the diff moves; `originalCommit` does not.
360
+ // Predicate 4 exists because of that gap, so the corpus must SHOW it — and
361
+ // show it where it actually happens. mmnto-ai/totem#2831 re-points nothing
362
+ // among its HIGH threads, which the README used to claim it did.
363
+ const rePointed = corpus.threads.filter((t) => t.rePointed);
364
+ const highRePointed = rePointed.filter((t) => t.expectedHigh);
365
+ expect(rePointed.length).toBe(8);
366
+ expect(highRePointed.map((t) => t.pr).sort()).toEqual([2827, 2839, 2839]);
367
+ // The per-PR breakdowns the README prints, asserted here so the table
368
+ // cannot drift from the fixture (round 4, F12).
369
+ const byPr = (rows) => rows.reduce((acc, t) => ({ ...acc, [t.pr]: (acc[t.pr] ?? 0) + 1 }), {});
370
+ expect(byPr(rePointed)).toEqual({ 2827: 1, 2830: 1, 2831: 2, 2834: 1, 2839: 3 });
371
+ expect(byPr(highRePointed)).toEqual({ 2827: 1, 2839: 2 });
372
+ expect(byPr(corpus.threads.filter((t) => t.expectedHigh))).toEqual({
373
+ 2821: 1,
374
+ 2827: 1,
375
+ 2830: 1,
376
+ 2831: 3,
377
+ 2839: 2,
378
+ });
379
+ expect(corpus.threads.filter((t) => t.pr === 2831 && t.expectedHigh && t.rePointed)).toEqual([]);
380
+ // Every row carries both commit fields, so the claim stays checkable.
381
+ for (const t of corpus.threads) {
382
+ expect(typeof t.commit, t.thread).toBe('string');
383
+ expect(typeof t.originalCommit, t.thread).toBe('string');
384
+ }
385
+ });
386
+ it('the greptile P2 thread on mmnto-ai/totem#2831 reads NOT high (the fold F4 falsifier)', () => {
387
+ // The prose read this replaced flagged it, through the word "critical" in
388
+ // its own explanation: a false deny on a finding its author ranked below
389
+ // the bar. The badge alt text is what decides now.
390
+ const p2 = corpus.threads.find((t) => t.thread === 'mmnto-ai/totem#2831/thread-2');
391
+ expect(p2, 'the P2 specimen must stay in the corpus').toBeDefined();
392
+ expect(p2.declaredLabel).toBe('alt="P2"');
393
+ expect(p2.body.toLowerCase()).toContain('critical'); // the prose that fooled the old read
394
+ expect(hasHighSeverityMarker(p2.body)).toBe(false);
395
+ });
396
+ it('CODE is not a label: a marker quoted in a fence or a code span is not high (round 3, F4)', () => {
397
+ // The firing path this closes: this gate's own docstring carries every
398
+ // marker, so a CodeRabbit Minor whose suggestion block quotes merge-ready.ts
399
+ // would read as HIGH and false-deny the gate's own maintenance PR.
400
+ const quoted = JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, 'synthetic-benign-fenced-marker-quote.json'), 'utf-8'));
401
+ const row = quoted.threads[0];
402
+ expect(row.declaredLabel).toBe('_\u{1F7E1} Minor_');
403
+ expect(row.body).toContain('```'); // the fence really is in the body
404
+ expect(hasHighSeverityMarker(row.body)).toBe(false);
405
+ // A4 — a TRUE control (round 4, F11): a fenced block on its own lines
406
+ // carrying the label cell. Pre-strip this read HIGH (the cell opens a line
407
+ // inside the fence); post-strip it does not. The earlier A4 row was an
408
+ // inline span mid-sentence, which never read HIGH either way.
409
+ expect(hasHighSeverityMarker('here is the row:\n\n```md\n| _\u{1F7E0} Major_ |\n```\n\nend')).toBe(false);
410
+ // The same cell OUTSIDE the fence still reads HIGH — the control's other half.
411
+ expect(hasHighSeverityMarker('here is the row:\n\n| _\u{1F7E0} Major_ |\n\nend')).toBe(true);
412
+ // A5 — a backtick fence.
413
+ expect(hasHighSeverityMarker('see below\n\n```md\n_\u{1F534} Critical_ | body\n```\n\ndone')).toBe(false);
414
+ // A6 — a tilde fence.
415
+ expect(hasHighSeverityMarker('see below\n\n~~~md\n<img alt="P1" src="x.svg">\n~~~\n\ndone')).toBe(false);
416
+ // The complement: the SAME label outside code still reads high.
417
+ expect(hasHighSeverityMarker('_\u{1F534} Critical_ | body')).toBe(true);
418
+ // An indented block and a <pre> block are code too (round 4, F4).
419
+ expect(hasHighSeverityMarker('example:\n\n _\u{1F534} Critical_ | body\n\ndone')).toBe(false);
420
+ expect(hasHighSeverityMarker('example:\n\n<pre>\n_\u{1F534} Critical_ | body\n</pre>\n')).toBe(false);
421
+ });
422
+ it('stripping code leaves a PLACEHOLDER, not a gap that opens an anchor (round 4, F2)', () => {
423
+ // The round-3 stripper replaced a span with a SPACE, so the text after it
424
+ // began a fresh line/cell position and the CR arm matched — a regression
425
+ // the stripper itself introduced. Each of these read HIGH with a space
426
+ // placeholder and must not now.
427
+ expect(hasHighSeverityMarker('``_\u{1F7E0} Major_``')).toBe(false);
428
+ expect(hasHighSeverityMarker('`x`_\u{1F7E0} Major_')).toBe(false);
429
+ expect(hasHighSeverityMarker('| `x`_\u{1F7E0} Major_ |')).toBe(false);
430
+ // A label with no code anywhere near it is untouched by the stripper.
431
+ expect(hasHighSeverityMarker('| _\u{1F7E0} Major_ |')).toBe(true);
432
+ });
433
+ it('the glyph class is read as CODE POINTS, not surrogate halves (round 4, F9)', () => {
434
+ // Without the `u` flag the astral dots decompose, so a lone surrogate — or
435
+ // a bare variation selector — before "major" matched as if it were one.
436
+ expect(hasHighSeverityMarker('\uD83D major rewrite')).toBe(false);
437
+ expect(hasHighSeverityMarker('\uDD34 major rewrite')).toBe(false);
438
+ // Built from its code point, never pasted: a raw variation selector is
439
+ // invisible in a diff and an editor can drop it, which would leave this
440
+ // control asserting nothing (round 5, F7).
441
+ expect(hasHighSeverityMarker(`${String.fromCharCode(0xfe0f)} major rewrite`)).toBe(false);
442
+ // The four dots and the warning sign still read as labels.
443
+ for (const glyph of ['\u{1F534}', '\u{1F7E0}', '\u{1F7E1}', '\u{1F535}', '⚠']) {
444
+ expect(hasHighSeverityMarker(`${glyph} Critical\n\nbody`), glyph).toBe(true);
445
+ }
446
+ });
447
+ it('the greptile badge is read whatever the quote style, unquoted included (round 4, F10)', () => {
448
+ expect(hasHighSeverityMarker('<img alt="P1" src="x.svg">')).toBe(true);
449
+ expect(hasHighSeverityMarker("<img alt='P1' src='x.svg'>")).toBe(true);
450
+ expect(hasHighSeverityMarker('<img alt=P1 src=x.svg>')).toBe(true);
451
+ expect(hasHighSeverityMarker('<img alt=P0 />')).toBe(true);
452
+ // The levels below the bar stay below it in every spelling.
453
+ expect(hasHighSeverityMarker('<img alt=P2 src=x.svg>')).toBe(false);
454
+ // And an alt value that merely STARTS with P1 is not the badge.
455
+ expect(hasHighSeverityMarker('<img alt=P1x src=x.svg>')).toBe(false);
456
+ });
457
+ it('an em-dash-led line is not a label (round 3, F7)', () => {
458
+ // LABEL_GLYPH was "any glyph that is not ASCII", so a line opening with an
459
+ // em dash read as a CodeRabbit heading. It is now the four severity dots
460
+ // and the warning sign only.
461
+ expect(hasHighSeverityMarker('— major rewrite of the parser')).toBe(false);
462
+ expect(hasHighSeverityMarker('– critical path timing')).toBe(false);
463
+ expect(hasHighSeverityMarker('⚠️ Potential issue\n\nthe error is dropped')).toBe(true);
464
+ });
465
+ it('reads each bot structured label, and no prose that merely uses the words', () => {
466
+ // Positives: the label forms, transcribed from the corpus.
467
+ for (const label of [
468
+ '<a href="#"><img alt="P0" src="x.svg" align="top"></a> **Boom**',
469
+ '<a href="#"><img alt="P1" src="x.svg" align="top"></a> **Boom**',
470
+ '![high](https://www.gstatic.com/codereviewagent/high-priority.svg)',
471
+ '_Potential issue_\n\nthe error is dropped',
472
+ // Single quotes: HTML permits either, so the read must not turn on the
473
+ // quote style (round 3, F11).
474
+ "<a href='#'><img alt='P1' src='x.svg' align='top'></a> **Boom**",
475
+ ]) {
476
+ expect(hasHighSeverityMarker(label), label.slice(0, 40)).toBe(true);
477
+ }
478
+ // Negatives: the levels below the bar, and prose that shares the words.
479
+ for (const benign of [
480
+ '<a href="#"><img alt="P2" src="x.svg" align="top"></a> **Nit**',
481
+ '<a href="#"><img alt="P3" src="x.svg" align="top"></a> **Nit**',
482
+ '![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg)',
483
+ // There is deliberately NO GCA `critical` arm: that asset 404s and GCA's
484
+ // published rubric tops out at `high`, so an arm for it would be
485
+ // inference rather than transcription (round 3, F5).
486
+ '![critical](https://www.gstatic.com/codereviewagent/critical-priority.svg)',
487
+ 'This is a critical section of the parser, but only a nit.',
488
+ '- critical path issues remain',
489
+ '> major rewrite needed here',
490
+ 'the major refactor is out of scope',
491
+ 'a potential issue could arise if the cache is cold',
492
+ ]) {
493
+ expect(hasHighSeverityMarker(benign), benign.slice(0, 40)).toBe(false);
494
+ }
495
+ });
496
+ it('a bot HIGH inline with a NULL commit is unevaluable, never a silent pass (round 2, F8)', () => {
497
+ // Predicate 4's input is missing for that thread, so whether it applies to
498
+ // the head is unknown. R2: an unreadable input is never a pass — it used to
499
+ // fall out of the filter and read as "not on head".
500
+ const e = evaluate('synthetic-high-inline-null-commit.json');
501
+ expect(e.verdict.disposition).toBe('deny');
502
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
503
+ expect(e.verdict.reason).toMatch(/carry no commit/i);
504
+ expect(e.notices.join('\n')).toMatch(/could not derive/);
505
+ expect(evaluate('synthetic-high-inline-null-commit.json', { tier: 'pilot' }).verdict.disposition).toBe('warn');
506
+ });
507
+ it('a failing check beside a null-commit HIGH inline DENIES as predicate 1 at both tiers — a tier never softens a fact (PR round 1)', () => {
508
+ // Before the fold the unreadable-commit arm returned first, so this shape
509
+ // was the UNEVALUABLE class: `warn` under pilot, and `ref: unevaluable`
510
+ // under strict for an audit record whose cause was predicate 1.
511
+ for (const tier of ['strict', 'pilot']) {
512
+ const e = evaluate('synthetic-failing-check-and-null-commit-high.json', { tier });
513
+ expect(e.verdict.disposition, tier).toBe('deny');
514
+ expect(e.verdict.provenance.ref, tier).toBe('checks');
515
+ expect(e.verdict.reason, tier).toMatch(/status checks are failing/);
516
+ expect(e.notices.join('\n'), tier).not.toMatch(/could not derive/);
517
+ }
518
+ });
519
+ it("BEHIND beside a null-commit HIGH inline is still unevaluable — predicate 4's missing input precedes predicate 5 (PR round 1)", () => {
520
+ const strict = evaluate('synthetic-merge-state-behind-and-null-commit-high.json');
521
+ expect(strict.verdict.disposition).toBe('deny');
522
+ expect(strict.verdict.provenance.ref).toBe('unevaluable');
523
+ expect(strict.verdict.reason).toMatch(/carry no commit/i);
524
+ expect(evaluate('synthetic-merge-state-behind-and-null-commit-high.json', { tier: 'pilot' }).verdict
525
+ .disposition).toBe('warn');
526
+ });
527
+ it('no bot review present passes predicates 2-4 as a fact, never a failure', () => {
528
+ const e = evaluate('synthetic-merge-state-clean.json');
529
+ expect(e.verdict.disposition).toBe('allow');
530
+ expect(e.verdict.reason).toMatch(/No bot review present/);
531
+ });
532
+ });
533
+ // ─── Predicate 3: CHANGES_REQUESTED ─────────────────────────────────────────
534
+ describe('merge-ready — predicate 3 (changes requested)', () => {
535
+ it('an un-superseded CHANGES_REQUESTED denies; a later COMMENTED from the SAME reviewer does not clear it', () => {
536
+ // The fixture carries satur8d CHANGES_REQUESTED, then someone-else
537
+ // COMMENTED, then satur8d COMMENTED — GitHub's own semantics say a
538
+ // COMMENTED review is not a decision, so it supersedes nothing (fold F6).
539
+ // A reader that took the latest review of ANY state would report [].
540
+ const e = evaluate('synthetic-changes-requested-standing.json');
541
+ expect(e.verdict.disposition).toBe('deny');
542
+ expect(e.verdict.provenance.ref).toBe('changes-requested');
543
+ expect(e.detail.changesRequestedBy).toEqual(['satur8d']);
544
+ });
545
+ it('a later APPROVED from the SAME reviewer supersedes it, and a DIFFERENT reviewer COMMENTED after does not resurrect it', () => {
546
+ // What this fixture actually proves: satur8d CHANGES_REQUESTED then
547
+ // APPROVED (the supersession), with a coderabbitai COMMENTED afterwards
548
+ // that must not re-open anything. The SAME-reviewer COMMENTED case is the
549
+ // standing fixture's job, one test up (round 2, F12: the old title claimed
550
+ // both).
551
+ const e = evaluate('synthetic-changes-requested-superseded.json');
552
+ expect(e.verdict.disposition).toBe('allow');
553
+ expect(e.detail.changesRequestedBy).toEqual([]);
554
+ });
555
+ });
556
+ // ─── Predicate 5: mergeStateStatus ──────────────────────────────────────────
557
+ describe('merge-ready — predicate 5 (mergeStateStatus)', () => {
558
+ const passes = [
559
+ ['CLEAN', 'synthetic-merge-state-clean.json'],
560
+ ['HAS_HOOKS', 'synthetic-merge-state-has-hooks.json'],
561
+ ['UNSTABLE', 'synthetic-merge-state-unstable.json'],
562
+ ];
563
+ for (const [status, file] of passes) {
564
+ it(`${status} passes`, () => {
565
+ const e = evaluate(file);
566
+ expect(e.verdict.disposition).toBe('allow');
567
+ expect(e.detail.mergeStateStatus).toBe(status);
568
+ });
569
+ }
570
+ const denies = [
571
+ ['BEHIND', 'synthetic-merge-state-behind.json'],
572
+ ['DIRTY', 'synthetic-merge-state-dirty.json'],
573
+ ['BLOCKED', 'synthetic-merge-state-blocked.json'],
574
+ ['DRAFT', 'synthetic-merge-state-draft.json'],
575
+ ];
576
+ for (const [status, file] of denies) {
577
+ it(`${status} denies at predicate 5`, () => {
578
+ const e = evaluate(file);
579
+ expect(e.verdict.disposition).toBe('deny');
580
+ expect(e.verdict.provenance.ref).toBe('merge-state');
581
+ expect(e.verdict.reason).toContain(status);
582
+ });
583
+ }
584
+ it('UNKNOWN is unevaluable — strict denies, pilot warns, neither allows', () => {
585
+ const strict = evaluate('synthetic-merge-state-unknown.json');
586
+ expect(strict.verdict.disposition).toBe('deny');
587
+ expect(strict.verdict.provenance.ref).toBe('unevaluable');
588
+ expect(strict.verdict.reason).toMatch(/mergeability/i);
589
+ const pilot = evaluate('synthetic-merge-state-unknown.json', { tier: 'pilot' });
590
+ expect(pilot.verdict.disposition).toBe('warn');
591
+ });
592
+ it('a mergeStateStatus this gate does not read is unevaluable, never allow', () => {
593
+ const e = evaluate('synthetic-merge-state-unrecognised.json');
594
+ expect(e.verdict.disposition).toBe('deny');
595
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
596
+ expect(e.verdict.reason).toContain('SOMETHING_NEW');
597
+ });
598
+ });
599
+ // ─── Pagination + the unevaluable class ─────────────────────────────────────
600
+ describe('merge-ready — pagination and the unevaluable class', () => {
601
+ it('a clean first page with a dirty SECOND page denies (the capped-read bar)', () => {
602
+ const e = evaluate('synthetic-pagination-second-page-deny.json');
603
+ expect(e.verdict.disposition).toBe('deny');
604
+ expect(e.verdict.provenance.ref).toBe('unresolved-bot-threads');
605
+ expect(e.detail.threads.pagesRead).toBe(2);
606
+ expect(e.detail.threads.complete).toBe(true);
607
+ // The second page is fetched with the first page's cursor, on the
608
+ // number-keyed document.
609
+ expect(e.calls[2]).toContain('threadsAfter=CURSOR-1');
610
+ });
611
+ it('a pagination FAILURE is unevaluable, not clean', () => {
612
+ const strict = evaluate('synthetic-pagination-second-page-fails.json');
613
+ expect(strict.verdict.disposition).toBe('deny');
614
+ expect(strict.verdict.provenance.ref).toBe('unevaluable');
615
+ expect(strict.detail.threads.complete).toBe(false);
616
+ expect(strict.notices.join('\n')).toMatch(/could not derive/);
617
+ const pilot = evaluate('synthetic-pagination-second-page-fails.json', { tier: 'pilot' });
618
+ expect(pilot.verdict.disposition).toBe('warn');
619
+ expect(pilot.verdict.disposition).not.toBe('allow');
620
+ });
621
+ it('a missing or truncated reviews / reviewThreads connection is unevaluable, never an empty complete list (PR round 1)', () => {
622
+ // Greptile's round-1 P1: `?? []` and a defaulted `hasNext: false` turned an
623
+ // answer WITHOUT these connections into "no reviews, no threads, complete",
624
+ // and predicates 2–4 passed on a list the read never received. The checks
625
+ // rollup already failed closed on the same shape; these now match it.
626
+ for (const [file, pattern] of [
627
+ ['synthetic-reviews-connection-missing.json', /reviews connection was missing/],
628
+ ['synthetic-threads-connection-missing.json', /review threads connection was missing/],
629
+ ['synthetic-threads-pageinfo-missing.json', /review threads connection carried no pageInfo/],
630
+ ]) {
631
+ const strict = evaluate(file);
632
+ expect(strict.verdict.disposition, file).toBe('deny');
633
+ expect(strict.verdict.provenance.ref, file).toBe('unevaluable');
634
+ expect(strict.verdict.reason, file).toMatch(pattern);
635
+ expect(strict.detail.threads.complete, file).toBe(false);
636
+ const pilot = evaluate(file, { tier: 'pilot' });
637
+ expect(pilot.verdict.disposition, file).toBe('warn');
638
+ expect(pilot.verdict.disposition, file).not.toBe('allow');
639
+ }
640
+ });
641
+ it('a head sha that moved between pages is unevaluable, named', () => {
642
+ const e = evaluate('synthetic-head-moved.json');
643
+ expect(e.verdict.disposition).toBe('deny');
644
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
645
+ expect(e.verdict.reason).toMatch(/head sha moved/i);
646
+ });
647
+ it('a rate-limited (non-zero) gh exit is unevaluable and names what gh said', () => {
648
+ const e = evaluate('synthetic-rate-limit-exit.json');
649
+ expect(e.verdict.disposition).toBe('deny');
650
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
651
+ expect(e.verdict.reason).toMatch(/rate limit/i);
652
+ });
653
+ it('a GraphQL error in a 200 body is a failed read, not an empty one', () => {
654
+ const e = evaluate('synthetic-graphql-error-body.json');
655
+ expect(e.verdict.disposition).toBe('deny');
656
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
657
+ expect(e.verdict.reason).toMatch(/rate limit exceeded/i);
658
+ });
659
+ it('gh absent or unauthenticated is unevaluable at BOTH tiers, never allow', () => {
660
+ const strict = evaluate('synthetic-gh-absent.json');
661
+ expect(strict.verdict.disposition).toBe('deny');
662
+ expect(strict.verdict.reason).toMatch(/gh is unavailable/i);
663
+ const pilot = evaluate('synthetic-gh-absent.json', { tier: 'pilot' });
664
+ expect(pilot.verdict.disposition).toBe('warn');
665
+ expect(pilot.notices.join('\n')).toMatch(/could not derive/);
666
+ });
667
+ it('every unevaluable path is named on stderr under BOTH tiers — only the exit differs', () => {
668
+ for (const file of [
669
+ 'synthetic-gh-absent.json',
670
+ 'synthetic-rate-limit-exit.json',
671
+ 'synthetic-pagination-second-page-fails.json',
672
+ 'synthetic-head-moved.json',
673
+ 'synthetic-merge-state-unknown.json',
674
+ ]) {
675
+ for (const tier of ['strict', 'pilot']) {
676
+ const e = evaluate(file, { tier });
677
+ expect(e.notices.some((n) => n.includes('could not derive'))).toBe(true);
678
+ expect(e.verdict.disposition).toBe(tier === 'pilot' ? 'warn' : 'deny');
679
+ }
680
+ }
681
+ });
682
+ });
683
+ // ─── Branch resolution ──────────────────────────────────────────────────────
684
+ describe('merge-ready — branch resolution', () => {
685
+ it('pr: null + branch resolves through the branch-keyed document', () => {
686
+ const e = evaluate('synthetic-branch-resolution.json', {
687
+ payload: { repo: 'mmnto-ai/totem', pr: null, branch: 'feat/example' },
688
+ });
689
+ expect(e.verdict.disposition).toBe('allow');
690
+ expect(e.detail.pr).toBe(4242);
691
+ const graphql = e.calls[1];
692
+ expect(graphql[3]).toBe(`query=${MERGE_READY_BRANCH_QUERY}`);
693
+ expect(graphql).toContain('branch=feat/example');
694
+ });
695
+ });
696
+ // ─── The audited override ───────────────────────────────────────────────────
697
+ describe('merge-ready — the audited override', () => {
698
+ it('allows a denying PR with an audit line naming repo, pr, head sha and the predicate', () => {
699
+ const e = evaluate('synthetic-merge-state-behind.json', {
700
+ env: { [MERGE_READY_OVERRIDE_ENV]: '1' },
701
+ });
702
+ expect(e.verdict.disposition).toBe('allow');
703
+ expect(e.verdict.provenance.ref).toBe('override');
704
+ expect(e.detail.override).toBe(true);
705
+ const audit = e.notices.find((n) => n.includes('OVERRIDE'));
706
+ expect(audit).toBeDefined();
707
+ expect(audit).toContain('mmnto-ai/totem#4242');
708
+ // The head sha, SHORTENED to 12: the fixture sha is 40 identical chars, so
709
+ // a bare `toContain` of the prefix could not tell 12 from 40.
710
+ expect(audit).toContain('a'.repeat(12));
711
+ expect(audit).not.toContain('a'.repeat(13));
712
+ expect(audit).toContain('merge-state');
713
+ });
714
+ it('allows even when the READ failed, and the audit line says so', () => {
715
+ const e = evaluate('synthetic-gh-absent.json', {
716
+ env: { [MERGE_READY_OVERRIDE_ENV]: '1' },
717
+ });
718
+ expect(e.verdict.disposition).toBe('allow');
719
+ const audit = e.notices.find((n) => n.includes('OVERRIDE'));
720
+ expect(audit).toMatch(/would have denied: the read failed/);
721
+ });
722
+ it('is not triggered by any value other than exactly "1"', () => {
723
+ const e = evaluate('synthetic-merge-state-behind.json', {
724
+ env: { [MERGE_READY_OVERRIDE_ENV]: 'true' },
725
+ });
726
+ expect(e.verdict.disposition).toBe('deny');
727
+ });
728
+ });
729
+ // ─── Payload contract ───────────────────────────────────────────────────────
730
+ describe('merge-ready — payload validation (never a default allow)', () => {
731
+ it('accepts the wrapper projection shapes', () => {
732
+ expect(parseMergeReadyPayload({ repo: 'mmnto-ai/totem', pr: 2800 })).toEqual({
733
+ repo: 'mmnto-ai/totem',
734
+ pr: 2800,
735
+ });
736
+ expect(parseMergeReadyPayload({ repo: 'mmnto-ai/totem', pr: null, branch: 'feat/x' })).toMatchObject({ pr: null, branch: 'feat/x' });
737
+ });
738
+ for (const [why, payload] of [
739
+ ['a non-object', 42],
740
+ ['no repo', { pr: 1 }],
741
+ ['an empty repo', { repo: ' ', pr: 1 }],
742
+ ['a repo that is not owner/name', { repo: 'totem', pr: 1 }],
743
+ ['neither pr nor branch', { repo: 'mmnto-ai/totem', pr: null }],
744
+ ['a non-integer pr', { repo: 'mmnto-ai/totem', pr: 1.5 }],
745
+ ['a zero pr', { repo: 'mmnto-ai/totem', pr: 0 }],
746
+ ['a short headSha', { repo: 'mmnto-ai/totem', pr: 1, headSha: 'abc' }],
747
+ ]) {
748
+ it(`throws GATE_INVALID on ${why}`, () => {
749
+ expect(() => parseMergeReadyPayload(payload)).toThrow(TotemError);
750
+ expect(() => parseMergeReadyPayload(payload)).toThrow(/merge-ready payload is invalid/);
751
+ });
752
+ }
753
+ it('an unexpanded shell variable is unevaluable BEFORE any gh call (fold F13)', () => {
754
+ // The runner throws if touched: the arm must return without a read, since
755
+ // there is no PR to query and guessing would judge the wrong one.
756
+ const runner = () => {
757
+ throw new Error('the evaluator must not call gh for an unresolved target');
758
+ };
759
+ const payload = { repo: 'mmnto-ai/totem', pr: null, unresolvedTarget: '$PR' };
760
+ const strict = evaluateMergeReady(payload, { runner, env: {} });
761
+ expect(strict.verdict.disposition).toBe('deny');
762
+ expect(strict.verdict.provenance.ref).toBe('unevaluable');
763
+ expect(strict.verdict.reason).toMatch(/shell variable not expanded/i);
764
+ expect(strict.verdict.reason).toContain('$PR');
765
+ expect(strict.notices.join('\n')).toMatch(/could not derive/);
766
+ const pilot = evaluateMergeReady(payload, { runner, tier: 'pilot', env: {} });
767
+ expect(pilot.verdict.disposition).toBe('warn');
768
+ });
769
+ it('names a payload head sha that is not the PR head, without changing the disposition', () => {
770
+ const e = evaluate('synthetic-merge-state-clean.json', {
771
+ payload: {
772
+ repo: 'mmnto-ai/totem',
773
+ pr: 4242,
774
+ headSha: 'dddddddddddddddddddddddddddddddddddddddd',
775
+ },
776
+ });
777
+ expect(e.verdict.disposition).toBe('allow');
778
+ expect(e.notices.some((n) => n.includes('is not the PR head'))).toBe(true);
779
+ });
780
+ });
781
+ // ─── The registry evaluator's stderr surface ────────────────────────────────
782
+ describe('mergeReadyEvaluator — the registry entry', () => {
783
+ it('routes every notice to the injected stderr sink and returns only the verdict', () => {
784
+ const fixture = loadFixture('synthetic-zero-checks.json');
785
+ const { runner } = runnerFor(fixture);
786
+ const lines = [];
787
+ const verdict = mergeReadyEvaluator({ repo: 'mmnto-ai/totem', pr: 4242 }, '.totem', {
788
+ ghRunner: runner,
789
+ env: {},
790
+ writeStderr: (line) => lines.push(line),
791
+ });
792
+ expect(verdict.disposition).toBe('allow');
793
+ expect(lines).toHaveLength(1);
794
+ expect(lines[0]).toMatch(/ZERO status checks/);
795
+ expect(lines[0].endsWith('\n')).toBe(true);
796
+ });
797
+ it('honours the tier passed through the gate context', () => {
798
+ const fixture = loadFixture('synthetic-merge-state-unknown.json');
799
+ const strict = mergeReadyEvaluator({ repo: 'mmnto-ai/totem', pr: 4242 }, '.totem', {
800
+ ghRunner: runnerFor(fixture).runner,
801
+ env: {},
802
+ writeStderr: () => { },
803
+ });
804
+ expect(strict.disposition).toBe('deny');
805
+ const pilot = mergeReadyEvaluator({ repo: 'mmnto-ai/totem', pr: 4242 }, '.totem', {
806
+ ghRunner: runnerFor(fixture).runner,
807
+ tier: 'pilot',
808
+ env: {},
809
+ writeStderr: () => { },
810
+ });
811
+ expect(pilot.disposition).toBe('warn');
812
+ });
813
+ });
814
+ //# sourceMappingURL=merge-ready.test.js.map