@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.
- package/dist/bot-identity.d.ts +94 -0
- package/dist/bot-identity.d.ts.map +1 -0
- package/dist/bot-identity.js +118 -0
- package/dist/bot-identity.js.map +1 -0
- package/dist/capability/review-catch.d.ts.map +1 -1
- package/dist/capability/review-catch.js +10 -6
- package/dist/capability/review-catch.js.map +1 -1
- package/dist/gate-engine.d.ts +8 -2
- package/dist/gate-engine.d.ts.map +1 -1
- package/dist/gate-engine.js +10 -2
- package/dist/gate-engine.js.map +1 -1
- package/dist/gate-engine.test.js +112 -1
- package/dist/gate-engine.test.js.map +1 -1
- package/dist/gate-types.d.ts +41 -1
- package/dist/gate-types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/merge-ready.d.ts +157 -0
- package/dist/merge-ready.d.ts.map +1 -0
- package/dist/merge-ready.js +1256 -0
- package/dist/merge-ready.js.map +1 -0
- package/dist/merge-ready.test.d.ts +2 -0
- package/dist/merge-ready.test.d.ts.map +1 -0
- package/dist/merge-ready.test.js +814 -0
- package/dist/merge-ready.test.js.map +1 -0
- package/dist/orchestration-resolver.d.ts +13 -7
- package/dist/orchestration-resolver.d.ts.map +1 -1
- package/dist/orchestration-resolver.js +60 -14
- package/dist/orchestration-resolver.js.map +1 -1
- package/dist/orchestration-resolver.test.js +277 -1
- package/dist/orchestration-resolver.test.js.map +1 -1
- package/dist/retrospect.d.ts +6 -6
- package/dist/selection-manifest.d.ts +2 -2
- package/dist/spine/corpus-dispositions.d.ts +30 -30
- package/dist/sys/git.d.ts +50 -0
- package/dist/sys/git.d.ts.map +1 -1
- package/dist/sys/git.js +101 -0
- package/dist/sys/git.js.map +1 -1
- package/dist/transport-shield.test.js +6 -2
- package/dist/transport-shield.test.js.map +1 -1
- 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
|
+
'',
|
|
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
|
+
'',
|
|
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
|
+
'',
|
|
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
|