@mmnto/totem 2.5.0 → 2.7.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.
@@ -2,18 +2,25 @@
2
2
  * merge-ready (mmnto-ai/totem#2800) — the gate's invariants, driven by the
3
3
  * checked-in fixtures under `gate-fixtures/merge-ready/`.
4
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 —
5
+ * FIVE of those fixtures are REAL `gh api graphql` captures taken with the
6
+ * exported {@link MERGE_READY_QUERY} (R4): four PR captures —
7
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.
8
+ * mmnto-ai/totem-strategy#1251, mmnto-ai/totem#2827 (the comment that
9
+ * re-pointed to the head) and mmnto-ai/totem#2871 (a HIGH resolved through the
10
+ * disposition path while its anchor survives on the head — the discharge
11
+ * specimen, mmnto-ai/totem#2861) — plus the benign corpus, every bot inline
12
+ * thread on mmnto-ai/totem#2820-2839, which measures the severity read's
13
+ * ADR-109 false-positive budget. The other 72 are synthetic and labelled
14
+ * `synthetic-` in their names — one per invariant the captures cannot
15
+ * exercise (all four PRs are merged, so GitHub answers `mergeStateStatus:
16
+ * UNKNOWN` for each and no capture can carry a BEHIND / DIRTY / BLOCKED head,
17
+ * or a bare-resolved HIGH thread); the five PR-round-1 rows
18
+ * (mmnto-ai/totem#2844) cover the strict connection reads and the predicate
19
+ * order ahead of the unreadable-commit arm; the ten mmnto-ai/totem#2861 rows
20
+ * cover the discharge read — its two evidence arms, the bare-resolve negative
21
+ * control in the field shape, the required resolve, the per-thread split, the
22
+ * second comments page, the incomplete window with and without evidence, the
23
+ * deleted-account reply and the strict PR comments connection.
17
24
  * The README beside them lists every file with its sha256 and instant, and a
18
25
  * test recomputes those receipts from the files on disk.
19
26
  *
@@ -25,7 +32,7 @@ import * as path from 'node:path';
25
32
  import { fileURLToPath } from 'node:url';
26
33
  import { describe, expect, it } from 'vitest';
27
34
  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';
35
+ import { dispositionedRootIds, evaluateMergeReady, hasHighSeverityMarker, MERGE_READY_BRANCH_QUERY, MERGE_READY_OVERRIDE_ENV, MERGE_READY_QUERY, MERGE_READY_SOURCE, mergeReadyEvaluator, parseMergeReadyPayload, } from './merge-ready.js';
29
36
  const FIXTURE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'gate-fixtures', 'merge-ready');
