@mmnto/cli 1.92.0 → 1.93.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/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +23 -4
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +135 -1
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +6 -2
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +63 -2
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +78 -3
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/review-fan.d.ts +336 -0
- package/dist/commands/review-fan.d.ts.map +1 -0
- package/dist/commands/review-fan.js +1076 -0
- package/dist/commands/review-fan.js.map +1 -0
- package/dist/commands/review-fan.test.d.ts +2 -0
- package/dist/commands/review-fan.test.d.ts.map +1 -0
- package/dist/commands/review-fan.test.js +1184 -0
- package/dist/commands/review-fan.test.js.map +1 -0
- package/dist/commands/shield-covariate.test.d.ts +14 -0
- package/dist/commands/shield-covariate.test.d.ts.map +1 -0
- package/dist/commands/shield-covariate.test.js +84 -0
- package/dist/commands/shield-covariate.test.js.map +1 -0
- package/dist/commands/shield.d.ts +162 -3
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +342 -74
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/shield.test.js +169 -3
- package/dist/commands/shield.test.js.map +1 -1
- package/dist/git.d.ts +25 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +50 -6
- package/dist/git.js.map +1 -1
- package/dist/git.test.js +119 -14
- package/dist/git.test.js.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/orchestrators/orchestrator.d.ts +1 -0
- package/dist/orchestrators/orchestrator.d.ts.map +1 -1
- package/dist/orchestrators/orchestrator.js +1 -1
- package/dist/orchestrators/orchestrator.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { computeLineageKey, computeVerdictArtifactContentHash, deriveCacheEligible, deriveSettled, findLatestVerdictForLineage, listVerdictArtifacts, readLedgerEvents, renderCovariateLine, TotemConfigError, VERDICT_ARTIFACT_SCHEMA_VERSION, } from '@mmnto/totem';
|
|
7
|
+
import { EMPTY_SHARED } from '../exemptions/exemption-schema.js';
|
|
8
|
+
import { cleanTmpDir } from '../test-utils.js';
|
|
9
|
+
import { assembleVerdict, assertFanFlagsSupported, buildDiffScope, classifyRejectedLane, printCovariateLine, resolveLineage, runLane, runReviewFan, validateReviewLanes, } from './review-fan.js';
|
|
10
|
+
import { MAX_DIFF_CHARS } from './shield-templates.js';
|
|
11
|
+
// ─── Test fixtures ────────────────────────────────────────────────────────────
|
|
12
|
+
const hex = (seed) => createHash('sha256').update(seed).digest('hex');
|
|
13
|
+
/** Test sink for the now-required scan `onWarn` (core is console-free; PR #2337 CR). */
|
|
14
|
+
const noWarn = () => { };
|
|
15
|
+
const wrapVerdict = (findings, summary = 's') => `<shield_verdict>${JSON.stringify({ findings, summary })}</shield_verdict>`;
|
|
16
|
+
/** Build a schema-valid RunArtifact (the panel re-parses these, so it must pass). */
|
|
17
|
+
function makeRunArtifact(opts) {
|
|
18
|
+
const provider = opts.provider ?? 'anthropic';
|
|
19
|
+
const model = opts.model ?? 'claude-x';
|
|
20
|
+
const artifact = {
|
|
21
|
+
schemaVersion: '1.1.0',
|
|
22
|
+
inputBundle: { maskedPrompt: 'prompt' },
|
|
23
|
+
inputHash: hex(`input-${provider}-${model}`),
|
|
24
|
+
grounding: opts.badProvenance
|
|
25
|
+
? {
|
|
26
|
+
hash: hex('g'),
|
|
27
|
+
provenanceSummary: 'made-up-class:1',
|
|
28
|
+
bundle: {
|
|
29
|
+
items: [
|
|
30
|
+
{
|
|
31
|
+
provenance: 'made-up-class',
|
|
32
|
+
contentHash: hex('citem'),
|
|
33
|
+
sourceType: 'code',
|
|
34
|
+
filePath: 'x.ts',
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
: { hash: hex('g'), provenanceSummary: 'ungrounded' },
|
|
40
|
+
backend: {
|
|
41
|
+
provider,
|
|
42
|
+
model,
|
|
43
|
+
qualifiedModel: `${provider}:${model}`,
|
|
44
|
+
admissionClass: 'completion_only',
|
|
45
|
+
taskProfile: 'Shield',
|
|
46
|
+
},
|
|
47
|
+
output: { content: opts.content, metrics: { durationMs: 1 } },
|
|
48
|
+
admission: { runMetadata: { caller: 'review' } },
|
|
49
|
+
createdAt: '2026-07-10T00:00:00.000Z',
|
|
50
|
+
};
|
|
51
|
+
return artifact;
|
|
52
|
+
}
|
|
53
|
+
/** A LaneInvocation for a completed lane whose output = the artifact content. */
|
|
54
|
+
function completedInvocation(opts) {
|
|
55
|
+
return {
|
|
56
|
+
content: opts.content,
|
|
57
|
+
runArtifactHash: hex(opts.seed),
|
|
58
|
+
runArtifact: makeRunArtifact(opts),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** An invoker driven by a laneModel → LaneInvocation map. */
|
|
62
|
+
function mapInvoker(map) {
|
|
63
|
+
return async (laneModel) => {
|
|
64
|
+
const entry = map[laneModel];
|
|
65
|
+
if (entry === undefined)
|
|
66
|
+
throw new Error(`no invocation configured for lane ${laneModel}`);
|
|
67
|
+
return typeof entry === 'function' ? entry() : entry;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// A stable fake worktree toplevel; `resolveLineage` runs `path.resolve` on it, so
|
|
71
|
+
// the lineage-key predictor below must resolve the same string identically.
|
|
72
|
+
const REPO_TOPLEVEL = process.platform === 'win32' ? 'C:/fake/worktree' : '/fake/worktree';
|
|
73
|
+
const RESOLVED_REPO = path.resolve(REPO_TOPLEVEL);
|
|
74
|
+
function fakeGit(branch, mergeBase) {
|
|
75
|
+
return (args) => {
|
|
76
|
+
if (args[0] === 'symbolic-ref')
|
|
77
|
+
return branch;
|
|
78
|
+
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel')
|
|
79
|
+
return REPO_TOPLEVEL;
|
|
80
|
+
if (args[0] === 'rev-parse')
|
|
81
|
+
return 'deadbeefdeadbeef';
|
|
82
|
+
if (args[0] === 'merge-base')
|
|
83
|
+
return mergeBase;
|
|
84
|
+
return '';
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const MINIMAL_CONFIG = {
|
|
88
|
+
totemDir: '.totem',
|
|
89
|
+
review: { sourceExtensions: ['.ts'] },
|
|
90
|
+
};
|
|
91
|
+
/** Build a ReviewFanContext for an end-to-end fan run over a temp totem dir. */
|
|
92
|
+
function makeCtx(totemDirAbs, laneModels, invoker, overrides = {}) {
|
|
93
|
+
return {
|
|
94
|
+
laneModels,
|
|
95
|
+
prompt: 'assembled prompt',
|
|
96
|
+
filteredDiff: 'diff --git a/x.ts b/x.ts\n+const x = 1;\n',
|
|
97
|
+
diffMeta: { source: 'branch-vs-base', base: 'main' },
|
|
98
|
+
config: MINIMAL_CONFIG,
|
|
99
|
+
cwd: totemDirAbs,
|
|
100
|
+
configRoot: totemDirAbs,
|
|
101
|
+
totemDirAbs,
|
|
102
|
+
options: {},
|
|
103
|
+
groundingHash: hex('gh'),
|
|
104
|
+
provenanceSummary: 'ungrounded',
|
|
105
|
+
groundingBundle: { items: [] },
|
|
106
|
+
totalResults: 0,
|
|
107
|
+
codeBlind: false,
|
|
108
|
+
shared: EMPTY_SHARED,
|
|
109
|
+
preFanContentHash: null,
|
|
110
|
+
invoker,
|
|
111
|
+
gitExec: fakeGit('feature-x', 'basesha'),
|
|
112
|
+
now: () => new Date().toISOString(),
|
|
113
|
+
// Default the post-fan content hash to null (matches the null preFanContentHash
|
|
114
|
+
// ⇒ reviewedState='matched') so the fan never spawns real git in a temp dir.
|
|
115
|
+
// Gate 1 overrides this to simulate a mid-fan tree mutation.
|
|
116
|
+
contentHash: async () => null,
|
|
117
|
+
...overrides,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// ─── validateReviewLanes (config validator) ──────────────────────────────────
|
|
121
|
+
describe('validateReviewLanes', () => {
|
|
122
|
+
it('accepts and normalizes known provider:model lanes', () => {
|
|
123
|
+
const out = validateReviewLanes(['anthropic:claude-x', 'gemini:gemini-2.5'], 'anthropic', TotemConfigError);
|
|
124
|
+
expect(out).toEqual(['anthropic:claude-x', 'gemini:gemini-2.5']);
|
|
125
|
+
});
|
|
126
|
+
it('resolves a bare lane against the base provider', () => {
|
|
127
|
+
const out = validateReviewLanes(['claude-x'], 'anthropic', TotemConfigError);
|
|
128
|
+
expect(out).toEqual(['anthropic:claude-x']);
|
|
129
|
+
});
|
|
130
|
+
it('returns [] for absent lanes (legacy path)', () => {
|
|
131
|
+
expect(validateReviewLanes(undefined, 'anthropic', TotemConfigError)).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
it('rejects the shell provider as an unsupported adapter for fan lanes (Gate G2)', () => {
|
|
134
|
+
// Capability-admission wording — a support-limit error, not "structurally
|
|
135
|
+
// ineligible" / allowlist phrasing.
|
|
136
|
+
expect(() => validateReviewLanes(['shell:echo'], 'anthropic', TotemConfigError)).toThrow(/unsupported adapter for review fan lanes/);
|
|
137
|
+
});
|
|
138
|
+
it('rejects duplicate normalized lanes', () => {
|
|
139
|
+
expect(() => validateReviewLanes(['anthropic:claude-x', 'anthropic:claude-x'], 'anthropic', TotemConfigError)).toThrow(/duplicate/);
|
|
140
|
+
});
|
|
141
|
+
it('rejects an unknown provider prefix', () => {
|
|
142
|
+
expect(() => validateReviewLanes(['weirdvendor:some-model'], 'anthropic', TotemConfigError)).toThrow(/unknown provider/);
|
|
143
|
+
});
|
|
144
|
+
it('rejects empty / whitespace-only entries', () => {
|
|
145
|
+
expect(() => validateReviewLanes([' '], 'anthropic', TotemConfigError)).toThrow(/empty/);
|
|
146
|
+
});
|
|
147
|
+
it('rejects a bare lane when no base provider is configured', () => {
|
|
148
|
+
expect(() => validateReviewLanes(['claude-x'], undefined, TotemConfigError)).toThrow(/no orchestrator provider/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
// ─── assertFanFlagsSupported (finding 12 — loud flag honesty) ─────────────────
|
|
152
|
+
describe('assertFanFlagsSupported', () => {
|
|
153
|
+
it('rejects --suppress when the fan is active', () => {
|
|
154
|
+
expect(() => assertFanFlagsSupported({ suppress: ['some-label'] }, TotemConfigError)).toThrow(/--suppress/);
|
|
155
|
+
});
|
|
156
|
+
it('rejects --learn when the fan is active', () => {
|
|
157
|
+
expect(() => assertFanFlagsSupported({ learn: true }, TotemConfigError)).toThrow(/--learn/);
|
|
158
|
+
});
|
|
159
|
+
it('rejects --auto-capture when the fan is active', () => {
|
|
160
|
+
expect(() => assertFanFlagsSupported({ autoCapture: true }, TotemConfigError)).toThrow(/--auto-capture/);
|
|
161
|
+
});
|
|
162
|
+
it('names ALL unsupported flags when several are combined', () => {
|
|
163
|
+
expect(() => assertFanFlagsSupported({ suppress: ['x'], learn: true, autoCapture: true }, TotemConfigError)).toThrow(/--suppress.*--learn.*--auto-capture/);
|
|
164
|
+
});
|
|
165
|
+
it('accepts a clean options set (no unsupported flags)', () => {
|
|
166
|
+
expect(() => assertFanFlagsSupported({}, TotemConfigError)).not.toThrow();
|
|
167
|
+
expect(() => assertFanFlagsSupported({ suppress: [] }, TotemConfigError)).not.toThrow();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
// ─── Predicates (core-owned deriveSettled / deriveCacheEligible — gate 6 + gate 8) ──
|
|
171
|
+
describe('deriveSettled / deriveCacheEligible (core-owned)', () => {
|
|
172
|
+
const completedLane = (id) => ({
|
|
173
|
+
status: 'completed',
|
|
174
|
+
laneId: id,
|
|
175
|
+
resolvedBackend: 'anthropic:claude-x',
|
|
176
|
+
runArtifactHash: hex(id),
|
|
177
|
+
verdictSummary: { critical: 0, warn: 0, info: 0 },
|
|
178
|
+
});
|
|
179
|
+
const failedLane = (id) => ({
|
|
180
|
+
status: 'failed',
|
|
181
|
+
laneId: id,
|
|
182
|
+
typedReason: 'invoke-error',
|
|
183
|
+
configuredLane: 'anthropic:claude-x',
|
|
184
|
+
});
|
|
185
|
+
const warn = { severity: 'WARN', confidence: 0.6, message: 'w' };
|
|
186
|
+
const critical = { severity: 'CRITICAL', confidence: 0.9, message: 'c' };
|
|
187
|
+
it('a dry round settles and is cache-eligible', () => {
|
|
188
|
+
const inputs = {
|
|
189
|
+
lanes: [completedLane('a'), completedLane('b')],
|
|
190
|
+
findings: [],
|
|
191
|
+
postChecks: [],
|
|
192
|
+
reviewedState: 'matched',
|
|
193
|
+
};
|
|
194
|
+
expect(deriveSettled(inputs)).toBe(true);
|
|
195
|
+
expect(deriveCacheEligible(inputs)).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
it('a WARN blocks settled but NOT cache-eligibility (WARN-drip class)', () => {
|
|
198
|
+
const inputs = {
|
|
199
|
+
lanes: [completedLane('a')],
|
|
200
|
+
findings: [warn],
|
|
201
|
+
postChecks: [],
|
|
202
|
+
reviewedState: 'matched',
|
|
203
|
+
};
|
|
204
|
+
expect(deriveSettled(inputs)).toBe(false);
|
|
205
|
+
expect(deriveCacheEligible(inputs)).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
it('a CRITICAL blocks BOTH settled and cache-eligibility', () => {
|
|
208
|
+
const inputs = {
|
|
209
|
+
lanes: [completedLane('a')],
|
|
210
|
+
findings: [critical],
|
|
211
|
+
postChecks: [],
|
|
212
|
+
reviewedState: 'matched',
|
|
213
|
+
};
|
|
214
|
+
expect(deriveSettled(inputs)).toBe(false);
|
|
215
|
+
expect(deriveCacheEligible(inputs)).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
it('a failed lane blocks BOTH (lane coverage conjunct)', () => {
|
|
218
|
+
const inputs = {
|
|
219
|
+
lanes: [completedLane('a'), failedLane('b')],
|
|
220
|
+
findings: [],
|
|
221
|
+
postChecks: [],
|
|
222
|
+
reviewedState: 'matched',
|
|
223
|
+
};
|
|
224
|
+
expect(deriveSettled(inputs)).toBe(false);
|
|
225
|
+
expect(deriveCacheEligible(inputs)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
it("reviewedState 'drifted' blocks BOTH even on an otherwise-dry round (codex rev-2 fold 1)", () => {
|
|
228
|
+
const inputs = {
|
|
229
|
+
lanes: [completedLane('a'), completedLane('b')],
|
|
230
|
+
findings: [],
|
|
231
|
+
postChecks: [],
|
|
232
|
+
reviewedState: 'drifted',
|
|
233
|
+
};
|
|
234
|
+
expect(deriveSettled(inputs)).toBe(false);
|
|
235
|
+
expect(deriveCacheEligible(inputs)).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
it('a decidable-tier post-check fail gates BOTH; a sensor-tier fail gates NEITHER (gate 8)', () => {
|
|
238
|
+
const base = {
|
|
239
|
+
lanes: [completedLane('a')],
|
|
240
|
+
findings: [],
|
|
241
|
+
reviewedState: 'matched',
|
|
242
|
+
};
|
|
243
|
+
const sensorFail = {
|
|
244
|
+
...base,
|
|
245
|
+
postChecks: [
|
|
246
|
+
{
|
|
247
|
+
ruleName: 'provenance-fail-safe-down',
|
|
248
|
+
tier: 'sensor',
|
|
249
|
+
verdict: 'fail',
|
|
250
|
+
message: 'x',
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
expect(deriveSettled(sensorFail)).toBe(true);
|
|
255
|
+
expect(deriveCacheEligible(sensorFail)).toBe(true);
|
|
256
|
+
const decidableFail = {
|
|
257
|
+
...base,
|
|
258
|
+
postChecks: [
|
|
259
|
+
{
|
|
260
|
+
ruleName: 'review-structured-verdict',
|
|
261
|
+
tier: 'decidable',
|
|
262
|
+
verdict: 'fail',
|
|
263
|
+
message: 'x',
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
expect(deriveSettled(decidableFail)).toBe(false);
|
|
268
|
+
expect(deriveCacheEligible(decidableFail)).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
// ─── runLane (per-lane classification — gate 2 + gate 3) ──────────────────────
|
|
272
|
+
describe('runLane', () => {
|
|
273
|
+
it('a response-cache/missing-emission lane fails (no completed lane without an artifact)', async () => {
|
|
274
|
+
const invoker = mapInvoker({
|
|
275
|
+
'anthropic:claude-x': {
|
|
276
|
+
content: wrapVerdict([]),
|
|
277
|
+
runArtifactHash: undefined,
|
|
278
|
+
runArtifact: undefined,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
const result = await runLane(1, 'anthropic:claude-x', invoker, EMPTY_SHARED, 'delivered-prompt');
|
|
282
|
+
expect(result.lane.status).toBe('failed');
|
|
283
|
+
if (result.lane.status === 'failed') {
|
|
284
|
+
expect(result.lane.typedReason).toBe('missing-artifact-emission');
|
|
285
|
+
// A failed lane (no backend resolved) uses the CONFIGURED lane in the laneId.
|
|
286
|
+
expect(result.lane.laneId).toBe('lane-1:anthropic:claude-x');
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
it('malformed lane output abstains (never a completed lane)', async () => {
|
|
290
|
+
const invoker = mapInvoker({
|
|
291
|
+
'anthropic:claude-x': completedInvocation({ content: 'not a verdict at all', seed: 'l1' }),
|
|
292
|
+
});
|
|
293
|
+
const result = await runLane(0, 'anthropic:claude-x', invoker, EMPTY_SHARED, 'delivered-prompt');
|
|
294
|
+
expect(result.lane.status).toBe('abstained');
|
|
295
|
+
});
|
|
296
|
+
it('an extractable verdict completes with an honest severity tally + lane-blind laneId', async () => {
|
|
297
|
+
const content = wrapVerdict([
|
|
298
|
+
{ severity: 'CRITICAL', confidence: 0.9, message: 'c' },
|
|
299
|
+
{ severity: 'WARN', confidence: 0.6, message: 'w' },
|
|
300
|
+
]);
|
|
301
|
+
const invoker = mapInvoker({
|
|
302
|
+
'anthropic:claude-x': completedInvocation({ content, seed: 'l2' }),
|
|
303
|
+
});
|
|
304
|
+
const result = await runLane(0, 'anthropic:claude-x', invoker, EMPTY_SHARED, 'delivered-prompt');
|
|
305
|
+
expect(result.lane.status).toBe('completed');
|
|
306
|
+
if (result.lane.status === 'completed') {
|
|
307
|
+
expect(result.lane.verdictSummary).toEqual({ critical: 1, warn: 1, info: 0 });
|
|
308
|
+
expect(result.lane.resolvedBackend).toBe('anthropic:claude-x');
|
|
309
|
+
// laneId is `lane-<index>:<resolvedBackend>` (Prop 302 G1 vocabulary).
|
|
310
|
+
expect(result.lane.laneId).toBe('lane-0:anthropic:claude-x');
|
|
311
|
+
}
|
|
312
|
+
expect(result.filteredFindings).toHaveLength(2);
|
|
313
|
+
});
|
|
314
|
+
it('a quota throw REJECTS runLane and classifies to a failed quota-exhausted lane (finding 13)', async () => {
|
|
315
|
+
const invoker = async () => {
|
|
316
|
+
throw new Error('Quota exhausted for anthropic:claude-x.');
|
|
317
|
+
};
|
|
318
|
+
// runLane no longer swallows the invoker throw — it rejects, and the fan's
|
|
319
|
+
// allSettled maps the rejection to a failed lane via classifyRejectedLane.
|
|
320
|
+
await expect(runLane(0, 'anthropic:claude-x', invoker, EMPTY_SHARED, 'delivered-prompt')).rejects.toThrow(/Quota exhausted/);
|
|
321
|
+
const classified = await classifyRejectedLane(2, 'anthropic:claude-x', new Error('Quota exhausted for anthropic:claude-x.'));
|
|
322
|
+
expect(classified.lane.status).toBe('failed');
|
|
323
|
+
if (classified.lane.status === 'failed') {
|
|
324
|
+
expect(classified.lane.typedReason).toBe('quota-exhausted');
|
|
325
|
+
expect(classified.lane.laneId).toBe('lane-2:anthropic:claude-x');
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
it('a generic invoke throw classifies to a failed invoke-error lane', async () => {
|
|
329
|
+
const classified = await classifyRejectedLane(0, 'anthropic:claude-x', new Error('socket hang up'));
|
|
330
|
+
expect(classified.lane.status).toBe('failed');
|
|
331
|
+
if (classified.lane.status === 'failed')
|
|
332
|
+
expect(classified.lane.typedReason).toBe('invoke-error');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
// ─── Diff scope + lineage ─────────────────────────────────────────────────────
|
|
336
|
+
describe('buildDiffScope', () => {
|
|
337
|
+
it('records both endpoints for explicit-range, defaulting a bare head to HEAD', () => {
|
|
338
|
+
expect(buildDiffScope({ source: 'explicit-range', base: 'HEAD^' }, hex('d'))).toEqual({
|
|
339
|
+
source: 'explicit-range',
|
|
340
|
+
diffHash: hex('d'),
|
|
341
|
+
base: 'HEAD^',
|
|
342
|
+
head: 'HEAD',
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
it('records base only for branch-vs-base', () => {
|
|
346
|
+
expect(buildDiffScope({ source: 'branch-vs-base', base: 'main' }, hex('d'))).toEqual({
|
|
347
|
+
source: 'branch-vs-base',
|
|
348
|
+
diffHash: hex('d'),
|
|
349
|
+
base: 'main',
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
it('records no refs for staged / uncommitted', () => {
|
|
353
|
+
expect(buildDiffScope({ source: 'staged' }, hex('d'))).toEqual({
|
|
354
|
+
source: 'staged',
|
|
355
|
+
diffHash: hex('d'),
|
|
356
|
+
});
|
|
357
|
+
expect(buildDiffScope({ source: 'uncommitted' }, hex('d'))).toEqual({
|
|
358
|
+
source: 'uncommitted',
|
|
359
|
+
diffHash: hex('d'),
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
describe('resolveLineage (gate 6)', () => {
|
|
364
|
+
it('two branches sharing base=main produce DISTINCT lineage keys', async () => {
|
|
365
|
+
const meta = { source: 'branch-vs-base', base: 'main' };
|
|
366
|
+
const a = await resolveLineage(meta, fakeGit('feature-a', 'sharedbase'));
|
|
367
|
+
const b = await resolveLineage(meta, fakeGit('feature-b', 'sharedbase'));
|
|
368
|
+
expect(a.lineageKey).not.toBe(b.lineageKey);
|
|
369
|
+
// Sanity: the same branch + base + source is stable.
|
|
370
|
+
const a2 = await resolveLineage(meta, fakeGit('feature-a', 'sharedbase'));
|
|
371
|
+
expect(a.lineageKey).toBe(a2.lineageKey);
|
|
372
|
+
});
|
|
373
|
+
it('two different explicit ranges on one branch + merge-base produce DISTINCT keys (codex rev-2 gate 2)', async () => {
|
|
374
|
+
const git = fakeGit('feature-x', 'sharedbase');
|
|
375
|
+
const a = await resolveLineage({ source: 'explicit-range', base: 'HEAD~3', head: 'HEAD' }, git);
|
|
376
|
+
const b = await resolveLineage({ source: 'explicit-range', base: 'HEAD~5', head: 'HEAD' }, git);
|
|
377
|
+
expect(a.lineageKey).not.toBe(b.lineageKey);
|
|
378
|
+
// Sanity: the same range on the same branch is stable.
|
|
379
|
+
const a2 = await resolveLineage({ source: 'explicit-range', base: 'HEAD~3', head: 'HEAD' }, git);
|
|
380
|
+
expect(a.lineageKey).toBe(a2.lineageKey);
|
|
381
|
+
});
|
|
382
|
+
it('--diff main (working-tree) and --diff main..HEAD (range) do NOT share a lineage (finding 10)', async () => {
|
|
383
|
+
const git = fakeGit('feature-x', 'sharedbase');
|
|
384
|
+
// Both resolve to base='main' head='HEAD' — only the raw selectorForm differs.
|
|
385
|
+
const bareForm = await resolveLineage({ source: 'explicit-range', base: 'main', selectorForm: 'main' }, git);
|
|
386
|
+
const rangeForm = await resolveLineage({ source: 'explicit-range', base: 'main', head: 'HEAD', selectorForm: 'main..HEAD' }, git);
|
|
387
|
+
expect(bareForm.lineageKey).not.toBe(rangeForm.lineageKey);
|
|
388
|
+
// Sanity: the same selector form is stable.
|
|
389
|
+
const bareForm2 = await resolveLineage({ source: 'explicit-range', base: 'main', selectorForm: 'main' }, git);
|
|
390
|
+
expect(bareForm.lineageKey).toBe(bareForm2.lineageKey);
|
|
391
|
+
});
|
|
392
|
+
it('staged/uncommitted use an empty merge-base (branch + source carry lineage)', async () => {
|
|
393
|
+
const res = await resolveLineage({ source: 'staged' }, fakeGit('feature-a', 'unused'));
|
|
394
|
+
expect(res.mergeBase).toBe('');
|
|
395
|
+
expect(res.branch).toBe('feature-a');
|
|
396
|
+
});
|
|
397
|
+
it('a detached HEAD becomes a DETACHED:<sha> literal', async () => {
|
|
398
|
+
const git = (args) => {
|
|
399
|
+
if (args[0] === 'symbolic-ref')
|
|
400
|
+
throw new Error('detached');
|
|
401
|
+
if (args[0] === 'rev-parse')
|
|
402
|
+
return 'abc123';
|
|
403
|
+
return '';
|
|
404
|
+
};
|
|
405
|
+
const res = await resolveLineage({ source: 'uncommitted' }, git);
|
|
406
|
+
expect(res.branch).toBe('DETACHED:abc123');
|
|
407
|
+
});
|
|
408
|
+
it('an explicit-range lineage never spawns git merge-base — only branch-vs-base needs it (greptile item 2)', async () => {
|
|
409
|
+
// A spy git that RECORDS every probe. explicit-range keys on its base+head endpoints
|
|
410
|
+
// and discards the merge-base, so `resolveMergeBase` must short-circuit WITHOUT
|
|
411
|
+
// shelling out to `git merge-base` for it.
|
|
412
|
+
const recordingGit = () => {
|
|
413
|
+
const calls = [];
|
|
414
|
+
const git = (args) => {
|
|
415
|
+
calls.push([...args]);
|
|
416
|
+
if (args[0] === 'symbolic-ref')
|
|
417
|
+
return 'feature-x';
|
|
418
|
+
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel')
|
|
419
|
+
return REPO_TOPLEVEL;
|
|
420
|
+
if (args[0] === 'rev-parse')
|
|
421
|
+
return 'deadbeefdeadbeef';
|
|
422
|
+
if (args[0] === 'merge-base')
|
|
423
|
+
return 'shouldNotBeCalled';
|
|
424
|
+
return '';
|
|
425
|
+
};
|
|
426
|
+
return { git, calls };
|
|
427
|
+
};
|
|
428
|
+
const range = recordingGit();
|
|
429
|
+
await resolveLineage({ source: 'explicit-range', base: 'HEAD~3', head: 'HEAD' }, range.git);
|
|
430
|
+
expect(range.calls.some((c) => c[0] === 'merge-base')).toBe(false);
|
|
431
|
+
// Control: branch-vs-base DOES probe merge-base — proving the spy would have caught it.
|
|
432
|
+
const branch = recordingGit();
|
|
433
|
+
await resolveLineage({ source: 'branch-vs-base', base: 'main' }, branch.git);
|
|
434
|
+
expect(branch.calls.some((c) => c[0] === 'merge-base')).toBe(true);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
// ─── assembleVerdict (structure) ──────────────────────────────────────────────
|
|
438
|
+
describe('assembleVerdict', () => {
|
|
439
|
+
const lane = (id, findings) => ({
|
|
440
|
+
lane: {
|
|
441
|
+
status: 'completed',
|
|
442
|
+
laneId: id,
|
|
443
|
+
resolvedBackend: 'anthropic:claude-x',
|
|
444
|
+
runArtifactHash: hex(id),
|
|
445
|
+
verdictSummary: {
|
|
446
|
+
critical: findings.filter((f) => f.severity === 'CRITICAL').length,
|
|
447
|
+
warn: findings.filter((f) => f.severity === 'WARN').length,
|
|
448
|
+
info: findings.filter((f) => f.severity === 'INFO').length,
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
runArtifact: makeRunArtifact({ content: wrapVerdict(findings) }),
|
|
452
|
+
filteredFindings: findings,
|
|
453
|
+
});
|
|
454
|
+
it('derives counts and settled from artifact content, never mirrored on trust', () => {
|
|
455
|
+
const verdict = assembleVerdict({
|
|
456
|
+
diffScope: { source: 'staged', diffHash: hex('d') },
|
|
457
|
+
laneResults: [lane('a', []), lane('b', [])],
|
|
458
|
+
panelAndChecks: { postChecks: [] },
|
|
459
|
+
round: { index: 0, lineageKey: 'lk' },
|
|
460
|
+
reviewedState: 'matched',
|
|
461
|
+
createdAt: '2026-07-10T00:00:00.000Z',
|
|
462
|
+
}, deriveSettled, VERDICT_ARTIFACT_SCHEMA_VERSION);
|
|
463
|
+
expect(verdict.attemptedLaneCount).toBe(2);
|
|
464
|
+
expect(verdict.completedLaneCount).toBe(2);
|
|
465
|
+
expect(verdict.reviewedState).toBe('matched');
|
|
466
|
+
expect(verdict.settled).toBe(true);
|
|
467
|
+
expect(verdict.panelArtifactHash).toBeUndefined();
|
|
468
|
+
});
|
|
469
|
+
it("records reviewedState='drifted' and forces settled=false on an otherwise-dry fan", () => {
|
|
470
|
+
const verdict = assembleVerdict({
|
|
471
|
+
diffScope: { source: 'staged', diffHash: hex('d') },
|
|
472
|
+
laneResults: [lane('a', []), lane('b', [])],
|
|
473
|
+
panelAndChecks: { postChecks: [] },
|
|
474
|
+
round: { index: 0, lineageKey: 'lk' },
|
|
475
|
+
reviewedState: 'drifted',
|
|
476
|
+
createdAt: '2026-07-10T00:00:00.000Z',
|
|
477
|
+
}, deriveSettled, VERDICT_ARTIFACT_SCHEMA_VERSION);
|
|
478
|
+
expect(verdict.reviewedState).toBe('drifted');
|
|
479
|
+
expect(verdict.settled).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
// ─── runReviewFan (end-to-end — gates 4, 5, 7 wiring) ─────────────────────────
|
|
483
|
+
describe('runReviewFan', () => {
|
|
484
|
+
let tmpDir;
|
|
485
|
+
beforeEach(() => {
|
|
486
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-fan-'));
|
|
487
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
488
|
+
});
|
|
489
|
+
afterEach(() => {
|
|
490
|
+
vi.restoreAllMocks();
|
|
491
|
+
cleanTmpDir(tmpDir);
|
|
492
|
+
});
|
|
493
|
+
// Predict the composite lineage key `resolveLineage` computes for a makeCtx fan:
|
|
494
|
+
// repoIdentity = resolved fake toplevel; branch-vs-base contributes base='main'
|
|
495
|
+
// (the makeCtx diffMeta base) + the resolved merge-base.
|
|
496
|
+
const lineageKeyFor = (branch, mergeBase, source) => {
|
|
497
|
+
switch (source) {
|
|
498
|
+
case 'branch-vs-base':
|
|
499
|
+
return computeLineageKey({
|
|
500
|
+
repoIdentity: RESOLVED_REPO,
|
|
501
|
+
branch,
|
|
502
|
+
source,
|
|
503
|
+
base: 'main',
|
|
504
|
+
mergeBase,
|
|
505
|
+
});
|
|
506
|
+
case 'explicit-range':
|
|
507
|
+
return computeLineageKey({
|
|
508
|
+
repoIdentity: RESOLVED_REPO,
|
|
509
|
+
branch,
|
|
510
|
+
source,
|
|
511
|
+
base: 'main',
|
|
512
|
+
head: 'HEAD',
|
|
513
|
+
});
|
|
514
|
+
case 'staged':
|
|
515
|
+
case 'uncommitted':
|
|
516
|
+
return computeLineageKey({ repoIdentity: RESOLVED_REPO, branch, source });
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
const oneFailedOnePassing = () => mapInvoker({
|
|
520
|
+
'anthropic:claude-a': completedInvocation({
|
|
521
|
+
content: wrapVerdict([]),
|
|
522
|
+
provider: 'anthropic',
|
|
523
|
+
model: 'claude-a',
|
|
524
|
+
seed: 'a',
|
|
525
|
+
}),
|
|
526
|
+
'gemini:g': async () => {
|
|
527
|
+
throw new Error('socket hang up');
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
it('one failed + one passing lane: DEFAULT sensor exit 0 — honest counts, verdict written, NO panel (finding 3)', async () => {
|
|
531
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], oneFailedOnePassing());
|
|
532
|
+
// Default (no --fail-on): degraded coverage does NOT throw (sensor exit 0).
|
|
533
|
+
await expect(runReviewFan(ctx)).resolves.toBeUndefined();
|
|
534
|
+
const verdicts = listVerdictArtifacts(tmpDir, noWarn);
|
|
535
|
+
expect(verdicts).toHaveLength(1);
|
|
536
|
+
const v = verdicts[0].artifact;
|
|
537
|
+
expect(v.attemptedLaneCount).toBe(2);
|
|
538
|
+
expect(v.completedLaneCount).toBe(1);
|
|
539
|
+
// 1 completed lane ⇒ NO panel was assembled.
|
|
540
|
+
expect(v.panelArtifactHash).toBeUndefined();
|
|
541
|
+
expect(v.diversity).toBeUndefined();
|
|
542
|
+
expect(v.settled).toBe(false);
|
|
543
|
+
// The verdict records the honest lane mix (a rejected lane is never lost — finding 13).
|
|
544
|
+
expect(v.lanes.map((l) => l.status).sort()).toEqual(['completed', 'failed']);
|
|
545
|
+
});
|
|
546
|
+
it('one failed + one passing lane: --fail-on critical throws on the degraded (not cache-eligible) round', async () => {
|
|
547
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], oneFailedOnePassing(), {
|
|
548
|
+
options: { failOn: 'critical' },
|
|
549
|
+
});
|
|
550
|
+
// No CRITICAL finding, but the round is not cache-eligible (a lane failed) ⇒ throws.
|
|
551
|
+
await expect(runReviewFan(ctx)).rejects.toThrow(/lane coverage 1\/2/);
|
|
552
|
+
// The honest verdict is still written before the throw.
|
|
553
|
+
expect(listVerdictArtifacts(tmpDir, noWarn)[0].artifact.completedLaneCount).toBe(1);
|
|
554
|
+
});
|
|
555
|
+
it('two completed lanes assemble a panel from usable lanes only', async () => {
|
|
556
|
+
const invoker = mapInvoker({
|
|
557
|
+
'anthropic:claude-a': completedInvocation({
|
|
558
|
+
content: wrapVerdict([]),
|
|
559
|
+
provider: 'anthropic',
|
|
560
|
+
model: 'claude-a',
|
|
561
|
+
seed: 'a',
|
|
562
|
+
}),
|
|
563
|
+
'gemini:g': completedInvocation({
|
|
564
|
+
content: wrapVerdict([]),
|
|
565
|
+
provider: 'gemini',
|
|
566
|
+
model: 'g',
|
|
567
|
+
seed: 'b',
|
|
568
|
+
}),
|
|
569
|
+
});
|
|
570
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], invoker);
|
|
571
|
+
await runReviewFan(ctx); // dry → PASS, no throw
|
|
572
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
573
|
+
expect(v.completedLaneCount).toBe(2);
|
|
574
|
+
expect(v.panelArtifactHash).toMatch(/^[0-9a-f]{64}$/);
|
|
575
|
+
expect(v.diversity?.class).toBe('cross-vendor');
|
|
576
|
+
expect(v.settled).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
it('a CRITICAL persisting across two rounds never settles; a later dry round settles', async () => {
|
|
579
|
+
const criticalContent = wrapVerdict([
|
|
580
|
+
{ severity: 'CRITICAL', confidence: 0.9, message: 'boom' },
|
|
581
|
+
]);
|
|
582
|
+
const cleanContent = wrapVerdict([]);
|
|
583
|
+
const git = fakeGit('feature-x', 'basesha');
|
|
584
|
+
const lk = lineageKeyFor('feature-x', 'basesha', 'branch-vs-base');
|
|
585
|
+
// Round 0 — CRITICAL present. Default sensor exit 0: writes the verdict, no throw.
|
|
586
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
587
|
+
'anthropic:claude-a': completedInvocation({
|
|
588
|
+
content: criticalContent,
|
|
589
|
+
provider: 'anthropic',
|
|
590
|
+
model: 'claude-a',
|
|
591
|
+
seed: 'r0a',
|
|
592
|
+
}),
|
|
593
|
+
'gemini:g': completedInvocation({
|
|
594
|
+
content: cleanContent,
|
|
595
|
+
provider: 'gemini',
|
|
596
|
+
model: 'g',
|
|
597
|
+
seed: 'r0b',
|
|
598
|
+
}),
|
|
599
|
+
}), { gitExec: git }));
|
|
600
|
+
const r0 = findLatestVerdictForLineage(tmpDir, lk, noWarn).artifact;
|
|
601
|
+
expect(r0.round.index).toBe(0);
|
|
602
|
+
expect(r0.settled).toBe(false);
|
|
603
|
+
// Round 1 — same CRITICAL persists; must link as round 1 and still not settle.
|
|
604
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
605
|
+
'anthropic:claude-a': completedInvocation({
|
|
606
|
+
content: criticalContent,
|
|
607
|
+
provider: 'anthropic',
|
|
608
|
+
model: 'claude-a',
|
|
609
|
+
seed: 'r1a',
|
|
610
|
+
}),
|
|
611
|
+
'gemini:g': completedInvocation({
|
|
612
|
+
content: cleanContent,
|
|
613
|
+
provider: 'gemini',
|
|
614
|
+
model: 'g',
|
|
615
|
+
seed: 'r1b',
|
|
616
|
+
}),
|
|
617
|
+
}), { gitExec: git }));
|
|
618
|
+
const r1 = findLatestVerdictForLineage(tmpDir, lk, noWarn).artifact;
|
|
619
|
+
expect(r1.round.index).toBe(1);
|
|
620
|
+
expect(r1.round.priorVerdictHash).toBeDefined();
|
|
621
|
+
expect(r1.settled).toBe(false);
|
|
622
|
+
// Round 2 — a genuinely dry round (both lanes clean) settles.
|
|
623
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
624
|
+
'anthropic:claude-a': completedInvocation({
|
|
625
|
+
content: cleanContent,
|
|
626
|
+
provider: 'anthropic',
|
|
627
|
+
model: 'claude-a',
|
|
628
|
+
seed: 'r2a',
|
|
629
|
+
}),
|
|
630
|
+
'gemini:g': completedInvocation({
|
|
631
|
+
content: cleanContent,
|
|
632
|
+
provider: 'gemini',
|
|
633
|
+
model: 'g',
|
|
634
|
+
seed: 'r2b',
|
|
635
|
+
}),
|
|
636
|
+
}), { gitExec: git }));
|
|
637
|
+
const r2 = findLatestVerdictForLineage(tmpDir, lk, noWarn).artifact;
|
|
638
|
+
expect(r2.round.index).toBe(2);
|
|
639
|
+
expect(r2.settled).toBe(true);
|
|
640
|
+
});
|
|
641
|
+
it('two branches sharing base=main cannot cross-link (each starts at round 0)', async () => {
|
|
642
|
+
const cleanContent = wrapVerdict([]);
|
|
643
|
+
const mk = (branch, seed) => makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
644
|
+
'anthropic:claude-a': completedInvocation({
|
|
645
|
+
content: cleanContent,
|
|
646
|
+
provider: 'anthropic',
|
|
647
|
+
model: 'claude-a',
|
|
648
|
+
seed: `${seed}a`,
|
|
649
|
+
}),
|
|
650
|
+
'gemini:g': completedInvocation({
|
|
651
|
+
content: cleanContent,
|
|
652
|
+
provider: 'gemini',
|
|
653
|
+
model: 'g',
|
|
654
|
+
seed: `${seed}b`,
|
|
655
|
+
}),
|
|
656
|
+
}), { gitExec: fakeGit(branch, 'mainbase') });
|
|
657
|
+
await runReviewFan(mk('feature-a', 'fa'));
|
|
658
|
+
await runReviewFan(mk('feature-b', 'fb'));
|
|
659
|
+
const lkA = lineageKeyFor('feature-a', 'mainbase', 'branch-vs-base');
|
|
660
|
+
const lkB = lineageKeyFor('feature-b', 'mainbase', 'branch-vs-base');
|
|
661
|
+
expect(lkA).not.toBe(lkB);
|
|
662
|
+
// Neither branch's round advanced past 0 — no cross-link occurred.
|
|
663
|
+
expect(findLatestVerdictForLineage(tmpDir, lkA, noWarn).artifact.round.index).toBe(0);
|
|
664
|
+
expect(findLatestVerdictForLineage(tmpDir, lkB, noWarn).artifact.round.index).toBe(0);
|
|
665
|
+
});
|
|
666
|
+
it('malformed lane output ⇒ abstained ⇒ not settled; abstained lane gets its decidable post-check fail row (finding 8)', async () => {
|
|
667
|
+
const invoker = mapInvoker({
|
|
668
|
+
'anthropic:claude-a': completedInvocation({
|
|
669
|
+
content: 'garbage, not a verdict',
|
|
670
|
+
provider: 'anthropic',
|
|
671
|
+
model: 'claude-a',
|
|
672
|
+
seed: 'm',
|
|
673
|
+
}),
|
|
674
|
+
});
|
|
675
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a'], invoker);
|
|
676
|
+
// Default sensor exit 0: an abstaining fan writes the verdict and does NOT throw.
|
|
677
|
+
await expect(runReviewFan(ctx)).resolves.toBeUndefined();
|
|
678
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
679
|
+
expect(v.lanes[0].status).toBe('abstained');
|
|
680
|
+
expect(v.settled).toBe(false);
|
|
681
|
+
// Finding 8: the abstained lane's unextractable output persists a decidable
|
|
682
|
+
// structured-output 'fail' row (post-checks now cover abstained lanes too).
|
|
683
|
+
const row = v.postChecks.find((r) => r.ruleName === 'review-structured-verdict');
|
|
684
|
+
expect(row?.tier).toBe('decidable');
|
|
685
|
+
expect(row?.verdict).toBe('fail');
|
|
686
|
+
});
|
|
687
|
+
it('a sensor-tier post-check fail never gates: an otherwise-dry round still PASSes and settles', async () => {
|
|
688
|
+
// Both lanes clean output, but their run artifacts carry a non-canonical
|
|
689
|
+
// provenance class → the provenanceSensorRule (SENSOR) fails. It must not gate.
|
|
690
|
+
const cleanContent = wrapVerdict([]);
|
|
691
|
+
const invoker = mapInvoker({
|
|
692
|
+
'anthropic:claude-a': {
|
|
693
|
+
content: cleanContent,
|
|
694
|
+
runArtifactHash: hex('sa'),
|
|
695
|
+
runArtifact: makeRunArtifact({
|
|
696
|
+
content: cleanContent,
|
|
697
|
+
provider: 'anthropic',
|
|
698
|
+
model: 'claude-a',
|
|
699
|
+
badProvenance: true,
|
|
700
|
+
}),
|
|
701
|
+
},
|
|
702
|
+
'gemini:g': {
|
|
703
|
+
content: cleanContent,
|
|
704
|
+
runArtifactHash: hex('sb'),
|
|
705
|
+
runArtifact: makeRunArtifact({
|
|
706
|
+
content: cleanContent,
|
|
707
|
+
provider: 'gemini',
|
|
708
|
+
model: 'g',
|
|
709
|
+
badProvenance: true,
|
|
710
|
+
}),
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], invoker);
|
|
714
|
+
await runReviewFan(ctx); // must not throw
|
|
715
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
716
|
+
expect(v.settled).toBe(true);
|
|
717
|
+
// The sensor row IS recorded (honest) but did not gate.
|
|
718
|
+
const sensorRow = v.postChecks.find((r) => r.ruleName === 'provenance-fail-safe-down');
|
|
719
|
+
expect(sensorRow?.verdict).toBe('fail');
|
|
720
|
+
expect(sensorRow?.tier).toBe('sensor');
|
|
721
|
+
});
|
|
722
|
+
it('a single-lane fan is legal and writes a verdict (degenerate-diversity sensor, never a block)', async () => {
|
|
723
|
+
const invoker = mapInvoker({
|
|
724
|
+
'anthropic:claude-a': completedInvocation({
|
|
725
|
+
content: wrapVerdict([]),
|
|
726
|
+
provider: 'anthropic',
|
|
727
|
+
model: 'claude-a',
|
|
728
|
+
seed: 's1',
|
|
729
|
+
}),
|
|
730
|
+
});
|
|
731
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a'], invoker);
|
|
732
|
+
await runReviewFan(ctx); // clean 1-lane fan → PASS
|
|
733
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
734
|
+
expect(v.attemptedLaneCount).toBe(1);
|
|
735
|
+
expect(v.panelArtifactHash).toBeUndefined();
|
|
736
|
+
expect(v.settled).toBe(true);
|
|
737
|
+
});
|
|
738
|
+
it('ALL lanes failing WRITES the honest verdict FIRST, then hard-errors (Gate G3)', async () => {
|
|
739
|
+
const invoker = async () => {
|
|
740
|
+
throw new Error('socket hang up');
|
|
741
|
+
};
|
|
742
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], invoker);
|
|
743
|
+
await expect(runReviewFan(ctx)).rejects.toThrow(/All 2 review lane/);
|
|
744
|
+
// Gate G3: the honest verdict is written BEFORE the throw (all lanes failed).
|
|
745
|
+
const verdicts = listVerdictArtifacts(tmpDir, noWarn);
|
|
746
|
+
expect(verdicts).toHaveLength(1);
|
|
747
|
+
const v = verdicts[0].artifact;
|
|
748
|
+
expect(v.attemptedLaneCount).toBe(2);
|
|
749
|
+
expect(v.completedLaneCount).toBe(0);
|
|
750
|
+
expect(v.lanes.every((l) => l.status === 'failed')).toBe(true);
|
|
751
|
+
expect(v.settled).toBe(false);
|
|
752
|
+
expect(v.findings).toEqual([]);
|
|
753
|
+
});
|
|
754
|
+
it('--continues on a mismatched lineage warns but proceeds, recording the current lineage', async () => {
|
|
755
|
+
const clean = wrapVerdict([]);
|
|
756
|
+
// Seed a prior verdict on lineage A.
|
|
757
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
758
|
+
'anthropic:claude-a': completedInvocation({
|
|
759
|
+
content: clean,
|
|
760
|
+
provider: 'anthropic',
|
|
761
|
+
model: 'claude-a',
|
|
762
|
+
seed: 'ca',
|
|
763
|
+
}),
|
|
764
|
+
'gemini:g': completedInvocation({
|
|
765
|
+
content: clean,
|
|
766
|
+
provider: 'gemini',
|
|
767
|
+
model: 'g',
|
|
768
|
+
seed: 'cb',
|
|
769
|
+
}),
|
|
770
|
+
}), { gitExec: fakeGit('feature-a', 'baseA') }));
|
|
771
|
+
const prior = listVerdictArtifacts(tmpDir, noWarn)[0];
|
|
772
|
+
// The STORED, verified address is the continues target (rev-6 item 1).
|
|
773
|
+
const priorContentHash = prior.contentHash;
|
|
774
|
+
// Continue it from a DIFFERENT branch/lineage.
|
|
775
|
+
const errSpy = vi.spyOn(console, 'error');
|
|
776
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
777
|
+
'anthropic:claude-a': completedInvocation({
|
|
778
|
+
content: clean,
|
|
779
|
+
provider: 'anthropic',
|
|
780
|
+
model: 'claude-a',
|
|
781
|
+
seed: 'cc',
|
|
782
|
+
}),
|
|
783
|
+
'gemini:g': completedInvocation({
|
|
784
|
+
content: clean,
|
|
785
|
+
provider: 'gemini',
|
|
786
|
+
model: 'g',
|
|
787
|
+
seed: 'cd',
|
|
788
|
+
}),
|
|
789
|
+
}), { gitExec: fakeGit('feature-b', 'baseB'), continues: priorContentHash }));
|
|
790
|
+
const lkB = lineageKeyFor('feature-b', 'baseB', 'branch-vs-base');
|
|
791
|
+
const continued = findLatestVerdictForLineage(tmpDir, lkB, noWarn).artifact;
|
|
792
|
+
// Linked as prior + 1, but recorded under the CURRENT (feature-b) lineage.
|
|
793
|
+
expect(continued.round.index).toBe(prior.artifact.round.index + 1);
|
|
794
|
+
expect(continued.round.priorVerdictHash).toBe(priorContentHash);
|
|
795
|
+
expect(continued.round.lineageKey).toBe(lkB);
|
|
796
|
+
expect(errSpy.mock.calls.map((c) => c.join(' ')).join('\n')).toMatch(/DIFFERENT lineage/);
|
|
797
|
+
});
|
|
798
|
+
it("tree mutation DURING post-check/panel/lineage ⇒ reviewedState='drifted', settled=false, NO cache stamp (findings 6, gate 1)", async () => {
|
|
799
|
+
// Both lanes complete cleanly (zero findings, no decidable fail) — the fan
|
|
800
|
+
// would otherwise settle. But the tracked-source tree mutates during the fan:
|
|
801
|
+
// the post-fan content hash (sampled AFTER the post-check/panel/lineage work in
|
|
802
|
+
// the real critical section — finding 6) differs from the pre-fan hash.
|
|
803
|
+
const clean = wrapVerdict([]);
|
|
804
|
+
const invoker = mapInvoker({
|
|
805
|
+
'anthropic:claude-a': completedInvocation({
|
|
806
|
+
content: clean,
|
|
807
|
+
provider: 'anthropic',
|
|
808
|
+
model: 'claude-a',
|
|
809
|
+
seed: 'd1a',
|
|
810
|
+
}),
|
|
811
|
+
'gemini:g': completedInvocation({
|
|
812
|
+
content: clean,
|
|
813
|
+
provider: 'gemini',
|
|
814
|
+
model: 'g',
|
|
815
|
+
seed: 'd1b',
|
|
816
|
+
}),
|
|
817
|
+
});
|
|
818
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], invoker, {
|
|
819
|
+
preFanContentHash: 'pre-hash-abc',
|
|
820
|
+
contentHash: async () => 'post-hash-different',
|
|
821
|
+
});
|
|
822
|
+
// DEFAULT sensor exit 0: drift does NOT throw (no --fail-on); it is loud + un-stamped.
|
|
823
|
+
await expect(runReviewFan(ctx)).resolves.toBeUndefined();
|
|
824
|
+
// The verdict IS written (bound to the pre-fan diff), honestly marked drifted.
|
|
825
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
826
|
+
expect(v.reviewedState).toBe('drifted');
|
|
827
|
+
expect(v.settled).toBe(false);
|
|
828
|
+
expect(v.completedLaneCount).toBe(2); // otherwise dry
|
|
829
|
+
expect(v.findings).toEqual([]);
|
|
830
|
+
// No stamp authorizes the changed content.
|
|
831
|
+
expect(fs.existsSync(path.join(tmpDir, '.totem', 'cache', '.reviewed-content-hash'))).toBe(false);
|
|
832
|
+
});
|
|
833
|
+
it('diffHash binds the delivered truncated masked <git_diff> segment; recomputes from the persisted masked prompt; truncated-away secret influences nothing (codex rev-2 gate 4)', async () => {
|
|
834
|
+
// A diff exceeding MAX_DIFF_CHARS with a secret planted BEYOND the truncation
|
|
835
|
+
// boundary — so the delivered `<git_diff>` segment never contains it.
|
|
836
|
+
const SECRET = 'sk-' + 'z'.repeat(40); // matches a built-in DLP pattern
|
|
837
|
+
const head = 'a'.repeat(MAX_DIFF_CHARS + 200);
|
|
838
|
+
const fullDiff = `${head}\nLEAKED=${SECRET}\n`;
|
|
839
|
+
// Emulate assemblePrompt: truncate at MAX_DIFF_CHARS + marker, wrap in <git_diff>.
|
|
840
|
+
const truncated = fullDiff.slice(0, MAX_DIFF_CHARS) + `\n... [diff truncated at ${MAX_DIFF_CHARS} chars] ...`;
|
|
841
|
+
const prompt = `SYSTEM PROMPT\n=== DIFF ===\n<git_diff>\n${truncated}\n</git_diff>\nEND`;
|
|
842
|
+
let capturedPrompt = '';
|
|
843
|
+
const invoker = async (_laneModel, deliveredPrompt) => {
|
|
844
|
+
capturedPrompt = deliveredPrompt;
|
|
845
|
+
const content = wrapVerdict([]);
|
|
846
|
+
return {
|
|
847
|
+
content,
|
|
848
|
+
runArtifactHash: hex('g4'),
|
|
849
|
+
// The persisted run artifact records EXACTLY the delivered masked prompt.
|
|
850
|
+
runArtifact: {
|
|
851
|
+
...makeRunArtifact({ content, provider: 'anthropic', model: 'claude-a' }),
|
|
852
|
+
inputBundle: { maskedPrompt: deliveredPrompt },
|
|
853
|
+
},
|
|
854
|
+
};
|
|
855
|
+
};
|
|
856
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a'], invoker, {
|
|
857
|
+
prompt,
|
|
858
|
+
filteredDiff: fullDiff,
|
|
859
|
+
});
|
|
860
|
+
await runReviewFan(ctx);
|
|
861
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
862
|
+
// Extract the <git_diff> segment from the persisted (delivered) masked prompt.
|
|
863
|
+
const seg = capturedPrompt.match(/<git_diff>\n([\s\S]*?)\n<\/git_diff>/)[1];
|
|
864
|
+
expect(createHash('sha256').update(seg, 'utf-8').digest('hex')).toBe(v.diffScope.diffHash);
|
|
865
|
+
// The truncated-away secret never entered the delivered payload nor the hash.
|
|
866
|
+
expect(seg).not.toContain(SECRET);
|
|
867
|
+
expect(capturedPrompt).not.toContain(SECRET);
|
|
868
|
+
});
|
|
869
|
+
// ── Exit contract (finding 3 / Gate G5) ──
|
|
870
|
+
const twoLaneInvoker = (aContent, seed) => mapInvoker({
|
|
871
|
+
'anthropic:claude-a': completedInvocation({
|
|
872
|
+
content: aContent,
|
|
873
|
+
provider: 'anthropic',
|
|
874
|
+
model: 'claude-a',
|
|
875
|
+
seed: `${seed}a`,
|
|
876
|
+
}),
|
|
877
|
+
'gemini:g': completedInvocation({
|
|
878
|
+
content: wrapVerdict([]),
|
|
879
|
+
provider: 'gemini',
|
|
880
|
+
model: 'g',
|
|
881
|
+
seed: `${seed}b`,
|
|
882
|
+
}),
|
|
883
|
+
});
|
|
884
|
+
const CRITICAL_CONTENT = wrapVerdict([
|
|
885
|
+
{ severity: 'CRITICAL', confidence: 0.9, message: 'boom-critical-finding' },
|
|
886
|
+
]);
|
|
887
|
+
const WARN_CONTENT = wrapVerdict([
|
|
888
|
+
{ severity: 'WARN', confidence: 0.6, message: 'a-warn-finding' },
|
|
889
|
+
]);
|
|
890
|
+
it('DEFAULT sensor exit 0 even with CRITICAL findings present (finding 3 / OQ-2 re-rule)', async () => {
|
|
891
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(CRITICAL_CONTENT, 'sd'));
|
|
892
|
+
await expect(runReviewFan(ctx)).resolves.toBeUndefined();
|
|
893
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
894
|
+
expect(v.findings.some((f) => f.severity === 'CRITICAL')).toBe(true);
|
|
895
|
+
expect(v.settled).toBe(false);
|
|
896
|
+
});
|
|
897
|
+
it('--fail-on critical throws on a CRITICAL round but NOT on a WARN-only round', async () => {
|
|
898
|
+
await expect(runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(CRITICAL_CONTENT, 'fc'), {
|
|
899
|
+
options: { failOn: 'critical' },
|
|
900
|
+
}))).rejects.toThrow(/CRITICAL/);
|
|
901
|
+
// A WARN-only round is cache-eligible ⇒ --fail-on critical does NOT trip.
|
|
902
|
+
await expect(runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(WARN_CONTENT, 'fcw'), {
|
|
903
|
+
options: { failOn: 'critical' },
|
|
904
|
+
}))).resolves.toBeUndefined();
|
|
905
|
+
});
|
|
906
|
+
it('--fail-on warn throws on a WARN-only round', async () => {
|
|
907
|
+
await expect(runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(WARN_CONTENT, 'fw'), {
|
|
908
|
+
options: { failOn: 'warn' },
|
|
909
|
+
}))).rejects.toThrow(/WARN/);
|
|
910
|
+
});
|
|
911
|
+
it('--override converts a --fail-on failure to pass AND ledgers the trap-ledgered stamp (matched tree)', async () => {
|
|
912
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(CRITICAL_CONTENT, 'ov'), {
|
|
913
|
+
options: {
|
|
914
|
+
failOn: 'critical',
|
|
915
|
+
override: 'operator-accepted false positive on the boom finding',
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
// Converted to a pass — no throw.
|
|
919
|
+
await expect(runReviewFan(ctx)).resolves.toBeUndefined();
|
|
920
|
+
// The override is trap-ledgered (routed through recordShieldOverride).
|
|
921
|
+
const events = readLedgerEvents(path.join(tmpDir, '.totem'));
|
|
922
|
+
expect(events.some((e) => e.type === 'override' && e.ruleId === 'shield-override')).toBe(true);
|
|
923
|
+
});
|
|
924
|
+
it('drift + --override NEVER stamps — a drifted tree is never stampable, even overridden', async () => {
|
|
925
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(wrapVerdict([]), 'dov'), {
|
|
926
|
+
preFanContentHash: 'pre-hash-xyz',
|
|
927
|
+
contentHash: async () => 'post-hash-drifted',
|
|
928
|
+
options: { override: 'operator override on a drifted tree — must still refuse the stamp' },
|
|
929
|
+
});
|
|
930
|
+
await expect(runReviewFan(ctx)).resolves.toBeUndefined();
|
|
931
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
932
|
+
expect(v.reviewedState).toBe('drifted');
|
|
933
|
+
// No stamp authorizes the changed content — even under --override.
|
|
934
|
+
expect(fs.existsSync(path.join(tmpDir, '.totem', 'cache', '.reviewed-content-hash'))).toBe(false);
|
|
935
|
+
});
|
|
936
|
+
// ── rev-5 item 1 (codex critical falsifier): --override must never stamp an unreviewed tree ──
|
|
937
|
+
it('FALSIFIER: tree mutates AFTER the fan compare but BEFORE override stamping ⇒ ledgered override, NO stamp (rev-5 item 1)', async () => {
|
|
938
|
+
// The fan's one compare sees the pre-fan hash (matched); the tree then mutates
|
|
939
|
+
// before the override stamp. The ledger+explicit-hash primitive recomputes the
|
|
940
|
+
// current hash immediately adjacent to the stamp write and must refuse — the
|
|
941
|
+
// current (mutated, unreviewed) tree hash is never stamped, and neither is the
|
|
942
|
+
// pre-fan hash (it no longer describes the tree).
|
|
943
|
+
const hashes = ['pre-fan-hash', 'post-compare-mutated-hash'];
|
|
944
|
+
let calls = 0;
|
|
945
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(CRITICAL_CONTENT, 'f1'), {
|
|
946
|
+
preFanContentHash: 'pre-fan-hash',
|
|
947
|
+
contentHash: async () => hashes[Math.min(calls++, hashes.length - 1)],
|
|
948
|
+
options: { override: 'operator-accepted false positive — but the tree moved' },
|
|
949
|
+
});
|
|
950
|
+
const errSpy = vi.spyOn(console, 'error');
|
|
951
|
+
await expect(runReviewFan(ctx)).resolves.toBeUndefined();
|
|
952
|
+
// The fan compare saw 'pre-fan-hash' ⇒ matched; the adjacent recompute saw the mutation.
|
|
953
|
+
expect(calls).toBe(2);
|
|
954
|
+
const v = listVerdictArtifacts(tmpDir, noWarn)[0].artifact;
|
|
955
|
+
expect(v.reviewedState).toBe('matched');
|
|
956
|
+
// The override IS trap-ledgered (the operator's justification is auditable)…
|
|
957
|
+
const events = readLedgerEvents(path.join(tmpDir, '.totem'));
|
|
958
|
+
expect(events.some((e) => e.type === 'override' && e.ruleId === 'shield-override')).toBe(true);
|
|
959
|
+
// …but NOTHING was stamped: not the pre-fan hash, and never the current tree hash.
|
|
960
|
+
expect(fs.existsSync(path.join(tmpDir, '.totem', 'cache', '.reviewed-content-hash'))).toBe(false);
|
|
961
|
+
// The refusal is loud.
|
|
962
|
+
const out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
963
|
+
expect(out).toMatch(/OVERRIDE STAMP REFUSED/);
|
|
964
|
+
});
|
|
965
|
+
it('override stamp binds EXACTLY the pre-fan hash when the adjacent recompute still matches (rev-5 item 1 positive)', async () => {
|
|
966
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(CRITICAL_CONTENT, 'f2'), {
|
|
967
|
+
preFanContentHash: 'pre-fan-stable-hash',
|
|
968
|
+
contentHash: async () => 'pre-fan-stable-hash',
|
|
969
|
+
options: { override: 'operator-accepted false positive on a stable tree' },
|
|
970
|
+
});
|
|
971
|
+
await expect(runReviewFan(ctx)).resolves.toBeUndefined();
|
|
972
|
+
const stampPath = path.join(tmpDir, '.totem', 'cache', '.reviewed-content-hash');
|
|
973
|
+
expect(fs.readFileSync(stampPath, 'utf-8')).toBe('pre-fan-stable-hash');
|
|
974
|
+
});
|
|
975
|
+
// ── rev-5 item 2: the stamp decision precedes ALL render/report I/O ──
|
|
976
|
+
it('the stamp lands BEFORE the findings render / covariate line / --out report; render-time mutation cannot affect it (rev-5 item 2)', async () => {
|
|
977
|
+
const outPath = path.join(tmpDir, 'fan-report.txt');
|
|
978
|
+
const stampPath = path.join(tmpDir, '.totem', 'cache', '.reviewed-content-hash');
|
|
979
|
+
let contentHashCalls = 0;
|
|
980
|
+
let stampExistedAtFindingsRender;
|
|
981
|
+
let stampExistedAtCovariateRender;
|
|
982
|
+
let stampExistedAtOutWrite;
|
|
983
|
+
// Observe the stamp file's state AT the moment each render side effect fires
|
|
984
|
+
// (all render/log output goes through console.error). The `--out` write is bracketed
|
|
985
|
+
// by its success log ("Fan report written"), which fires immediately AFTER writeOutput
|
|
986
|
+
// — so reordering the --out write BEFORE the stamp would observe a missing stamp here
|
|
987
|
+
// (item 6: the ordering assertion now covers writeOutput, not just findings/covariate).
|
|
988
|
+
vi.mocked(console.error).mockImplementation((...args) => {
|
|
989
|
+
const line = args.map(String).join(' ');
|
|
990
|
+
if (line.includes('Review fan —'))
|
|
991
|
+
stampExistedAtFindingsRender = fs.existsSync(stampPath);
|
|
992
|
+
if (line.includes('local-lane:'))
|
|
993
|
+
stampExistedAtCovariateRender = fs.existsSync(stampPath);
|
|
994
|
+
if (line.includes('Fan report written'))
|
|
995
|
+
stampExistedAtOutWrite = fs.existsSync(stampPath);
|
|
996
|
+
});
|
|
997
|
+
const ctx = makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(wrapVerdict([]), 'ord'), {
|
|
998
|
+
preFanContentHash: 'pre-hash-ordering',
|
|
999
|
+
contentHash: async () => {
|
|
1000
|
+
contentHashCalls += 1;
|
|
1001
|
+
return 'pre-hash-ordering';
|
|
1002
|
+
},
|
|
1003
|
+
options: { out: outPath },
|
|
1004
|
+
});
|
|
1005
|
+
await runReviewFan(ctx);
|
|
1006
|
+
// The single fan compare is the ONLY tree read on the ordinary path — no re-hash
|
|
1007
|
+
// during render/report I/O, so a mutation there cannot influence the stamp.
|
|
1008
|
+
expect(contentHashCalls).toBe(1);
|
|
1009
|
+
// The stamp decision was already durable when the findings render, the covariate
|
|
1010
|
+
// line, and the --out report happened.
|
|
1011
|
+
expect(stampExistedAtFindingsRender).toBe(true);
|
|
1012
|
+
expect(stampExistedAtCovariateRender).toBe(true);
|
|
1013
|
+
// item 6: the stamp was durable at the moment the --out report was written, too —
|
|
1014
|
+
// reordering writeOutput before the stamp would flip this to false.
|
|
1015
|
+
expect(stampExistedAtOutWrite).toBe(true);
|
|
1016
|
+
expect(fs.readFileSync(stampPath, 'utf-8')).toBe('pre-hash-ordering');
|
|
1017
|
+
expect(fs.existsSync(outPath)).toBe(true);
|
|
1018
|
+
});
|
|
1019
|
+
// ── Findings render (finding 2) ──
|
|
1020
|
+
it('a WARN round and a CRITICAL round both render the actual finding MESSAGES to output (finding 2)', async () => {
|
|
1021
|
+
const errSpy = vi.spyOn(console, 'error');
|
|
1022
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(WARN_CONTENT, 'rw')));
|
|
1023
|
+
let out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
1024
|
+
expect(out).toContain('a-warn-finding');
|
|
1025
|
+
errSpy.mockClear();
|
|
1026
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(CRITICAL_CONTENT, 'rc')));
|
|
1027
|
+
out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
1028
|
+
expect(out).toContain('boom-critical-finding');
|
|
1029
|
+
});
|
|
1030
|
+
it('--out writes the human-readable fan report (findings + lanes + covariate line)', async () => {
|
|
1031
|
+
const outPath = path.join(tmpDir, 'fan-report.txt');
|
|
1032
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(WARN_CONTENT, 'out'), {
|
|
1033
|
+
options: { out: outPath },
|
|
1034
|
+
}));
|
|
1035
|
+
const report = fs.readFileSync(outPath, 'utf-8');
|
|
1036
|
+
expect(report).toContain('a-warn-finding');
|
|
1037
|
+
expect(report).toContain('lane-0:anthropic:claude-a');
|
|
1038
|
+
expect(report).toContain('local-lane:');
|
|
1039
|
+
});
|
|
1040
|
+
// ── Parallel determinism (finding 13) ──
|
|
1041
|
+
it('lanes completing OUT OF ORDER yield an artifact identical to in-order completion (finding 13)', async () => {
|
|
1042
|
+
const clean = wrapVerdict([]);
|
|
1043
|
+
const laneA = () => completedInvocation({ content: clean, provider: 'anthropic', model: 'claude-a', seed: 'pa' });
|
|
1044
|
+
const laneB = () => completedInvocation({ content: clean, provider: 'gemini', model: 'g', seed: 'pb' });
|
|
1045
|
+
const delayed = (inv, ms) => () => new Promise((resolve) => setTimeout(() => resolve(inv), ms));
|
|
1046
|
+
const dir1 = fs.mkdtempSync(path.join(os.tmpdir(), 'fan-order1-'));
|
|
1047
|
+
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'fan-order2-'));
|
|
1048
|
+
try {
|
|
1049
|
+
// Run 1: lane A slow, lane B fast (B completes first).
|
|
1050
|
+
await runReviewFan(makeCtx(dir1, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
1051
|
+
'anthropic:claude-a': delayed(laneA(), 25),
|
|
1052
|
+
'gemini:g': delayed(laneB(), 1),
|
|
1053
|
+
})));
|
|
1054
|
+
// Run 2: lane A fast, lane B slow (A completes first).
|
|
1055
|
+
await runReviewFan(makeCtx(dir2, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
1056
|
+
'anthropic:claude-a': delayed(laneA(), 1),
|
|
1057
|
+
'gemini:g': delayed(laneB(), 25),
|
|
1058
|
+
})));
|
|
1059
|
+
const v1 = listVerdictArtifacts(dir1, noWarn)[0];
|
|
1060
|
+
const v2 = listVerdictArtifacts(dir2, noWarn)[0];
|
|
1061
|
+
// Content hash excludes createdAt → identical regardless of completion order. The
|
|
1062
|
+
// STORED addresses (verified filename stems) are equal for the same logical round.
|
|
1063
|
+
expect(v1.contentHash).toBe(v2.contentHash);
|
|
1064
|
+
expect(computeVerdictArtifactContentHash(v1.artifact)).toBe(computeVerdictArtifactContentHash(v2.artifact));
|
|
1065
|
+
// Lanes are canonicalized into configured order in both.
|
|
1066
|
+
expect(v1.artifact.lanes.map((l) => l.laneId)).toEqual([
|
|
1067
|
+
'lane-0:anthropic:claude-a',
|
|
1068
|
+
'lane-1:gemini:g',
|
|
1069
|
+
]);
|
|
1070
|
+
expect(v2.artifact.lanes.map((l) => l.laneId)).toEqual(v1.artifact.lanes.map((l) => l.laneId));
|
|
1071
|
+
}
|
|
1072
|
+
finally {
|
|
1073
|
+
cleanTmpDir(dir1);
|
|
1074
|
+
cleanTmpDir(dir2);
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
it('review.lanes absent ⇒ no fan surface: [] from the validator, and an empty fan writes no verdict / no local-lane line (codex rev-2 gate 5/7)', async () => {
|
|
1078
|
+
// Production gate: absent lanes normalize to [] → shieldCommand's fanActive is
|
|
1079
|
+
// false → the legacy single-lane path runs unchanged (findings/display/exit/cache).
|
|
1080
|
+
expect(validateReviewLanes(undefined, 'anthropic', TotemConfigError)).toEqual([]);
|
|
1081
|
+
// And the fan — the SOLE emitter of the verdict artifact + additive `local-lane:`
|
|
1082
|
+
// line — produces neither when there are no lanes to converge.
|
|
1083
|
+
await expect(runReviewFan(makeCtx(tmpDir, [], mapInvoker({})))).rejects.toThrow(/All 0 review lane/);
|
|
1084
|
+
expect(listVerdictArtifacts(tmpDir, noWarn)).toHaveLength(0);
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
// ─── printCovariateLine (rev-5 item 4 — executable covariate transport) ────────
|
|
1088
|
+
describe('printCovariateLine (rev-5 item 4)', () => {
|
|
1089
|
+
let tmpDir;
|
|
1090
|
+
beforeEach(() => {
|
|
1091
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'covariate-'));
|
|
1092
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
1093
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
1094
|
+
});
|
|
1095
|
+
afterEach(() => {
|
|
1096
|
+
vi.restoreAllMocks();
|
|
1097
|
+
cleanTmpDir(tmpDir);
|
|
1098
|
+
});
|
|
1099
|
+
const cleanTwoLaneInvoker = () => mapInvoker({
|
|
1100
|
+
'anthropic:claude-a': completedInvocation({
|
|
1101
|
+
content: wrapVerdict([]),
|
|
1102
|
+
provider: 'anthropic',
|
|
1103
|
+
model: 'claude-a',
|
|
1104
|
+
seed: 'cva',
|
|
1105
|
+
}),
|
|
1106
|
+
'gemini:g': completedInvocation({
|
|
1107
|
+
content: wrapVerdict([]),
|
|
1108
|
+
provider: 'gemini',
|
|
1109
|
+
model: 'g',
|
|
1110
|
+
seed: 'cvb',
|
|
1111
|
+
}),
|
|
1112
|
+
});
|
|
1113
|
+
it('prints the EXACT core-owned covariate line for the current lineage on stdout (mechanical)', async () => {
|
|
1114
|
+
const git = fakeGit('feature-cov', 'covbase');
|
|
1115
|
+
// Write a verdict via a real fan run (same lineage resolution the covariate uses).
|
|
1116
|
+
await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], cleanTwoLaneInvoker(), {
|
|
1117
|
+
gitExec: git,
|
|
1118
|
+
}));
|
|
1119
|
+
const verdict = listVerdictArtifacts(tmpDir, noWarn)[0];
|
|
1120
|
+
const logSpy = vi.mocked(console.log);
|
|
1121
|
+
logSpy.mockClear();
|
|
1122
|
+
await printCovariateLine({
|
|
1123
|
+
diffMeta: { source: 'branch-vs-base', base: 'main' },
|
|
1124
|
+
totemDirAbs: tmpDir,
|
|
1125
|
+
cwd: tmpDir,
|
|
1126
|
+
gitExec: git,
|
|
1127
|
+
});
|
|
1128
|
+
// EXACTLY the core renderer's line, on stdout — format v1, byte-for-byte.
|
|
1129
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
1130
|
+
expect(logSpy).toHaveBeenCalledWith(renderCovariateLine(verdict));
|
|
1131
|
+
const line = String(logSpy.mock.calls[0][0]);
|
|
1132
|
+
expect(line).toMatch(/^local-lane: [0-9a-f]{8} round=0 settled=true lanes=2\/2$/);
|
|
1133
|
+
});
|
|
1134
|
+
it('resolves the LATEST verdict for the lineage (round chain respected)', async () => {
|
|
1135
|
+
const git = fakeGit('feature-cov', 'covbase');
|
|
1136
|
+
const mk = (seed) => makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], mapInvoker({
|
|
1137
|
+
'anthropic:claude-a': completedInvocation({
|
|
1138
|
+
content: wrapVerdict([]),
|
|
1139
|
+
provider: 'anthropic',
|
|
1140
|
+
model: 'claude-a',
|
|
1141
|
+
seed: `${seed}a`,
|
|
1142
|
+
}),
|
|
1143
|
+
'gemini:g': completedInvocation({
|
|
1144
|
+
content: wrapVerdict([]),
|
|
1145
|
+
provider: 'gemini',
|
|
1146
|
+
model: 'g',
|
|
1147
|
+
seed: `${seed}b`,
|
|
1148
|
+
}),
|
|
1149
|
+
}), { gitExec: git });
|
|
1150
|
+
await runReviewFan(mk('r0'));
|
|
1151
|
+
await runReviewFan(mk('r1'));
|
|
1152
|
+
const logSpy = vi.mocked(console.log);
|
|
1153
|
+
logSpy.mockClear();
|
|
1154
|
+
await printCovariateLine({
|
|
1155
|
+
diffMeta: { source: 'branch-vs-base', base: 'main' },
|
|
1156
|
+
totemDirAbs: tmpDir,
|
|
1157
|
+
cwd: tmpDir,
|
|
1158
|
+
gitExec: git,
|
|
1159
|
+
});
|
|
1160
|
+
expect(String(logSpy.mock.calls[0][0])).toContain('round=1');
|
|
1161
|
+
});
|
|
1162
|
+
it('no verdict for the lineage ⇒ loud sensor message, NO stdout line, clean return (exit 0)', async () => {
|
|
1163
|
+
const errSpy = vi.mocked(console.error);
|
|
1164
|
+
const logSpy = vi.mocked(console.log);
|
|
1165
|
+
await expect(printCovariateLine({
|
|
1166
|
+
diffMeta: { source: 'branch-vs-base', base: 'main' },
|
|
1167
|
+
totemDirAbs: tmpDir,
|
|
1168
|
+
cwd: tmpDir,
|
|
1169
|
+
gitExec: fakeGit('feature-none', 'nobase'),
|
|
1170
|
+
})).resolves.toBeUndefined();
|
|
1171
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
1172
|
+
const out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
1173
|
+
expect(out).toMatch(/no verdict artifact recorded for the current lineage/i);
|
|
1174
|
+
});
|
|
1175
|
+
it('no diff scope (diffMeta null) ⇒ loud sensor message, NO stdout line, clean return', async () => {
|
|
1176
|
+
const errSpy = vi.mocked(console.error);
|
|
1177
|
+
const logSpy = vi.mocked(console.log);
|
|
1178
|
+
await expect(printCovariateLine({ diffMeta: null, totemDirAbs: tmpDir, cwd: tmpDir })).resolves.toBeUndefined();
|
|
1179
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
1180
|
+
const out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
1181
|
+
expect(out).toMatch(/no diff detected/i);
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
//# sourceMappingURL=review-fan.test.js.map
|