@rigour-labs/core 6.6.5 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -92
- package/dist/environment.test.js +0 -4
- package/dist/gates/agent-team.d.ts +0 -2
- package/dist/gates/agent-team.js +0 -8
- package/dist/gates/checkpoint.d.ts +0 -1
- package/dist/gates/checkpoint.js +0 -1
- package/dist/gates/context.d.ts +0 -4
- package/dist/gates/context.js +7 -17
- package/dist/gates/deep-analysis.d.ts +0 -1
- package/dist/gates/deep-runner.js +0 -1
- package/dist/gates/frontend-secret-exposure.d.ts +1 -1
- package/dist/gates/frontend-secret-exposure.js +9 -3
- package/dist/gates/frontend-secret-exposure.test.js +6 -3
- package/dist/gates/hallucinated-imports/index.d.ts +0 -2
- package/dist/gates/hallucinated-imports/index.js +2 -2
- package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
- package/dist/gates/hallucinated-imports.test.js +10 -0
- package/dist/gates/runner.js +3 -4
- package/dist/gates/test-quality-matchers.d.ts +0 -4
- package/dist/gates/test-quality-matchers.js +0 -7
- package/dist/gates/test-quality.d.ts +0 -2
- package/dist/gates/test-quality.js +1 -10
- package/dist/hooks/checker.js +1 -1
- package/dist/hooks/index.d.ts +2 -3
- package/dist/hooks/index.js +1 -2
- package/dist/hooks/stop-review.d.ts +6 -9
- package/dist/hooks/stop-review.js +5 -10
- package/dist/hooks/types.d.ts +0 -14
- package/dist/hooks/types.js +1 -15
- package/dist/index.d.ts +11 -3
- package/dist/index.js +11 -3
- package/dist/inference/cloud-provider.d.ts +2 -0
- package/dist/inference/cloud-provider.js +16 -6
- package/dist/inference/cloud-provider.test.js +21 -0
- package/dist/pattern-index/embeddings.d.ts +0 -5
- package/dist/pattern-index/embeddings.js +11 -4
- package/dist/pattern-index/semantic-runtime.d.ts +10 -0
- package/dist/pattern-index/semantic-runtime.js +46 -0
- package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
- package/dist/pattern-index/semantic-runtime.test.js +31 -0
- package/dist/review/backtest-init.d.ts +8 -0
- package/dist/review/backtest-init.js +87 -0
- package/dist/review/backtest-init.test.d.ts +1 -0
- package/dist/review/backtest-init.test.js +74 -0
- package/dist/review/backtest-judges.d.ts +38 -0
- package/dist/review/backtest-judges.js +63 -0
- package/dist/review/backtest-judges.test.d.ts +1 -0
- package/dist/review/backtest-judges.test.js +32 -0
- package/dist/review/backtest.d.ts +320 -0
- package/dist/review/backtest.js +207 -0
- package/dist/review/backtest.test.d.ts +1 -0
- package/dist/review/backtest.test.js +129 -0
- package/dist/review/baseline.js +2 -1
- package/dist/review/dead-code.test.js +15 -0
- package/dist/review/fix-scope.d.ts +21 -0
- package/dist/review/fix-scope.js +94 -0
- package/dist/review/fix-scope.test.d.ts +1 -0
- package/dist/review/fix-scope.test.js +104 -0
- package/dist/review/lint-overlay.d.ts +21 -0
- package/dist/review/lint-overlay.js +89 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/partial-wiring.js +5 -3
- package/dist/review/query-patterns.js +3 -2
- package/dist/review/quiet.d.ts +6 -0
- package/dist/review/quiet.js +12 -2
- package/dist/review/quiet.test.js +11 -0
- package/dist/review/review.d.ts +6 -0
- package/dist/review/review.js +10 -2
- package/dist/review/reviewer/adapters.d.ts +51 -0
- package/dist/review/reviewer/adapters.js +181 -0
- package/dist/review/reviewer/adapters.test.d.ts +1 -0
- package/dist/review/reviewer/adapters.test.js +23 -0
- package/dist/review/reviewer/background.d.ts +41 -0
- package/dist/review/reviewer/background.js +153 -0
- package/dist/review/reviewer/background.test.d.ts +1 -0
- package/dist/review/reviewer/background.test.js +118 -0
- package/dist/review/reviewer/consensus.d.ts +36 -0
- package/dist/review/reviewer/consensus.js +144 -0
- package/dist/review/reviewer/context.d.ts +75 -0
- package/dist/review/reviewer/context.js +140 -0
- package/dist/review/reviewer/context.test.d.ts +1 -0
- package/dist/review/reviewer/context.test.js +18 -0
- package/dist/review/reviewer/exec.d.ts +17 -0
- package/dist/review/reviewer/exec.js +27 -0
- package/dist/review/reviewer/exec.test.d.ts +1 -0
- package/dist/review/reviewer/exec.test.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +39 -0
- package/dist/review/reviewer/inputs.js +107 -0
- package/dist/review/reviewer/merge-impact.d.ts +2 -0
- package/dist/review/reviewer/merge-impact.js +64 -0
- package/dist/review/reviewer/panel.d.ts +67 -0
- package/dist/review/reviewer/panel.js +101 -0
- package/dist/review/reviewer/panel.test.d.ts +1 -0
- package/dist/review/reviewer/panel.test.js +110 -0
- package/dist/review/reviewer/prompt.d.ts +32 -0
- package/dist/review/reviewer/prompt.js +145 -0
- package/dist/review/reviewer/settings.d.ts +58 -0
- package/dist/review/reviewer/settings.js +152 -0
- package/dist/review/reviewer/settings.test.d.ts +1 -0
- package/dist/review/reviewer/settings.test.js +57 -0
- package/dist/review/reviewer/store.d.ts +49 -0
- package/dist/review/reviewer/store.js +99 -0
- package/dist/review/reviewer/usage.d.ts +2 -0
- package/dist/review/reviewer/usage.js +28 -0
- package/dist/review/reviewer/usage.test.d.ts +1 -0
- package/dist/review/reviewer/usage.test.js +14 -0
- package/dist/review/reviewer/verdict.d.ts +141 -0
- package/dist/review/reviewer/verdict.js +166 -0
- package/dist/review/reviewer.d.ts +84 -49
- package/dist/review/reviewer.js +364 -195
- package/dist/review/reviewer.test.js +316 -50
- package/dist/review/test-files.d.ts +5 -1
- package/dist/review/test-files.js +6 -2
- package/dist/review/toolchain.d.ts +5 -2
- package/dist/review/toolchain.js +89 -7
- package/dist/review/toolchain.test.js +45 -4
- package/dist/review/typed/program.d.ts +26 -0
- package/dist/review/typed/program.js +90 -0
- package/dist/review/typed/redundancy.d.ts +10 -0
- package/dist/review/typed/redundancy.js +378 -0
- package/dist/review/typed/redundancy.test.d.ts +1 -0
- package/dist/review/typed/redundancy.test.js +179 -0
- package/dist/review/typed/schema-nullability.d.ts +6 -0
- package/dist/review/typed/schema-nullability.js +218 -0
- package/dist/review/typed/schema-nullability.test.d.ts +1 -0
- package/dist/review/typed/schema-nullability.test.js +63 -0
- package/dist/review/unused-exports.js +10 -7
- package/dist/semantic/learn/types.d.ts +2 -2
- package/dist/services/adaptive-thresholds.d.ts +2 -52
- package/dist/services/adaptive-thresholds.js +2 -147
- package/dist/services/adaptive-thresholds.test.js +1 -81
- package/dist/settings.d.ts +14 -6
- package/dist/settings.js +0 -12
- package/dist/storage/team-diagnostics.js +1 -1
- package/dist/storage/team-schema.d.ts +1 -1
- package/dist/storage/team-schema.js +2 -0
- package/dist/storage/team-store.js +26 -3
- package/dist/storage/team-sync-scope.test.js +35 -2
- package/dist/telemetry/telemetry.d.ts +5 -0
- package/dist/telemetry/telemetry.js +11 -1
- package/dist/templates/paradigms.js +0 -8
- package/dist/templates/presets.d.ts +0 -1
- package/dist/templates/presets.js +0 -23
- package/dist/templates/universal-config.js +16 -46
- package/dist/types/index.d.ts +202 -574
- package/dist/types/index.js +64 -67
- package/dist/types/index.test.d.ts +1 -0
- package/dist/types/index.test.js +33 -0
- package/dist/utils/command-line.d.ts +8 -0
- package/dist/utils/command-line.js +8 -0
- package/dist/utils/command-line.test.d.ts +1 -0
- package/dist/utils/command-line.test.js +8 -0
- package/dist/utils/profile.js +1 -1
- package/package.json +6 -7
|
@@ -4,18 +4,24 @@ import os from 'os';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
6
|
import { ConfigSchema } from '../types/index.js';
|
|
7
|
-
import {
|
|
7
|
+
import { reviewerBlocks, runReviewer } from './reviewer.js';
|
|
8
|
+
import { dismissReviewerFinding } from './reviewer/context.js';
|
|
9
|
+
import { reviewStatus } from './reviewer/background.js';
|
|
10
|
+
import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
|
|
11
|
+
import { account, carryResolved, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
8
12
|
let repo;
|
|
9
|
-
const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account' } });
|
|
10
|
-
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
13
|
+
const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'] } } });
|
|
14
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
15
|
+
const PR = { number: 42, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: 'Every read is bounded at both ends.' };
|
|
11
16
|
const REVIEWS = [
|
|
12
|
-
{ id: 1, user: { login: 'ci-bot', type: 'Bot' }, body: 'automated', state: 'COMMENTED', submitted_at: '2026-10-01' },
|
|
13
|
-
{ id: 2, user: { login: 'author', type: 'User' }, body: 'self note', state: 'COMMENTED', submitted_at: '2026-10-02' },
|
|
14
|
-
{ id: 3, user: { login: 'senior', type: 'User' }, body: 'Two blocking points.', state: 'CHANGES_REQUESTED', submitted_at: '2026-10-03' },
|
|
17
|
+
{ id: 1, user: { login: 'ci-bot', type: 'Bot' }, body: 'automated', state: 'COMMENTED', submitted_at: '2026-10-01', commit_id: 'aaaaaaaaa' },
|
|
18
|
+
{ id: 2, user: { login: 'author', type: 'User' }, body: 'self note', state: 'COMMENTED', submitted_at: '2026-10-02', commit_id: 'aaaaaaaaa' },
|
|
19
|
+
{ id: 3, user: { login: 'senior', type: 'User' }, body: 'Two blocking points.', state: 'CHANGES_REQUESTED', submitted_at: '2026-10-03', commit_id: 'aaaaaaaaa' },
|
|
15
20
|
];
|
|
16
|
-
const INLINE = [{ path: 'src/job.ts', line: 12, body: 'Check the lock before the first read.' }];
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
const INLINE = [{ id: 7, user: { login: 'senior', type: 'User' }, path: 'src/job.ts', line: 12, body: 'Check the lock before the first read.', created_at: '2026-10-03', updated_at: '2026-10-03' }];
|
|
22
|
+
const EMPTY = { prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: true, evidence: 'a.ts:1' }], redundant: [], reads: [], scans: [], merge_impact: [], findings: [], carried: [], resolved_previous: [] };
|
|
23
|
+
/** Real git; scripted gh; agent CLIs that record what they were shown and answer `answer` (a function of the reviewer's name). */
|
|
24
|
+
function fakes(answer, seen, pr = PR) {
|
|
19
25
|
return async (command, args, options) => {
|
|
20
26
|
if (command === 'git') {
|
|
21
27
|
try {
|
|
@@ -26,23 +32,51 @@ function fakes(answer, seen) {
|
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
34
|
if (command === 'gh') {
|
|
35
|
+
seen.ghArgs.push(args);
|
|
29
36
|
if (args[0] === 'auth')
|
|
30
37
|
return { exitCode: 0, stdout: 'token-for-account\n', stderr: '' };
|
|
31
38
|
seen.ghToken = options.env?.GH_TOKEN;
|
|
32
39
|
if (args[0] === 'pr')
|
|
33
|
-
return { exitCode: 0, stdout:
|
|
40
|
+
return pr ? { exitCode: 0, stdout: JSON.stringify(pr), stderr: '' } : { exitCode: 1, stdout: '', stderr: 'no pull requests found for branch "feature"' };
|
|
34
41
|
if (args[1].endsWith('/reviews'))
|
|
35
42
|
return { exitCode: 0, stdout: JSON.stringify(REVIEWS), stderr: '' };
|
|
36
|
-
|
|
43
|
+
if (args[1].endsWith('/comments'))
|
|
44
|
+
return { exitCode: 0, stdout: JSON.stringify(INLINE), stderr: '' };
|
|
45
|
+
return { exitCode: 1, stdout: '', stderr: 'unexpected gh call' };
|
|
37
46
|
}
|
|
38
|
-
const
|
|
47
|
+
const binary = path.basename(command).replace(/\.(cmd|exe)$/, '');
|
|
48
|
+
if (args[0] === '--version')
|
|
49
|
+
return (seen.installed ?? ['claude', 'cursor-agent']).includes(binary) ? { exitCode: 0, stdout: `${seen.versions?.[command] ?? '1.0.0'}\n`, stderr: '' } : { exitCode: 127, stdout: '', stderr: 'not found' };
|
|
50
|
+
seen.ran.push(command);
|
|
51
|
+
(seen.args ??= []).push(args);
|
|
52
|
+
const name = binary === 'claude' ? 'claude' : binary === 'cursor-agent' ? 'cursor' : 'codex';
|
|
53
|
+
const prompt = binary === 'claude' ? args[args.indexOf('-p') + 1] : args[args.length - 1];
|
|
39
54
|
seen.prompts.push(prompt);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
// Paths as the prompt names them, on either separator (Windows writes `D:\...`).
|
|
56
|
+
for (const match of prompt.matchAll(/(\S+(?:previous-reviews\.md|pr-description\.md|full\.diff|hints\.txt|previous-open\.json|delta\.diff|previous-resolved\.json|team-knowledge\.md))/g)) {
|
|
57
|
+
seen.files[path.basename(match[1])] = fs.readFileSync(match[1], 'utf8');
|
|
58
|
+
}
|
|
59
|
+
const reply = answer(name);
|
|
60
|
+
if (typeof reply !== 'string')
|
|
61
|
+
return reply;
|
|
62
|
+
const stdout = binary === 'claude' ? JSON.stringify({ result: reply, total_cost_usd: 1.5 }) : binary === 'codex' ? JSON.stringify({ type: 'item.completed', item: { text: reply } }) : JSON.stringify({ result: reply });
|
|
63
|
+
return { exitCode: 0, stdout, stderr: '' };
|
|
43
64
|
};
|
|
44
65
|
}
|
|
66
|
+
const seenNow = () => ({ prompts: [], files: {}, ghArgs: [], ran: [] });
|
|
67
|
+
/** A PATH of our own, so the test sees only the agent CLIs it creates (the fake exec answers for them by name). */
|
|
68
|
+
let bins;
|
|
69
|
+
const originalPath = process.env.PATH;
|
|
70
|
+
function installFake(dir, name) {
|
|
71
|
+
const file = path.join(dir, process.platform === 'win32' ? `${name}.cmd` : name);
|
|
72
|
+
fs.writeFileSync(file, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
73
|
+
return file;
|
|
74
|
+
}
|
|
45
75
|
beforeEach(() => {
|
|
76
|
+
bins = [fs.mkdtempSync(path.join(os.tmpdir(), 'bin-a-')), fs.mkdtempSync(path.join(os.tmpdir(), 'bin-b-'))];
|
|
77
|
+
for (const name of ['claude', 'cursor-agent'])
|
|
78
|
+
installFake(bins[0], name);
|
|
79
|
+
process.env.PATH = [...bins, originalPath ?? ''].join(path.delimiter);
|
|
46
80
|
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'reviewer-'));
|
|
47
81
|
git('init', '-q', '-b', 'main');
|
|
48
82
|
git('config', 'user.email', 't@example.com');
|
|
@@ -51,49 +85,281 @@ beforeEach(() => {
|
|
|
51
85
|
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
|
52
86
|
git('add', '-A');
|
|
53
87
|
git('commit', '-qm', 'init');
|
|
88
|
+
git('checkout', '-qb', 'feature');
|
|
89
|
+
fs.mkdirSync(path.join(repo, 'src'));
|
|
90
|
+
fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 1;\n}\n');
|
|
91
|
+
git('add', '-A');
|
|
92
|
+
git('commit', '-qm', 'job');
|
|
93
|
+
});
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
process.env.PATH = originalPath;
|
|
96
|
+
for (const dir of [repo, ...bins])
|
|
97
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
54
98
|
});
|
|
55
|
-
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
56
99
|
describe('the reviewer', () => {
|
|
57
|
-
it(
|
|
58
|
-
const seen =
|
|
59
|
-
const answer = JSON.stringify({ prior_points: [{ point: 'lock before read', resolved: false, evidence: 'src/job.ts:
|
|
60
|
-
const result = await runReviewer(repo, 'main', config, fakes(answer, seen), () => undefined);
|
|
61
|
-
expect(seen.
|
|
62
|
-
expect(seen.
|
|
63
|
-
expect(seen.
|
|
64
|
-
expect(seen.
|
|
100
|
+
it('works from every human review with inline comments and the description, written to files, and blocks on an open point', async () => {
|
|
101
|
+
const seen = seenNow();
|
|
102
|
+
const answer = JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: false, evidence: 'src/job.ts:2' }] });
|
|
103
|
+
const result = await runReviewer(repo, 'main', config, fakes(() => answer, seen), () => undefined);
|
|
104
|
+
expect(seen.files['previous-reviews.md']).toContain('Review by senior');
|
|
105
|
+
expect(seen.files['previous-reviews.md']).toContain('- 2026-10-03 src/job.ts:12: Check the lock before the first read.');
|
|
106
|
+
expect(seen.files['previous-reviews.md']).not.toContain('automated');
|
|
107
|
+
expect(seen.files['previous-reviews.md']).not.toContain('self note');
|
|
108
|
+
expect(seen.files['pr-description.md']).toBe('Every read is bounded at both ends.');
|
|
109
|
+
expect(seen.files['full.diff']).toContain('+export function job()');
|
|
65
110
|
expect(seen.ghToken).toBe('token-for-account');
|
|
66
|
-
expect(seen.
|
|
67
|
-
expect(result).toMatchObject({ cached: false, previousReview: 'senior, 2026-10-03 (1 review)' });
|
|
111
|
+
expect(seen.ghArgs[1].slice(0, 3)).toEqual(['pr', 'view', 'feature']); // by branch: the commit is not on the forge yet
|
|
112
|
+
expect(result).toMatchObject({ outcome: 'findings', reviewers: ['claude'], scope: 'full', cached: false, previousReview: 'senior, 2026-10-03 (1 review)', pr: 42, costUsd: 1.5 });
|
|
113
|
+
expect(result.items).toEqual([expect.objectContaining({ kind: 'prior', issue: 'lock before read', evidence: 'src/job.ts:2', reviewer: 'claude' })]);
|
|
68
114
|
expect(reviewerBlocks(result)).toBe(true);
|
|
69
115
|
});
|
|
70
|
-
it('caches the verdict per commit and
|
|
71
|
-
const seen =
|
|
72
|
-
const
|
|
73
|
-
await runReviewer(repo, 'main', config, fakes(
|
|
74
|
-
|
|
116
|
+
it('caches the verdict per commit and inputs, so the same push is free, and the next commit gets a delta review that carries what is not accounted for', async () => {
|
|
117
|
+
const seen = seenNow();
|
|
118
|
+
const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', consequence: 'a second run reads stale rows', why: 'x' }] });
|
|
119
|
+
const first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
|
|
120
|
+
expect(first.items).toHaveLength(1);
|
|
121
|
+
const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
|
|
75
122
|
expect(again.cached).toBe(true);
|
|
76
123
|
expect(seen.prompts).toHaveLength(1);
|
|
77
|
-
expect(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
124
|
+
expect(fs.readdirSync(repo).sort()).toEqual(['.git', 'a.ts', 'src']); // nothing written to the working tree
|
|
125
|
+
fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 2;\n}\n');
|
|
126
|
+
git('commit', '-qam', 'tweak');
|
|
127
|
+
// What the checks found changed with the commit: the context changes, the instructions do not, so it is still a delta.
|
|
128
|
+
const delta = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [], carried: [], resolved_previous: [] }), seen), () => undefined, { checks: ['src/job.ts:2 Unused export `other`'] });
|
|
129
|
+
expect(delta.scope).toBe('delta');
|
|
130
|
+
expect(seen.prompts[1]).toContain('DELTA MODE');
|
|
131
|
+
expect(seen.files['delta.diff']).toContain('- return 1;');
|
|
132
|
+
expect(JSON.parse(seen.files['previous-open.json'])).toHaveLength(1);
|
|
133
|
+
expect(delta.items).toEqual([expect.objectContaining({ issue: 'returns before the lock', status: 'not accounted for' })]);
|
|
134
|
+
expect(delta.outcome).toBe('findings');
|
|
135
|
+
// The human point the previous verdict resolved (a.ts untouched) was carried, not judged again.
|
|
136
|
+
expect(JSON.parse(seen.files['previous-resolved.json'])).toEqual([expect.objectContaining({ point: 'lock before read', resolved: true })]);
|
|
137
|
+
expect(delta.answerInReply).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
it('resolves a previous item only with evidence, and never blocks on an item that names code the checkout does not have', async () => {
|
|
140
|
+
const seen = seenNow();
|
|
141
|
+
const first = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: 'src/ghost.ts', line: 1, issue: 'unused', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: '', issue: 'somewhere, no file named', consequence: 'a second run reads stale rows' }] }), seen), () => undefined);
|
|
142
|
+
expect(first.items.map(i => i.file)).toEqual(['src/job.ts']);
|
|
143
|
+
expect(first.unverified.map(i => i.file)).toEqual(['src/ghost.ts', '']); // a slip and a finding with no place to check: shown, never a block
|
|
144
|
+
const id = first.items[0].id;
|
|
145
|
+
fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 2;\n}\n');
|
|
146
|
+
git('commit', '-qam', 'fix');
|
|
147
|
+
const delta = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [], resolved_previous: [{ id, evidence: 'src/job.ts:2 the lock comes first now' }] }), seen), () => undefined);
|
|
148
|
+
expect(delta.outcome).toBe('passed');
|
|
149
|
+
expect(delta.resolved).toEqual([{ item: expect.objectContaining({ id }), evidence: 'src/job.ts:2 the lock comes first now' }]);
|
|
150
|
+
});
|
|
151
|
+
it('at push, asks a model only when someone will read the push: an open, non-draft pull request', async () => {
|
|
152
|
+
const seen = seenNow();
|
|
153
|
+
const none = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'push' });
|
|
154
|
+
expect(none).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('no pull request for feature') });
|
|
155
|
+
expect(reviewerBlocks(none)).toBe(false);
|
|
156
|
+
expect(seen.prompts).toHaveLength(0);
|
|
157
|
+
const draft = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, { ...PR, isDraft: true }), () => undefined, { trigger: 'push' });
|
|
158
|
+
expect(draft.reason).toContain('is a draft');
|
|
159
|
+
const asked = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'review' });
|
|
160
|
+
expect(asked.outcome).toBe('passed'); // on request it reviews without a pull request
|
|
161
|
+
expect(seen.prompts).toHaveLength(1);
|
|
162
|
+
});
|
|
163
|
+
it('for a backtest, reads the named pull request and hides every review and comment from the reviewed moment on', async () => {
|
|
164
|
+
const seen = seenNow();
|
|
165
|
+
const hidden = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { pr: 42, reviewsBefore: '2026-10-03', force: true });
|
|
166
|
+
expect(seen.ghArgs.find(a => a[0] === 'pr')?.slice(0, 3)).toEqual(['pr', 'view', '42']);
|
|
167
|
+
expect(seen.files['previous-reviews.md']).toBe('none\n');
|
|
168
|
+
expect(hidden.outcome).toBe('passed');
|
|
169
|
+
const shown = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { pr: 42, reviewsBefore: '2026-10-04', force: true });
|
|
170
|
+
expect(seen.files['previous-reviews.md']).toContain('Review by senior');
|
|
171
|
+
expect(shown).toMatchObject({ outcome: 'unavailable', reason: 'claude did not report on the human reviews' });
|
|
172
|
+
});
|
|
173
|
+
it('never passes without a verdict: a crash, a malformed answer, an unreadable pull request or no installed reviewer', async () => {
|
|
174
|
+
const crashed = await runReviewer(repo, 'main', config, fakes(() => ({ exitCode: 1, stdout: '', stderr: 'API error' }), seenNow()), () => undefined);
|
|
175
|
+
expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('claude: no answer (exit 1)') });
|
|
176
|
+
const prose = await runReviewer(repo, 'main', config, fakes(() => 'Looks good to me!', seenNow()), () => undefined);
|
|
177
|
+
expect(prose).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
|
|
178
|
+
const broken = async (command, args, options) => command === 'gh' && args[0] === 'pr' ? { exitCode: 1, stdout: '', stderr: 'HTTP 500' } : fakes(() => '', seenNow())(command, args, options);
|
|
179
|
+
expect(await runReviewer(repo, 'main', config, broken, () => undefined)).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('could not read the pull request') });
|
|
180
|
+
const seen = { ...seenNow(), installed: [] };
|
|
181
|
+
expect(await runReviewer(repo, 'main', config, fakes(() => '', seen), () => undefined)).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no reviewer installed') });
|
|
182
|
+
for (const result of [crashed, prose])
|
|
183
|
+
expect(reviewerBlocks(result)).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
it('in full mode runs two vendors and resolves a human point only when both say so', async () => {
|
|
186
|
+
const seen = seenNow();
|
|
187
|
+
const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused', consequence: 'a second run reads stale rows' }] : [] });
|
|
188
|
+
const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
|
|
189
|
+
expect(result.reviewers).toEqual(['claude', 'cursor']);
|
|
190
|
+
expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor'], ['finding', 'cursor']]);
|
|
191
|
+
expect(seen.prompts).toHaveLength(2);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe('choosing reviewers', () => {
|
|
195
|
+
it('runs the newest installed copy of a CLI, not the first on PATH', async () => {
|
|
196
|
+
const seen = seenNow();
|
|
197
|
+
const older = installFake(bins[0], 'claude');
|
|
198
|
+
const newer = installFake(bins[1], 'claude');
|
|
199
|
+
seen.versions = { [older]: '2.0.34 (Claude Code)', [newer]: '2.1.289 (Claude Code)' };
|
|
200
|
+
const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen), () => undefined);
|
|
201
|
+
expect(result.outcome).toBe('passed');
|
|
202
|
+
expect(seen.ran).toEqual([newer]);
|
|
203
|
+
});
|
|
204
|
+
it('reads the vendors on the trailers, prefers another one in cross mode, and pairs two vendors in full mode', () => {
|
|
205
|
+
const authors = vendorsOf('Claude Opus <noreply@example.com>\n');
|
|
206
|
+
expect([...authors]).toEqual(['anthropic']);
|
|
207
|
+
const installed = new Set(['claude', 'cursor', 'codex']);
|
|
208
|
+
expect(selectReviewers(['claude', 'cursor', 'codex'], 'single', authors, installed)).toEqual(['claude']);
|
|
209
|
+
expect(selectReviewers(['claude', 'cursor', 'codex'], 'cross', authors, installed)).toEqual(['cursor']);
|
|
210
|
+
expect(selectReviewers(['claude', 'cursor', 'codex'], 'full', authors, installed)).toEqual(['cursor', 'claude']);
|
|
211
|
+
expect(selectReviewers(['claude', 'cursor'], 'cross', authors, new Set(['claude']))).toEqual(['claude']); // falls back to what is installed
|
|
212
|
+
expect(selectReviewers(['claude'], 'full', new Set(), new Set())).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('verdicts', () => {
|
|
216
|
+
it('finds the verdict when the reviewer wraps it in a summary or a code fence, and refuses prose', () => {
|
|
217
|
+
const verdict = JSON.stringify(EMPTY);
|
|
218
|
+
for (const text of [`## Summary\nAll checked.\n\n\`\`\`json\n${verdict}\n\`\`\`\nDone.`, `Notes first.\n${verdict}\nThat is all {see above}.`]) {
|
|
219
|
+
expect(parseVerdict(text, true, 'claude', {})).toMatchObject({ verdict: { prior_points: [{ point: 'lock before read' }], reviewer: 'claude' } });
|
|
85
220
|
}
|
|
221
|
+
expect(parseVerdict('Looks good to me!', false, 'claude', {})).toMatchObject({ error: expect.stringContaining('no valid verdict') });
|
|
222
|
+
expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude', {})).toEqual({ error: 'claude did not report on the human reviews' });
|
|
223
|
+
});
|
|
224
|
+
it('turns reads, scans, redundancy and merge impact into open items with stable ids, and answers non-blocking points in the reply', () => {
|
|
225
|
+
const verdict = {
|
|
226
|
+
...EMPTY,
|
|
227
|
+
prior_points: [{ point: 'nit: rename', severity: 'non-blocking', resolved: false }],
|
|
228
|
+
reads: [{ file: 'src/q.ts', line: 10, read: 'select attempts', rules: [{ rule: 'flag off', known_before_read: true, applied_before_read: false }], narrower_source: 'attempt.updated_at', keys: [{ name: 'eventId', inputs: 'answered_at', stable_under_edit: false }], window_bounded: false, keyset: null }],
|
|
229
|
+
scans: [{ file: 'src/s.ts', line: 3, function: 'later', outer: 'attempts', inner: 'answers', fix: 'index by attempt' }],
|
|
230
|
+
redundant: [{ file: 'src/r.ts', line: 5, what: 'null guard', made_redundant_by: 'src/r.ts:2', removed: false }, { file: 'src/r.ts', line: 9, what: 'removed guard', removed: true }],
|
|
231
|
+
merge_impact: [{ symbol: 'parseId', main_file: 'src/id.ts', call_site: 'src/link.ts:4', holds: false, why: 'band dropped' }],
|
|
232
|
+
};
|
|
233
|
+
const { open, answerInReply } = account(verdict, undefined, () => true);
|
|
234
|
+
expect(open.map(i => `${i.class} ${i.file}:${i.line}`)).toEqual([
|
|
235
|
+
'dead-code src/r.ts:5', 'production-cost src/q.ts:10', 'production-cost src/q.ts:10', 'production-cost src/q.ts:10', 'correctness src/q.ts:10', 'production-cost src/s.ts:3', 'correctness src/link.ts:4',
|
|
236
|
+
]);
|
|
237
|
+
expect(new Set(open.map(i => i.id)).size).toBe(open.length);
|
|
238
|
+
expect(account(verdict, undefined, () => true).open.map(i => i.id)).toEqual(open.map(i => i.id)); // stable
|
|
239
|
+
expect(answerInReply).toEqual([expect.objectContaining({ point: 'nit: rename' })]);
|
|
240
|
+
});
|
|
241
|
+
it('merges two verdicts: a point is resolved only when every reviewer resolves it', () => {
|
|
242
|
+
const a = { ...EMPTY, reviewer: 'claude', prior_points: [{ point: 'Lock before read', severity: 'blocking', resolved: true }], resolved_previous: [{ id: 'x', evidence: 'e' }, { id: 'y', evidence: 'e' }] };
|
|
243
|
+
const b = { ...EMPTY, reviewer: 'cursor', prior_points: [{ point: 'lock before read.', severity: 'blocking', resolved: false }], resolved_previous: [{ id: 'x', evidence: 'e' }] };
|
|
244
|
+
const merged = mergeVerdicts([a, b]);
|
|
245
|
+
expect(merged.prior_points).toEqual([expect.objectContaining({ resolved: false, reviewer: 'cursor' })]);
|
|
246
|
+
expect(merged.resolved_previous.map(r => r.id)).toEqual(['x']);
|
|
247
|
+
expect(merged.reviewers?.map(r => r.reviewer)).toEqual(['claude', 'cursor']);
|
|
248
|
+
});
|
|
249
|
+
it('carries a resolved human point into a delta verdict unless the new commits touch its evidence', () => {
|
|
250
|
+
const previous = { ...EMPTY, prior_points: [{ point: 'lock before read', resolved: true, evidence: 'src/job.ts:2' }, { point: 'rename it', resolved: true, evidence: 'src/name.ts:9' }] };
|
|
251
|
+
const fresh = { ...EMPTY, prior_points: [] };
|
|
252
|
+
expect(carryResolved(fresh, previous, new Set(['src/name.ts'])).prior_points.map(p => p.point)).toEqual(['lock before read']);
|
|
253
|
+
expect(carryResolved({ ...EMPTY, prior_points: [{ point: 'Lock before read', resolved: false }] }, previous, new Set()).prior_points).toHaveLength(2);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
describe('a panel of judges', () => {
|
|
257
|
+
const panelConfig = (reviewer) => ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor', 'codex'], mode: 'full', panel: 'on', ...reviewer } } });
|
|
258
|
+
const LOCK = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' };
|
|
259
|
+
const LONE = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', consequence: 'a reader treats it as the contract' };
|
|
260
|
+
const OPINION = { class: 'duplication', file: 'src/job.ts', line: 2, issue: 'could be one line shorter', consequence: '' };
|
|
261
|
+
it('confirms what a majority raised, drops what the others refute with evidence, and never blocks on an opinion', async () => {
|
|
262
|
+
installFake(bins[0], 'codex');
|
|
263
|
+
const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
|
|
264
|
+
const reply = (name) => {
|
|
265
|
+
const prompt = seen.prompts.at(-1) ?? '';
|
|
266
|
+
if (prompt.includes('The other\nreviewer raised')) {
|
|
267
|
+
const ids = [...prompt.matchAll(/"id": "([0-9a-f]+)"/g)].map(m => m[1]);
|
|
268
|
+
return JSON.stringify({ answers: ids.map(id => ({ id, call: 'refute', evidence: `src/job.ts:1 ${name}: job is imported by the runner` })) });
|
|
269
|
+
}
|
|
270
|
+
if (name === 'codex')
|
|
271
|
+
return JSON.stringify({ ...EMPTY, findings: [LONE] });
|
|
272
|
+
return JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK, OPINION] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }] });
|
|
273
|
+
};
|
|
274
|
+
const result = await runReviewer(repo, 'main', panelConfig({ judges: 3, cross_models: { claude: 'claude-haiku-4-5' } }), fakes(reply, seen), () => undefined);
|
|
275
|
+
expect(result.reviewers).toEqual(['claude', 'cursor', 'codex']);
|
|
276
|
+
expect(result.mode).toMatchObject({ asked: 'panel', ran: 'panel', source: 'team' });
|
|
277
|
+
expect(result.items.map(i => [i.issue, i.reviewer])).toEqual([[LOCK.issue, 'claude+cursor']]);
|
|
278
|
+
expect(result.dropped.map(i => i.issue)).toEqual([LONE.issue]);
|
|
279
|
+
expect(result.notes.map(i => i.issue)).toEqual([OPINION.issue]);
|
|
280
|
+
expect(seen.prompts).toHaveLength(5); // three blind reviews, then claude and cursor each cross-examine codex's lone finding once
|
|
281
|
+
expect(result.panel?.find(d => d.item.issue === LONE.issue)).toMatchObject({ judges: ['codex'], calls: { codex: 'raised', claude: 'refute', cursor: 'refute' }, status: 'dropped' });
|
|
282
|
+
expect(result.costUsd).toBe(3); // claude's review and claude's cross-examination
|
|
283
|
+
const claudeCalls = (seen.args ?? []).filter((_, i) => path.basename(seen.ran[i]).startsWith('claude'));
|
|
284
|
+
expect(claudeCalls.map(a => a.includes('claude-haiku-4-5'))).toEqual([false, true]); // the cheaper model for the cross-examination only
|
|
285
|
+
});
|
|
286
|
+
it('runs one judge and says why when only one vendor is installed, and is unavailable when the team requires the panel', async () => {
|
|
287
|
+
const seen = seenNow();
|
|
288
|
+
const reply = () => JSON.stringify({ ...EMPTY, findings: [LOCK] });
|
|
289
|
+
const fallback = await runReviewer(repo, 'main', panelConfig({ reviewers: ['claude', 'codex'] }), fakes(reply, seen), () => undefined);
|
|
290
|
+
expect(fallback.mode).toMatchObject({ asked: 'panel', ran: 'single', degraded: expect.stringContaining('not installed: codex') });
|
|
291
|
+
expect(fallback.items.map(i => i.issue)).toEqual([LOCK.issue]);
|
|
292
|
+
const required = await runReviewer(repo, 'main', panelConfig({ reviewers: ['claude', 'codex'], panel: 'required' }), fakes(reply, seenNow()), () => undefined);
|
|
293
|
+
expect(required.outcome).toBe('unavailable');
|
|
294
|
+
expect(required.reason).toContain('rigour.yml requires two reviewers');
|
|
295
|
+
});
|
|
296
|
+
it('keeps to the daily caps: a review past the run cap is skipped, or unavailable when the team requires the reviewer', async () => {
|
|
297
|
+
const reply = () => JSON.stringify({ ...EMPTY, findings: [LOCK] });
|
|
298
|
+
const skipped = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 1 }), fakes(reply, seenNow()), () => undefined);
|
|
299
|
+
expect(skipped.outcome).toBe('skipped');
|
|
300
|
+
expect(skipped.reason).toContain('the daily run cap is reached: 0 of 1 agent runs used today in this repository, and this needs 2 more');
|
|
301
|
+
const required = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 1, panel: 'required' }), fakes(reply, seenNow()), () => undefined);
|
|
302
|
+
expect(required.outcome).toBe('unavailable');
|
|
303
|
+
});
|
|
304
|
+
it('counts every run, stops new reviews at the cost cap, and leaves a cross-examination past the run cap disputed', async () => {
|
|
305
|
+
const seen = seenNow();
|
|
306
|
+
const lone = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', consequence: 'a reader treats it as the contract' };
|
|
307
|
+
const reply = (name) => JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }, lone] });
|
|
308
|
+
// Two judges fit in a cap of 2; the cross-examination of cursor's lone finding would be a third run.
|
|
309
|
+
const result = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 2 }), fakes(reply, seen), () => undefined);
|
|
310
|
+
expect(seen.prompts).toHaveLength(2);
|
|
311
|
+
expect(result.items.map(i => i.issue)).toEqual([LOCK.issue]);
|
|
312
|
+
expect(result.panel?.find(d => d.item.issue === lone.issue)).toMatchObject({ status: 'disputed', note: expect.stringContaining('the daily run cap is reached') });
|
|
313
|
+
// claude reported $1.50: a cost cap of $1 lets no new review start today.
|
|
314
|
+
const capped = await runReviewer(repo, 'main', panelConfig({ max_usd_per_day: 1 }), fakes(reply, seenNow()), () => undefined, { force: true });
|
|
315
|
+
expect(capped).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('the daily cost cap is reached: $1.50 of $1.00') });
|
|
316
|
+
});
|
|
317
|
+
it('escalates on risk: one judge for a change with no risky function and no human review', async () => {
|
|
318
|
+
const seen = seenNow();
|
|
319
|
+
const result = await runReviewer(repo, 'main', panelConfig({ escalate: 'risk' }), fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined);
|
|
320
|
+
expect(result.mode).toMatchObject({ asked: 'panel', ran: 'single', escalation: expect.stringContaining('no risky changed function') });
|
|
321
|
+
expect(seen.prompts).toHaveLength(1);
|
|
322
|
+
const full = await runReviewer(repo, 'main', panelConfig({ escalate: 'risk' }), fakes(() => JSON.stringify(EMPTY), seenNow(), null), () => undefined, { full: true, force: true });
|
|
323
|
+
expect(full.mode?.ran).toBe('panel'); // the --full hard stop always gets every judge
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
describe('what the team already knows', () => {
|
|
327
|
+
const allowing = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'], dismissals: true } } });
|
|
328
|
+
it('refuses a dismissal unless the team allows them: fix the code, or the reviewer', async () => {
|
|
329
|
+
const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' }] }), seenNow()), () => undefined);
|
|
330
|
+
expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock', false)).error).toContain('this team does not dismiss reviewer findings');
|
|
331
|
+
expect(fs.existsSync(path.join(repo, '.rigour/dismissed-review-items.json'))).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
it('a dismissed finding reaches the next judge as settled, and a re-worded repeat never blocks', async () => {
|
|
334
|
+
fs.mkdirSync(path.join(repo, 'docs'));
|
|
335
|
+
fs.writeFileSync(path.join(repo, 'docs/jobs.md'), 'The job runner (src/job.ts) takes the lock first.\n');
|
|
336
|
+
git('add', '-A');
|
|
337
|
+
git('commit', '-qm', 'docs');
|
|
338
|
+
const finding = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' };
|
|
339
|
+
const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [finding] }), seenNow()), () => undefined);
|
|
340
|
+
expect(first.outcome).toBe('findings');
|
|
341
|
+
expect(await dismissReviewerFinding(repo, 'abcdef0123', 'not one of ours', true)).toEqual({ error: 'no open reviewer finding abcdef0123 on feature: run `rigour review --reviewer` and copy the id it shows' });
|
|
342
|
+
expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock one level up', true)).item?.issue).toBe('returns before the lock is taken');
|
|
343
|
+
expect((await reviewStatus(repo, 'feature'))?.last?.open).toEqual([]); // not work any more, right away
|
|
344
|
+
const seen = seenNow();
|
|
345
|
+
// The same commit again: no new run; the stored decision is reused, with the dismissal applied.
|
|
346
|
+
const reused = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify(EMPTY), seenNow()), () => undefined);
|
|
347
|
+
expect(reused).toMatchObject({ cached: true, outcome: 'passed' });
|
|
348
|
+
expect(reused.dismissed.map(i => i.issue)).toEqual(['returns before the lock is taken']);
|
|
349
|
+
// A fresh review: the judge is told it is settled, and a re-worded repeat does not block either.
|
|
350
|
+
const again = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ ...finding, issue: 'returns before the lock is taken, so it races' }] }), seen), () => undefined, { force: true });
|
|
351
|
+
expect(again.cached).toBe(false);
|
|
352
|
+
expect(again.outcome).toBe('passed');
|
|
353
|
+
expect(again.dismissed.map(i => i.issue)).toEqual(['returns before the lock is taken, so it races']);
|
|
354
|
+
expect(seen.files['team-knowledge.md']).toContain('dismissed as not a bug by t@example.com: src/job.ts:2 [correctness] returns before the lock is taken (reason: the runner holds a lock one level up)');
|
|
355
|
+
expect(seen.files['team-knowledge.md']).toContain('docs/jobs.md (names src/job.ts');
|
|
356
|
+
const told = seenNow();
|
|
357
|
+
await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify(EMPTY), told), () => undefined, { force: true, checks: ['src/job.ts:1 Unused export `job`'] });
|
|
358
|
+
expect(told.files['team-knowledge.md']).toContain("## Already found by Rigour's checks: they block on their own, so do not report them again\n- src/job.ts:1 Unused export `job`");
|
|
86
359
|
});
|
|
87
|
-
it('
|
|
88
|
-
const
|
|
89
|
-
expect(
|
|
90
|
-
expect(
|
|
91
|
-
});
|
|
92
|
-
it('never passes on an answer that is not a verdict', () => {
|
|
93
|
-
expect(parseVerdict({ exitCode: 1, stdout: '', stderr: 'API error' }, false)).toMatchObject({ error: expect.stringContaining('did not answer') });
|
|
94
|
-
expect(parseVerdict({ exitCode: 0, stdout: JSON.stringify({ result: 'Looks good to me!' }), stderr: '' }, false)).toMatchObject({ error: expect.stringContaining('not a verdict') });
|
|
95
|
-
expect(parseVerdict({ exitCode: 0, stdout: JSON.stringify({ result: '{"prior_points":[],"blocking":[]}' }), stderr: '' }, true))
|
|
96
|
-
.toEqual({ error: 'the reviewer did not report on the previous review' });
|
|
97
|
-
expect(reviewerBlocks({ error: 'x', cached: false })).toBe(true);
|
|
360
|
+
it('fails closed: a finding whose judge left out the consequence still blocks', async () => {
|
|
361
|
+
const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock' }] }), seenNow()), () => undefined);
|
|
362
|
+
expect(result.items.map(i => i.issue)).toEqual(['returns before the lock']);
|
|
363
|
+
expect(result.notes).toEqual([]);
|
|
98
364
|
});
|
|
99
365
|
});
|
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* A test file, by the conventions test runners use: by name (`x.test.ts`, `x.spec.ts`, `x.e2e.ts`,
|
|
3
|
+
* tsd's `x.test-d.ts`) or by folder (`test/`, `tests/`, `__tests__/`, `__mocks__/`, `e2e/`, tsd's
|
|
4
|
+
* `test-d/`). The one definition every review check uses, so they never disagree about a file.
|
|
5
|
+
*/
|
|
2
6
|
export declare function isTestFile(file: string): boolean;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* A test file, by the conventions test runners use: by name (`x.test.ts`, `x.spec.ts`, `x.e2e.ts`,
|
|
3
|
+
* tsd's `x.test-d.ts`) or by folder (`test/`, `tests/`, `__tests__/`, `__mocks__/`, `e2e/`, tsd's
|
|
4
|
+
* `test-d/`). The one definition every review check uses, so they never disagree about a file.
|
|
5
|
+
*/
|
|
2
6
|
export function isTestFile(file) {
|
|
3
|
-
return /\.(test|spec|e2e)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)(__tests__|tests?|e2e)\//.test(file);
|
|
7
|
+
return /\.(test|spec|e2e|test-d)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)(__tests__|__mocks__|tests?|test-d|e2e)\//.test(file);
|
|
4
8
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { Config } from '../types/index.js';
|
|
2
2
|
export type ToolStatus = 'pass' | 'fail' | 'skipped';
|
|
3
3
|
export interface ToolResult {
|
|
4
|
-
tool: 'format' | 'lint' | 'typecheck' | 'test';
|
|
4
|
+
tool: 'format' | 'lint' | 'overlay' | 'typecheck' | 'test' | 'knip';
|
|
5
5
|
status: ToolStatus;
|
|
6
6
|
/** The command run, or why it was not. */
|
|
7
7
|
command: string;
|
|
8
8
|
/** The tail of the output when it failed. */
|
|
9
9
|
output?: string;
|
|
10
|
+
/** One line per finding, for tools that report places (the lint overlay, knip). */
|
|
11
|
+
lines?: string[];
|
|
10
12
|
}
|
|
11
|
-
|
|
13
|
+
/** `changedLines`: the lines the change touched, for the tools that report per line (the overlay blocks on those only). */
|
|
14
|
+
export declare function runToolchain(cwd: string, changedFiles: string[], config: Config, changedLines?: Record<string, Set<number>>): Promise<ToolResult[]>;
|