30
37
  function loadFixture(name) {
31
38
  return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf-8'));
@@ -76,6 +83,7 @@ describe('merge-ready — the fixture README matches the fixtures', () => {
76
83
  'benign-corpus-bot-inlines.json',
77
84
  'liquid-city-363.json',
78
85
  'totem-2827.json',
86
+ 'totem-2871.json',
79
87
  'totem-strategy-1251.json',
80
88
  ];
81
89
  it('every sha256 in the README is the sha256 of the file it names', () => {
@@ -111,8 +119,8 @@ describe('merge-ready — the fixture README matches the fixtures', () => {
111
119
  const synthetic = files.filter((f) => f.startsWith('synthetic-'));
112
120
  const captures = files.filter((f) => !f.startsWith('synthetic-'));
113
121
  expect(captures.sort()).toEqual(CAPTURES);
114
- expect(synthetic.length).toBe(44);
115
- expect(files.length).toBe(48);
122
+ expect(synthetic.length).toBe(72);
123
+ expect(files.length).toBe(77);
116
124
  });
117
125
  it('the README query sha is the sha of the exported query (round 4, F7)', () => {
118
126
  // A receipt for the QUERY, re-derived like the file receipts: change the
@@ -134,7 +142,13 @@ describe('merge-ready — the checked-in captures', () => {
134
142
  expect(e.verdict.disposition).toBe('deny');
135
143
  expect(e.verdict.provenance.ref).toBe('unresolved-bot-threads');
136
144
  expect(e.verdict.provenance.source).toBe(MERGE_READY_SOURCE);
137
- expect(e.detail.checks).toEqual({ total: 6, success: 6, pending: 0, failing: 0 });
145
+ expect(e.detail.checks).toEqual({
146
+ total: 6,
147
+ success: 6,
148
+ pending: 0,
149
+ failing: 0,
150
+ superseded: 0,
151
+ });
138
152
  expect(e.detail.threads.unresolvedBot).toBe(1);
139
153
  // The root comment is GCA's, spelled WITHOUT the `[bot]` suffix on the
140
154
  // GraphQL surface — the trap `bot-identity.ts` exists to hold.
@@ -171,6 +185,54 @@ describe('merge-ready — the checked-in captures', () => {
171
185
  expect(e.detail.highInline).toBe(1);
172
186
  expect(e.verdict.disposition).toBe('deny');
173
187
  });
188
+ it('mmnto-ai/totem#2871 (capture): a CodeRabbit Major resolved THROUGH THE DISPOSITION PATH while its anchor survives on the head is discharged (mmnto-ai/totem#2861)', () => {
189
+ // The real specimen of predicate 4's own territory: the finding was
190
+ // declined with reason in the round disposition (a PR-level comment that
191
+ // post-dates the thread's root), the thread resolved by
192
+ // `totem resolve-threads --apply`, and the anchored line never changed —
193
+ // so `comment.commit.oid` IS the head and, before the cure, this read
194
+ // `deny · high-severity-inline · highInline 1` (calibration row
195
+ // mmnto-ai/totem#2871 on the issue). Read straight off the capture first,
196
+ // so the shape stays a statement about GitHub's data.
197
+ const fixture = loadFixture('totem-2871.json');
198
+ const body = fixture.pages[0].body;
199
+ const pr = body.data.repository.pullRequest;
200
+ const onHead = pr.reviewThreads.nodes.filter((t) => t.comments.nodes[0].commit?.oid === pr.headRefOid &&
201
+ hasHighSeverityMarker(t.comments.nodes[0].body));
202
+ expect(onHead).toHaveLength(1);
203
+ const thread = onHead[0];
204
+ expect(thread.isResolved).toBe(true);
205
+ expect(thread.comments.nodes[0].author?.login).toBe('coderabbitai');
206
+ const rootAt = Date.parse(thread.comments.nodes[0].createdAt);
207
+ const humanAfterRoot = pr.comments.nodes.filter((c) => c.author?.__typename !== 'Bot' && Date.parse(c.createdAt) > rootAt);
208
+ expect(humanAfterRoot.length).toBeGreaterThan(0);
209
+ // A human comment after the root NAMES the thread on a `disposition:`
210
+ // line — the calibration replay line posted on the PR under the 2026-09-16
211
+ // ruling — asserted through the SHIPPED predicate, never a copy (r2-f8).
212
+ const rootId = thread.comments.nodes[0].databaseId;
213
+ expect(rootId).toBe(4000747291);
214
+ expect(humanAfterRoot.some((c) => dispositionedRootIds(c.body).includes(rootId))).toBe(true);
215
+ const e = evaluate('totem-2871.json', { tier: 'pilot' });
216
+ // 17 runs under 16 names on the 2026-09-18 re-capture: one name ran twice
217
+ // (both SUCCESS) and is judged by its later run (mmnto-ai/totem#2879) — a
218
+ // live specimen of the collapse inside the discharge specimen.
219
+ expect(e.detail.checks).toEqual({
220
+ total: 16,
221
+ success: 16,
222
+ pending: 0,
223
+ failing: 0,
224
+ superseded: 1,
225
+ });
226
+ expect(e.detail.threads.unresolvedBot).toBe(0);
227
+ expect(e.detail.highInline).toBe(0);
228
+ expect(e.detail.dischargedHigh).toBe(1);
229
+ expect(e.detail.dischargedBy).toEqual({ inThreadReply: 0, prLevelDisposition: 1 });
230
+ // Merged, so GitHub answers UNKNOWN: the read lands on the arm that is
231
+ // reachable ONLY when predicates 1–4 have passed — the replay's witness.
232
+ expect(e.verdict.provenance.ref).toBe('unevaluable');
233
+ expect(e.verdict.reason).toMatch(/mergeStateStatus: UNKNOWN/);
234
+ expect(e.notices.some((n) => n.includes('1 HIGH/Major bot inline(s) on the head commit discharged'))).toBe(true);
235
+ });
174
236
  it('sends the exported query, as argv, with no shell string anywhere', () => {
175
237
  const e = evaluate('liquid-city-363.json');
176
238
  expect(e.calls[0]).toEqual(['--version']);
@@ -191,7 +253,13 @@ describe('merge-ready — predicate 1 (checks)', () => {
191
253
  expect(e.verdict.disposition).toBe('deny');
192
254
  expect(e.verdict.provenance.ref).toBe('checks');
193
255
  expect(e.verdict.reason).toContain('Totem Lint');
194
- expect(e.detail.checks).toEqual({ total: 2, success: 1, pending: 0, failing: 1 });
256
+ expect(e.detail.checks).toEqual({
257
+ total: 2,
258
+ success: 1,
259
+ pending: 0,
260
+ failing: 1,
261
+ superseded: 0,
262
+ });
195
263
  });
196
264
  it('a still-running check denies and names it', () => {
197
265
  const e = evaluate('synthetic-pending-check.json');
@@ -203,11 +271,275 @@ describe('merge-ready — predicate 1 (checks)', () => {
203
271
  it('R5 — zero checks passes predicate 1 as a FACT, with the count and ONE stderr line', () => {
204
272
  const e = evaluate('synthetic-zero-checks.json');
205
273
  expect(e.verdict.disposition).toBe('allow');
206
- expect(e.detail.checks).toEqual({ total: 0, success: 0, pending: 0, failing: 0 });
274
+ expect(e.detail.checks).toEqual({
275
+ total: 0,
276
+ success: 0,
277
+ pending: 0,
278
+ failing: 0,
279
+ superseded: 0,
280
+ });
207
281
  const zeroLines = e.notices.filter((n) => n.includes('ZERO status checks'));
208
282
  expect(zeroLines).toHaveLength(1);
209
283
  expect(zeroLines[0]).toMatch(/branch protection/i);
210
284
  });
285
+ // ─── Same-named runs are judged by the LATEST run (mmnto-ai/totem#2879) ──
286
+ //
287
+ // The rollup lists EVERY check run on the head, a concurrency group's
288
+ // cancelled duplicate beside the run that superseded it, and its order is
289
+ // not chronological (mmnto-ai/totem#2877's head listed the later D1 run
290
+ // first). The latest run is the greatest `databaseId`. Two fixtures list
291
+ // the greater id in opposite positions so that neither "first listed" nor
292
+ // "last listed" survives as a mutant.
293
+ it('2879 — a concurrency-cancelled run beside the LATER success of the same name passes predicate 1, listed order notwithstanding', () => {
294
+ // The later (success) run is listed FIRST; a last-listed-wins read would
295
+ // judge the cancelled one and deny.
296
+ const e = evaluate('synthetic-check-superseded-cancelled.json');
297
+ expect(e.verdict.disposition).toBe('allow');
298
+ expect(e.detail.checks).toEqual({
299
+ total: 2,
300
+ success: 2,
301
+ pending: 0,
302
+ failing: 0,
303
+ superseded: 1,
304
+ });
305
+ const lines = e.notices.filter((n) => n.includes('times on the head commit'));
306
+ expect(lines).toHaveLength(1);
307
+ expect(lines[0]).toContain('check "Auto-close required check (D1)" from "github-actions/CI" ran 2 times on the head commit — judged by its latest run 5003 (success)');
308
+ expect(lines[0]).toContain('1 superseded run(s) not counted (mmnto-ai/totem#2879)');
309
+ });
310
+ it('2879 — a success and then a LATER cancelled run of the same name DENIES naming the check: the earlier success does not stand in', () => {
311
+ // The greater id (cancelled) is listed SECOND; a first-listed-wins read
312
+ // would judge the success and allow.
313
+ const e = evaluate('synthetic-check-cancelled-after-success.json');
314
+ expect(e.verdict.disposition).toBe('deny');
315
+ expect(e.verdict.provenance.ref).toBe('checks');
316
+ expect(e.verdict.reason).toContain('1 of 2 status checks are failing (Auto-close required check (D1))');
317
+ expect(e.detail.checks).toEqual({
318
+ total: 2,
319
+ success: 1,
320
+ pending: 0,
321
+ failing: 1,
322
+ superseded: 1,
323
+ });
324
+ });
325
+ it('2879 — ONE cancelled run with no later run of its name still denies: the negative that must hold', () => {
326
+ const e = evaluate('synthetic-check-single-cancelled.json');
327
+ expect(e.verdict.disposition).toBe('deny');
328
+ expect(e.verdict.provenance.ref).toBe('checks');
329
+ expect(e.verdict.reason).toContain('Auto-close required check (D1)');
330
+ expect(e.detail.checks).toEqual({
331
+ total: 2,
332
+ success: 1,
333
+ pending: 0,
334
+ failing: 1,
335
+ superseded: 0,
336
+ });
337
+ expect(e.notices.join('\n')).not.toMatch(/times on the head commit/);
338
+ });
339
+ it('2879 — two runs of one name where one carries no readable databaseId is UNREADABLE at both tiers, never the first or the last one listed', () => {
340
+ for (const tier of ['strict', 'pilot']) {
341
+ const e = evaluate('synthetic-check-duplicate-id-missing.json', { tier });
342
+ expect(e.verdict.disposition, tier).toBe(tier === 'pilot' ? 'warn' : 'deny');
343
+ expect(e.verdict.provenance.ref, tier).toBe('unevaluable');
344
+ expect(e.verdict.reason, tier).toContain('no readable databaseId');
345
+ expect(e.verdict.reason, tier).toContain('Auto-close required check (D1)');
346
+ expect(e.detail.checks, tier).toEqual({
347
+ total: 0,
348
+ success: 0,
349
+ pending: 0,
350
+ failing: 0,
351
+ superseded: 0,
352
+ });
353
+ }
354
+ });
355
+ it('2879 — the query selects databaseId on CheckRun, so the judgment reads what gh answers', () => {
356
+ expect(MERGE_READY_QUERY).toContain('... on CheckRun { name status conclusion databaseId checkSuite { databaseId app { slug } workflowRun { workflow { databaseId name } } } }');
357
+ });
358
+ // ─── Bot round 1 (Greptile P1, P2): the producer is part of the key, and
359
+ // an id must be a SAFE integer ────────────────────────────────────────────
360
+ it('2879 — two INDEPENDENT checks that share a name never collapse: a later success from another workflow does not hide a failure (bot round 1, Greptile P1)', () => {
361
+ const e = evaluate('synthetic-check-same-name-two-producers.json');
362
+ expect(e.verdict.disposition).toBe('deny');
363
+ expect(e.verdict.provenance.ref).toBe('checks');
364
+ expect(e.verdict.reason).toContain('1 of 3 status checks are failing (test)');
365
+ expect(e.detail.checks).toEqual({
366
+ total: 3,
367
+ success: 2,
368
+ pending: 0,
369
+ failing: 1,
370
+ superseded: 0,
371
+ });
372
+ expect(e.notices.join('\n')).not.toMatch(/times on the head commit/);
373
+ });
374
+ it('2879 — two runs of one name where one carries no readable producer are UNREADABLE at both tiers: reruns cannot be told from independent checks (bot round 1, Greptile P1)', () => {
375
+ for (const tier of ['strict', 'pilot']) {
376
+ const e = evaluate('synthetic-check-duplicate-producer-missing.json', { tier });
377
+ expect(e.verdict.disposition, tier).toBe(tier === 'pilot' ? 'warn' : 'deny');
378
+ expect(e.verdict.provenance.ref, tier).toBe('unevaluable');
379
+ expect(e.verdict.reason, tier).toContain('no readable producer');
380
+ expect(e.verdict.reason, tier).toContain('Auto-close required check (D1)');
381
+ }
382
+ });
383
+ it('2879 — an id beyond the safe-integer range does not read: a rerun group carrying one is UNREADABLE, never ordered on a rounded number (bot round 1, Greptile P2)', () => {
384
+ for (const tier of ['strict', 'pilot']) {
385
+ const e = evaluate('synthetic-check-duplicate-id-unsafe.json', { tier });
386
+ expect(e.verdict.disposition, tier).toBe(tier === 'pilot' ? 'warn' : 'deny');
387
+ expect(e.verdict.provenance.ref, tier).toBe('unevaluable');
388
+ expect(e.verdict.reason, tier).toContain('no readable databaseId');
389
+ expect(e.verdict.reason, tier).toContain('from "github-actions/Auto-close guard"');
390
+ }
391
+ });
392
+ // ─── The re-armed leg's F1 and F6: the producer is the workflow's ID, and
393
+ // two same-named runs in one suite fail closed ─────────────────────────────
394
+ it('2879 — two workflow FILES sharing a display name are two producers: their same-named jobs are both judged and the failure denies (re-armed leg F1)', () => {
395
+ const e = evaluate('synthetic-check-same-name-two-workflow-files.json');
396
+ expect(e.verdict.disposition).toBe('deny');
397
+ expect(e.verdict.provenance.ref).toBe('checks');
398
+ expect(e.verdict.reason).toContain('1 of 3 status checks are failing (build)');
399
+ expect(e.detail.checks).toEqual({
400
+ total: 3,
401
+ success: 2,
402
+ pending: 0,
403
+ failing: 1,
404
+ superseded: 0,
405
+ });
406
+ expect(e.notices.join('\n')).not.toMatch(/times on the head commit/);
407
+ });
408
+ it('2879 — two same-named jobs of ONE workflow run share a check suite: independent checks the key cannot tell apart, UNREADABLE at both tiers (re-armed leg F1)', () => {
409
+ for (const tier of ['strict', 'pilot']) {
410
+ const e = evaluate('synthetic-check-two-jobs-one-suite.json', { tier });
411
+ expect(e.verdict.disposition, tier).toBe(tier === 'pilot' ? 'warn' : 'deny');
412
+ expect(e.verdict.provenance.ref, tier).toBe('unevaluable');
413
+ expect(e.verdict.reason, tier).toContain('sit in one check suite');
414
+ expect(e.verdict.reason, tier).toContain('check "test" from "github-actions/CI"');
415
+ }
416
+ });
417
+ it('2879 — a rerun group where one run has no readable check-suite id is UNREADABLE at both tiers (fourth leg F2)', () => {
418
+ for (const tier of ['strict', 'pilot']) {
419
+ const e = evaluate('synthetic-check-rerun-suite-id-missing.json', { tier });
420
+ expect(e.verdict.disposition, tier).toBe(tier === 'pilot' ? 'warn' : 'deny');
421
+ expect(e.verdict.provenance.ref, tier).toBe('unevaluable');
422
+ expect(e.verdict.reason, tier).toContain('no readable check-suite id');
423
+ expect(e.verdict.reason, tier).toContain('check "Auto-close required check (D1)" from "github-actions/Auto-close guard"');
424
+ }
425
+ });
426
+ it('2879 — an Actions run with no readable workflow id in a same-named group is a producer that did not read: UNREADABLE at both tiers, never one producer for every workflow (fourth leg F2, third leg F5)', () => {
427
+ for (const tier of ['strict', 'pilot']) {
428
+ const e = evaluate('synthetic-check-actions-workflow-id-missing.json', { tier });
429
+ expect(e.verdict.disposition, tier).toBe(tier === 'pilot' ? 'warn' : 'deny');
430
+ expect(e.verdict.provenance.ref, tier).toBe('unevaluable');
431
+ expect(e.verdict.reason, tier).toContain('no readable producer');
432
+ expect(e.verdict.reason, tier).toContain('the workflow id of an Actions run');
433
+ expect(e.verdict.reason, tier).toContain('Auto-close required check (D1)');
434
+ }
435
+ });
436
+ it('2879 — a non-Actions app that posts two same-named runs in its one suite is UNREADABLE at both tiers, and the reason names the suite, not a workflow run (fourth leg F4)', () => {
437
+ for (const tier of ['strict', 'pilot']) {
438
+ const e = evaluate('synthetic-check-app-two-runs-one-suite.json', { tier });
439
+ expect(e.verdict.disposition, tier).toBe(tier === 'pilot' ? 'warn' : 'deny');
440
+ expect(e.verdict.provenance.ref, tier).toBe('unevaluable');
441
+ expect(e.verdict.reason, tier).toContain('check "lint" from "some-ci-app"');
442
+ expect(e.verdict.reason, tier).toContain('two runs of one app');
443
+ }
444
+ });
445
+ it('2879 — two same-named checks from different producers where one has no id are both judged: the id orders reruns and nothing else (re-armed leg F6)', () => {
446
+ const e = evaluate('synthetic-check-two-producers-one-null-id.json');
447
+ expect(e.verdict.disposition).toBe('allow');
448
+ expect(e.detail.checks).toEqual({
449
+ total: 3,
450
+ success: 3,
451
+ pending: 0,
452
+ failing: 0,
453
+ superseded: 0,
454
+ });
455
+ expect(e.notices.join('\n')).not.toMatch(/times on the head commit|could not derive/);
456
+ });
457
+ // ─── The fold of the leg's F1–F4, F7: arithmetic and tolerances, not only
458
+ // the two-run happy path ─────────────────────────────────────────────────
459
+ it('2879 — superseded counts RUNS, one disclosure line per name, and an earlier FAILED run is superseded like a cancelled one (leg F1, F2, F7)', () => {
460
+ // Three D1 runs (FAILURE, CANCELLED, SUCCESS) beside two Totem Lint runs:
461
+ // a mutant counting NAMES would say 2; the lines carry their whole names.
462
+ const e = evaluate('synthetic-check-three-runs-two-names.json');
463
+ expect(e.verdict.disposition).toBe('allow');
464
+ expect(e.detail.checks).toEqual({
465
+ total: 3,
466
+ success: 3,
467
+ pending: 0,
468
+ failing: 0,
469
+ superseded: 3,
470
+ });
471
+ const lines = e.notices.filter((n) => n.includes('times on the head commit'));
472
+ expect(lines).toHaveLength(2);
473
+ expect(lines[0]).toContain('check "Auto-close required check (D1)" from "github-actions/CI" ran 3 times on the head commit — judged by its latest run 5004 (success)');
474
+ expect(lines[0]).toContain('2 superseded run(s) not counted');
475
+ expect(lines[1]).toContain('check "Totem Lint" from "github-actions/CI" ran 2 times on the head commit — judged by its latest run 5006 (success)');
476
+ expect(lines[1]).toContain('1 superseded run(s) not counted');
477
+ for (const line of lines)
478
+ expect(line).not.toContain('…');
479
+ });
480
+ it('2879 — a check that ran ONCE with no readable databaseId is judged on its conclusion, in BOTH directions: the id orders same-named runs and nothing else (leg F3, r2-F7)', () => {
481
+ // Two lone null-id runs: one SUCCESS, one FAILURE. The over-strict mutant
482
+ // (any unreadable id is unevaluable) would say "could not derive"; the
483
+ // lenient mutant (a lone null-id run reads as success) would allow.
484
+ const e = evaluate('synthetic-check-single-null-id.json');
485
+ expect(e.verdict.disposition).toBe('deny');
486
+ expect(e.verdict.provenance.ref).toBe('checks');
487
+ expect(e.verdict.reason).toContain('1 of 3 status checks are failing (Totem Doctor (--strict))');
488
+ expect(e.detail.checks).toEqual({
489
+ total: 3,
490
+ success: 2,
491
+ pending: 0,
492
+ failing: 1,
493
+ superseded: 0,
494
+ });
495
+ expect(e.notices.join('\n')).not.toMatch(/times on the head commit|could not derive/);
496
+ });
497
+ it('2879 — a check name longer than the evidence bound is disclosed WHOLE: the notice is sanitised, never sliced (r2-F1)', () => {
498
+ const e = evaluate('synthetic-check-long-name-superseded.json');
499
+ expect(e.verdict.disposition).toBe('allow');
500
+ expect(e.detail.checks).toEqual({
501
+ total: 2,
502
+ success: 2,
503
+ pending: 0,
504
+ failing: 0,
505
+ superseded: 1,
506
+ });
507
+ const lines = e.notices.filter((n) => n.includes('times on the head commit'));
508
+ expect(lines).toHaveLength(1);
509
+ const longName = 'spine-adopt harnesses on ubuntu-latest (specimens, seed20, matrix shard 7 of 12, reusable workflow harness/run-adopt.yml @ main, with the full fixture corpus and the extended timeout budget for cold-store starts)';
510
+ expect(longName.length).toBeGreaterThan(160);
511
+ expect(lines[0]).toContain(`check ${JSON.stringify(longName)} from "github-actions/CI" ran 2 times on the head commit`);
512
+ expect(lines[0]).not.toContain('…');
513
+ });
514
+ it("2879 — two runs whose name did not read are two checks, never one that ran twice (r2-F4; the first leg's F10)", () => {
515
+ const e = evaluate('synthetic-check-unnamed-runs.json');
516
+ expect(e.verdict.disposition).toBe('allow');
517
+ expect(e.detail.checks).toEqual({
518
+ total: 3,
519
+ success: 3,
520
+ pending: 0,
521
+ failing: 0,
522
+ superseded: 0,
523
+ });
524
+ expect(e.notices.join('\n')).not.toMatch(/times on the head commit|could not derive/);
525
+ });
526
+ it('2879 — same-named runs on different pages of the checks connection are judged together, after the last page (leg F4)', () => {
527
+ const e = evaluate('synthetic-check-duplicate-across-pages.json');
528
+ expect(e.verdict.disposition).toBe('allow');
529
+ expect(e.detail.checks).toEqual({
530
+ total: 2,
531
+ success: 2,
532
+ pending: 0,
533
+ failing: 0,
534
+ superseded: 1,
535
+ });
536
+ const graphql = e.calls.filter((c) => c[0] === 'api');
537
+ expect(graphql).toHaveLength(2);
538
+ expect(graphql[1]).toContain('checksAfter=checks-page-1');
539
+ const lines = e.notices.filter((n) => n.includes('times on the head commit'));
540
+ expect(lines).toHaveLength(1);
541
+ expect(lines[0]).toContain('judged by its latest run 5003 (success)');
542
+ });
211
543
  // ─── The rollup must BELONG to the head commit (fold F7) ────────────────
212
544
  //
213
545
  // Predicate 1 reads `commits(last: 1)`. Every way that node can fail to be
@@ -314,15 +646,209 @@ describe('merge-ready — predicates 2 and 4 (bot threads)', () => {
314
646
  expect(e.verdict.disposition).toBe('allow');
315
647
  expect(e.detail.highInline).toBe(0);
316
648
  });
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.
649
+ it('the NEGATIVE CONTROL — a RESOLVED HIGH inline still on the HEAD commit with no disposition on record denies at predicate 4 (mmnto-ai/totem#2861)', () => {
650
+ // Predicate 4's own territory (fold F2 of mmnto-ai/totem#2800, narrowed by
651
+ // the 2861 ruling), in the FIELD shape the legs constructed (f1, r2-f1): a
652
+ // bare UI resolve on a PR that keeps accumulating human comments. The only
653
+ // disposition naming this thread (root id 1001) PRE-dates the root; after
654
+ // it come a Bot's summary, a `[bot]`-suffixed login carrying the line (a
655
+ // counterfactual shape — GraphQL answers `Bot` for every App — kept to
656
+ // exercise the suffix arm), human chatter, and a later round's disposition
657
+ // that names ANOTHER thread (9999) and says so; the only in-thread reply is
658
+ // the bot's own. Predicate 2 passes, nothing discharges, the finding still
659
+ // applies, and the reason names the line it looked for AHEAD of the quoted
660
+ // body so it survives the matched bound (f6).
321
661
  const e = evaluate('synthetic-head-commit-high-inline.json');
322
662
  expect(e.verdict.disposition).toBe('deny');
323
663
  expect(e.verdict.provenance.ref).toBe('high-severity-inline');
324
664
  expect(e.detail.threads.unresolvedBot).toBe(0); // predicate 2 did NOT fire
325
665
  expect(e.detail.highInline).toBe(1);
666
+ expect(e.detail.dischargedHigh).toBe(0);
667
+ expect(e.detail.dischargedBy).toEqual({ inThreadReply: 0, prLevelDisposition: 0 });
668
+ // The reason names the LINE the read looked for — with this thread's own
669
+ // id — never "no disposition exists" (r2-f3: a round disposition that
670
+ // answered other threads is a real event).
671
+ expect(e.verdict.reason).toContain('"disposition: 1001 <verb>"');
672
+ expect(e.verdict.reason).toMatch(/and no non-bot reply in its thread/);
673
+ expect(e.verdict.reason).toMatch(/a round disposition that did not name this thread/);
674
+ // The id survives the 160-character bound on `matched` because the line
675
+ // LEADS the clause (r3-f3: a clause that led with prose cut it off).
676
+ expect(e.verdict.provenance.matched).toContain('"disposition: 1001 <verb>"');
677
+ expect(e.notices.some((n) => n.includes('discharged'))).toBe(false);
678
+ // The fixture really carries the post-dating human chatter the field has.
679
+ const fixture = loadFixture('synthetic-head-commit-high-inline.json');
680
+ const pr = fixture.pages[0].body.data.repository.pullRequest;
681
+ const humanAfterRoot = pr.comments.nodes.filter((c) => c.author.__typename === 'User' && c.createdAt > '2026-09-08T03:00:00Z');
682
+ // Three post-dating User-typed nodes: the `[bot]`-suffixed login NAMING
683
+ // this thread (skipped by the suffix arm, not by content), the human
684
+ // chatter, and the other round's disposition naming 9999 — asserted
685
+ // through the shipped predicate. The last is the r2-f1 shape: under a
686
+ // round-keyed read it discharged; under the id it cannot.
687
+ const suffixed = humanAfterRoot.filter((c) => /\[bot\]$/i.test(c.author.login));
688
+ const humans = humanAfterRoot.filter((c) => !/\[bot\]$/i.test(c.author.login));
689
+ expect(suffixed.length).toBe(1);
690
+ expect(dispositionedRootIds(suffixed[0].body)).toContain(1001);
691
+ expect(humans.length).toBe(2);
692
+ expect(humans.every((c) => !dispositionedRootIds(c.body).includes(1001))).toBe(true);
693
+ expect(humans.some((c) => dispositionedRootIds(c.body).includes(9999))).toBe(true);
694
+ });
695
+ // ─── The discharge read (mmnto-ai/totem#2861) ────────────────────────────
696
+ //
697
+ // A HIGH on the head whose thread was RESOLVED through the disposition path
698
+ // — the resolve-threads evidence rule on the same read — no longer applies.
699
+ // The bare resolve above is the fail-closed arm; these are the two evidence
700
+ // arms and the edges of each.
701
+ it('a resolved HIGH on the head with a non-bot PR-level disposition line naming it AFTER its root is discharged', () => {
702
+ const e = evaluate('synthetic-high-inline-discharged-pr-level.json');
703
+ expect(e.verdict.disposition).toBe('allow');
704
+ expect(e.detail.highInline).toBe(0);
705
+ expect(e.detail.dischargedHigh).toBe(1);
706
+ expect(e.detail.dischargedBy).toEqual({ inThreadReply: 0, prLevelDisposition: 1 });
707
+ // The audit breadcrumb: what the predicate RELEASED, and by which arm, is
708
+ // on stderr.
709
+ const line = e.notices.find((n) => n.includes('discharged through the disposition path'));
710
+ expect(line).toBeDefined();
711
+ expect(line).toContain('1 HIGH/Major bot inline(s)');
712
+ expect(line).toContain('1 by a PR-level disposition line naming the thread');
713
+ expect(line).toContain('mmnto-ai/totem#4242');
714
+ });
715
+ it('a resolved HIGH on the head with a non-bot IN-THREAD reply and no PR-level comment is discharged', () => {
716
+ const e = evaluate('synthetic-high-inline-discharged-in-thread.json');
717
+ expect(e.verdict.disposition).toBe('allow');
718
+ expect(e.detail.highInline).toBe(0);
719
+ expect(e.detail.dischargedHigh).toBe(1);
720
+ expect(e.detail.dischargedBy).toEqual({ inThreadReply: 1, prLevelDisposition: 0 });
721
+ expect(e.notices.join('\n')).toContain('1 by a non-bot in-thread reply');
722
+ });
723
+ it('a root with no readable databaseId is a thread no line can name — it stays applying, and the page stays readable (r3-f4)', () => {
724
+ // The schema types `databaseId` nullable. A page-scoped refusal would
725
+ // route the PR into the unevaluable class, which pilot maps to `warn` —
726
+ // the same downgrade fold 1 removed for the incomplete window. So the
727
+ // thread alone fails closed, at both tiers, and the reason says why.
728
+ for (const tier of ['strict', 'pilot']) {
729
+ const e = evaluate('synthetic-high-inline-root-id-missing.json', { tier });
730
+ expect(e.verdict.disposition, tier).toBe('deny');
731
+ expect(e.verdict.provenance.ref, tier).toBe('high-severity-inline');
732
+ expect(e.verdict.reason, tier).toMatch(/no line can name it/);
733
+ expect(e.detail.highInline, tier).toBe(1);
734
+ expect(e.detail.dischargedHigh, tier).toBe(0);
735
+ expect(e.notices.join('\n'), tier).not.toMatch(/could not derive/);
736
+ }
737
+ });
738
+ it('evidence FOUND discharges even when the thread window is incomplete (f7)', () => {
739
+ // The window is incomplete (`hasNextPage`) and carries only the bot's own
740
+ // reply, but the PR-level disposition after the root is on record: the
741
+ // completeness test never precedes the evidence tests.
742
+ const e = evaluate('synthetic-high-inline-discharged-window-incomplete.json');
743
+ expect(e.verdict.disposition).toBe('allow');
744
+ expect(e.detail.dischargedHigh).toBe(1);
745
+ expect(e.detail.dischargedBy).toEqual({ inThreadReply: 0, prLevelDisposition: 1 });
746
+ });
747
+ it('a deleted-account reply (author: null) is a human reply — the resolve-threads rule', () => {
748
+ const e = evaluate('synthetic-high-inline-deleted-author-reply.json');
749
+ expect(e.verdict.disposition).toBe('allow');
750
+ expect(e.detail.dischargedHigh).toBe(1);
751
+ });
752
+ it('an UNRESOLVED HIGH with a post-dating disposition is NOT discharged: the resolve is required, predicate 2 denies first', () => {
753
+ const e = evaluate('synthetic-high-inline-unresolved-with-disposition.json');
754
+ expect(e.verdict.disposition).toBe('deny');
755
+ expect(e.verdict.provenance.ref).toBe('unresolved-bot-threads');
756
+ expect(e.detail.highInline).toBe(1);
757
+ expect(e.detail.dischargedHigh).toBe(0);
758
+ });
759
+ it('discharge is judged PER THREAD: one discharged beside one bare denies at predicate 4 naming the bare one', () => {
760
+ const e = evaluate('synthetic-high-inline-mixed-discharge.json');
761
+ expect(e.verdict.disposition).toBe('deny');
762
+ expect(e.verdict.provenance.ref).toBe('high-severity-inline');
763
+ expect(e.detail.highInline).toBe(1);
764
+ expect(e.detail.dischargedHigh).toBe(1);
765
+ expect(e.detail.dischargedBy).toEqual({ inThreadReply: 1, prLevelDisposition: 0 });
766
+ expect(e.verdict.reason).toContain('coderabbitai');
767
+ expect(e.verdict.reason).toContain('"disposition: 1002 <verb>"');
768
+ expect(e.verdict.reason).toMatch(/a round disposition that did not name this thread/);
769
+ });
770
+ it('the pr-level fixture disposition names root 1001 on its line, read through the shipped predicate', () => {
771
+ // Read straight off the fixture rather than only through the verdict: the
772
+ // negative control's post-dating comments name nothing but 9999 (asserted
773
+ // above), while the pr-level fixture's disposition body names the thread.
774
+ const pr = loadFixture('synthetic-high-inline-discharged-pr-level.json').pages[0].body.data.repository.pullRequest;
775
+ const human = pr.comments.nodes.filter((c) => c.author.__typename === 'User');
776
+ expect(human).toHaveLength(1);
777
+ expect(dispositionedRootIds(human[0].body)).toEqual([1001]);
778
+ });
779
+ it('the disposition line is read at line start anywhere in the body, fenced included, and never inside an HTML comment, a quote, a list or a span', () => {
780
+ const line = 'disposition: 4000747291 declined';
781
+ // The skills render machine lines inside text fences; a fenced line counts.
782
+ expect(dispositionedRootIds(`## Round 1 disposition\n\n\`\`\`text\n${line}\n\`\`\`\n`)).toEqual([4000747291]);
783
+ expect(dispositionedRootIds(`prose\n\n${line}`)).toEqual([4000747291]);
784
+ expect(dispositionedRootIds(` ${line}`)).toEqual([4000747291]);
785
+ expect(dispositionedRootIds(`\t${line}`)).toEqual([4000747291]);
786
+ expect(dispositionedRootIds(`## Round 1 disposition\r\n\r\n${line}\r\n`)).toEqual([4000747291]);
787
+ // Several lines, in order, without duplicates; a verb is not required.
788
+ expect(dispositionedRootIds('disposition: 11 fixed\ndisposition: 22 declined\ndisposition: 11 nit\ndisposition: 33')).toEqual([11, 22, 33]);
789
+ // Not at line start: an inline span mid-sentence, a blockquote, a list
790
+ // item, a table cell. Not the token: a prose sentence, a different key.
791
+ expect(dispositionedRootIds(`as recorded: \`${line}\`.`)).toEqual([]);
792
+ expect(dispositionedRootIds(`> ${line}`)).toEqual([]);
793
+ expect(dispositionedRootIds(`- ${line}`)).toEqual([]);
794
+ expect(dispositionedRootIds(`| ${line} |`)).toEqual([]);
795
+ expect(dispositionedRootIds('the disposition: 4000747291 was declined')).toEqual([]);
796
+ expect(dispositionedRootIds('dispositions: 4000747291 declined')).toEqual([]);
797
+ // An HTML comment is stripped before the read — terminated, or left open
798
+ // to the end of the body the way a renderer hides it (r3-f9).
799
+ expect(dispositionedRootIds(`<!--\n${line}\n-->`)).toEqual([]);
800
+ expect(dispositionedRootIds(`<!-- note\n${line}`)).toEqual([]);
801
+ expect(dispositionedRootIds(`${line}\n<!-- note`)).toEqual([4000747291]);
802
+ // The id is the exact decimal the seat copied: a longer run of digits is
803
+ // a different id, and nothing coerced — a leading zero, a fraction, a
804
+ // glued letter or dash is not this id (r3-f5: `Number()` had equated
805
+ // `01001`, `1001.5` and `1001x` to 1001).
806
+ expect(dispositionedRootIds('disposition: 40007472911 declined')).toEqual([40007472911]);
807
+ for (const lenient of [
808
+ 'disposition: 04000747291 declined',
809
+ 'disposition: 4000747291.0 declined',
810
+ 'disposition: 4000747291.5 declined',
811
+ 'disposition: 4000747291x declined',
812
+ 'disposition: 4000747291-fixed',
813
+ 'disposition: +4000747291 declined',
814
+ 'disposition:4000747291 declined',
815
+ 'Disposition: 4000747291 declined',
816
+ ]) {
817
+ expect(dispositionedRootIds(lenient), lenient).toEqual([]);
818
+ }
819
+ expect(dispositionedRootIds('disposition: 4000747291')).toEqual([4000747291]);
820
+ expect(dispositionedRootIds('disposition: 4000747291\r\n')).toEqual([4000747291]);
821
+ expect(dispositionedRootIds('no line at all')).toEqual([]);
822
+ // A fenced QUOTE of a prior disposition names the same ids it named — the
823
+ // disclosed residue of accepting fenced lines; it can only re-name threads
824
+ // an earlier disposition already named.
825
+ expect(dispositionedRootIds(`For the record, the round disposition read:\n\n\`\`\`\n${line}\n\`\`\``)).toEqual([4000747291]);
826
+ });
827
+ it('PR-level evidence on the SECOND comments page is found, with the cursor sent', () => {
828
+ const e = evaluate('synthetic-comments-second-page-evidence.json');
829
+ expect(e.verdict.disposition).toBe('allow');
830
+ expect(e.detail.dischargedHigh).toBe(1);
831
+ expect(e.detail.threads.pagesRead).toBe(2);
832
+ expect(e.calls[2]).toContain('commentsAfter=CURSOR-C1');
833
+ });
834
+ it('a resolved HIGH with more comments than the window and no evidence in what was read is a bare resolve that DENIES at both tiers, the reason naming the window (f2)', () => {
835
+ // Pre-cure this shape was a predicate-4 FACT that denied at every tier; a
836
+ // first fold routed it to the unevaluable class, which pilot maps to
837
+ // `warn` — a downgrade the first leg caught (f2). It is a fact-side deny
838
+ // again, judged on what was read, and the reason says the window was
839
+ // incomplete rather than calling the thread unanswered outright.
840
+ for (const tier of ['strict', 'pilot']) {
841
+ const e = evaluate('synthetic-high-inline-resolved-window-incomplete.json', { tier });
842
+ expect(e.verdict.disposition, tier).toBe('deny');
843
+ expect(e.verdict.provenance.ref, tier).toBe('high-severity-inline');
844
+ expect(e.verdict.reason, tier).toMatch(/no non-bot reply in the ten comments read \(the thread has more/);
845
+ expect(e.verdict.reason, tier).toContain('"disposition: 1001 <verb>"');
846
+ // The id sits inside the 160-character bound on `matched` (r3-f3).
847
+ expect(e.verdict.provenance.matched, tier).toContain('"disposition: 1001 <verb>"');
848
+ expect(e.detail.highInline, tier).toBe(1);
849
+ expect(e.detail.dischargedHigh, tier).toBe(0);
850
+ expect(e.notices.join('\n'), tier).not.toMatch(/could not derive/);
851
+ }
326
852
  });
327
853
  it('reads comment.commit (where the finding applies NOW), never originalCommit', () => {
328
854
  // The falsifier for the inert predicate this fold removed: both fixtures
@@ -627,6 +1153,8 @@ describe('merge-ready — pagination and the unevaluable class', () => {
627
1153
  ['synthetic-reviews-connection-missing.json', /reviews connection was missing/],
628
1154
  ['synthetic-threads-connection-missing.json', /review threads connection was missing/],
629
1155
  ['synthetic-threads-pageinfo-missing.json', /review threads connection carried no pageInfo/],
1156
+ // The evidence surface (mmnto-ai/totem#2861) is held to the same bar.
1157
+ ['synthetic-comments-connection-missing.json', /PR comments connection was missing/],
630
1158
  ]) {
631
1159
  const strict = evaluate(file);
632
1160
  expect(strict.verdict.disposition, file).toBe('deny');
@@ -671,6 +1199,7 @@ describe('merge-ready — pagination and the unevaluable class', () => {
671
1199
  'synthetic-pagination-second-page-fails.json',
672
1200
  'synthetic-head-moved.json',
673
1201
  'synthetic-merge-state-unknown.json',
1202
+ 'synthetic-comments-connection-missing.json',
674
1203
  ]) {
675
1204
  for (const tier of ['strict', 'pilot']) {
676
1205
  const e = evaluate(file, { tier });