@rigour-labs/core 6.6.4 → 6.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/gates/frontend-secret-exposure.d.ts +1 -1
  2. package/dist/gates/frontend-secret-exposure.js +9 -3
  3. package/dist/gates/frontend-secret-exposure.test.js +4 -1
  4. package/dist/hooks/index.d.ts +1 -1
  5. package/dist/hooks/index.js +1 -1
  6. package/dist/hooks/stop-review.d.ts +6 -9
  7. package/dist/hooks/stop-review.js +5 -10
  8. package/dist/index.d.ts +5 -2
  9. package/dist/index.js +5 -2
  10. package/dist/review/backtest-init.d.ts +8 -0
  11. package/dist/review/backtest-init.js +87 -0
  12. package/dist/review/backtest-init.test.d.ts +1 -0
  13. package/dist/review/backtest-init.test.js +74 -0
  14. package/dist/review/backtest.d.ts +315 -0
  15. package/dist/review/backtest.js +197 -0
  16. package/dist/review/backtest.test.d.ts +1 -0
  17. package/dist/review/backtest.test.js +129 -0
  18. package/dist/review/code-patterns.test.js +58 -0
  19. package/dist/review/dead-code.test.js +8 -0
  20. package/dist/review/duplicate-functions.js +7 -2
  21. package/dist/review/lint-overlay.d.ts +21 -0
  22. package/dist/review/lint-overlay.js +89 -0
  23. package/dist/review/loop-copies.d.ts +2 -0
  24. package/dist/review/loop-copies.js +116 -0
  25. package/dist/review/migration-order.js +5 -1
  26. package/dist/review/orphan-files.js +5 -1
  27. package/dist/review/partial-fixes.d.ts +2 -0
  28. package/dist/review/partial-fixes.js +138 -0
  29. package/dist/review/partial-wiring.d.ts +2 -0
  30. package/dist/review/partial-wiring.js +201 -0
  31. package/dist/review/quiet.d.ts +6 -0
  32. package/dist/review/quiet.js +12 -2
  33. package/dist/review/quiet.test.js +11 -0
  34. package/dist/review/review.d.ts +6 -0
  35. package/dist/review/review.js +12 -2
  36. package/dist/review/reviewer/adapters.d.ts +35 -0
  37. package/dist/review/reviewer/adapters.js +156 -0
  38. package/dist/review/reviewer/background.d.ts +34 -0
  39. package/dist/review/reviewer/background.js +128 -0
  40. package/dist/review/reviewer/background.test.d.ts +1 -0
  41. package/dist/review/reviewer/background.test.js +104 -0
  42. package/dist/review/reviewer/exec.d.ts +17 -0
  43. package/dist/review/reviewer/exec.js +23 -0
  44. package/dist/review/reviewer/inputs.d.ts +37 -0
  45. package/dist/review/reviewer/inputs.js +103 -0
  46. package/dist/review/reviewer/merge-impact.d.ts +2 -0
  47. package/dist/review/reviewer/merge-impact.js +64 -0
  48. package/dist/review/reviewer/prompt.d.ts +21 -0
  49. package/dist/review/reviewer/prompt.js +123 -0
  50. package/dist/review/reviewer/store.d.ts +26 -0
  51. package/dist/review/reviewer/store.js +57 -0
  52. package/dist/review/reviewer/verdict.d.ts +127 -0
  53. package/dist/review/reviewer/verdict.js +156 -0
  54. package/dist/review/reviewer.d.ts +41 -49
  55. package/dist/review/reviewer.js +210 -194
  56. package/dist/review/reviewer.test.js +199 -48
  57. package/dist/review/toolchain.d.ts +5 -2
  58. package/dist/review/toolchain.js +81 -6
  59. package/dist/review/toolchain.test.js +39 -1
  60. package/dist/review/typed/program.d.ts +27 -0
  61. package/dist/review/typed/program.js +90 -0
  62. package/dist/review/typed/redundancy.d.ts +10 -0
  63. package/dist/review/typed/redundancy.js +339 -0
  64. package/dist/review/typed/redundancy.test.d.ts +1 -0
  65. package/dist/review/typed/redundancy.test.js +121 -0
  66. package/dist/review/unused-exports.js +8 -4
  67. package/dist/templates/universal-config.js +11 -1
  68. package/dist/types/index.d.ts +128 -8
  69. package/dist/types/index.js +26 -2
  70. package/package.json +6 -6
@@ -4,18 +4,22 @@ 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 { parseVerdict, reviewerBlocks, runReviewer } from './reviewer.js';
7
+ import { reviewerBlocks, runReviewer } from './reviewer.js';
8
+ import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
9
+ import { account, carryResolved, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
8
10
  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' });
11
+ const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'] } } });
12
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
13
+ const PR = { number: 42, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: 'Every read is bounded at both ends.' };
11
14
  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' },
15
+ { id: 1, user: { login: 'ci-bot', type: 'Bot' }, body: 'automated', state: 'COMMENTED', submitted_at: '2026-10-01', commit_id: 'aaaaaaaaa' },
16
+ { id: 2, user: { login: 'author', type: 'User' }, body: 'self note', state: 'COMMENTED', submitted_at: '2026-10-02', commit_id: 'aaaaaaaaa' },
17
+ { id: 3, user: { login: 'senior', type: 'User' }, body: 'Two blocking points.', state: 'CHANGES_REQUESTED', submitted_at: '2026-10-03', commit_id: 'aaaaaaaaa' },
15
18
  ];
16
- const INLINE = [{ path: 'src/job.ts', line: 12, body: 'Check the lock before the first read.' }];
17
- /** Real git; scripted gh; a reviewer that records what it was shown and answers `answer`. */
18
- function fakes(answer, seen) {
19
+ 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' }];
20
+ 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: [] };
21
+ /** Real git; scripted gh; agent CLIs that record what they were shown and answer `answer` (a function of the reviewer's name). */
22
+ function fakes(answer, seen, pr = PR) {
19
23
  return async (command, args, options) => {
20
24
  if (command === 'git') {
21
25
  try {
@@ -26,23 +30,49 @@ function fakes(answer, seen) {
26
30
  }
27
31
  }
28
32
  if (command === 'gh') {
33
+ seen.ghArgs.push(args);
29
34
  if (args[0] === 'auth')
30
35
  return { exitCode: 0, stdout: 'token-for-account\n', stderr: '' };
31
36
  seen.ghToken = options.env?.GH_TOKEN;
32
37
  if (args[0] === 'pr')
33
- return { exitCode: 0, stdout: `42\tauthor\t${Buffer.from('Every read is bounded at both ends.').toString('base64')}\n`, stderr: '' };
38
+ return pr ? { exitCode: 0, stdout: JSON.stringify(pr), stderr: '' } : { exitCode: 1, stdout: '', stderr: 'no pull requests found for branch "feature"' };
34
39
  if (args[1].endsWith('/reviews'))
35
40
  return { exitCode: 0, stdout: JSON.stringify(REVIEWS), stderr: '' };
36
- return { exitCode: 0, stdout: JSON.stringify(INLINE), stderr: '' };
41
+ if (args[1].endsWith('/comments'))
42
+ return { exitCode: 0, stdout: JSON.stringify(INLINE), stderr: '' };
43
+ return { exitCode: 1, stdout: '', stderr: 'unexpected gh call' };
37
44
  }
38
- const prompt = args[args.indexOf('-p') + 1];
45
+ const binary = path.basename(command).replace(/\.(cmd|exe)$/, '');
46
+ if (args[0] === '--version')
47
+ 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' };
48
+ seen.ran.push(command);
49
+ const name = binary === 'claude' ? 'claude' : binary === 'cursor-agent' ? 'cursor' : 'codex';
50
+ const prompt = binary === 'cursor-agent' ? args[args.length - 1] : args[args.indexOf('-p') + 1];
39
51
  seen.prompts.push(prompt);
40
- seen.review = fs.readFileSync(prompt.match(/inline comments: (\S+)/)[1], 'utf8');
41
- seen.description = fs.readFileSync(prompt.match(/description: (\S+)/)[1], 'utf8');
42
- return { exitCode: 0, stdout: JSON.stringify({ result: answer }), stderr: '' };
52
+ // Paths as the prompt names them, on either separator (Windows writes `D:\...`).
53
+ 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))/g)) {
54
+ seen.files[path.basename(match[1])] = fs.readFileSync(match[1], 'utf8');
55
+ }
56
+ const reply = answer(name);
57
+ if (typeof reply !== 'string')
58
+ return reply;
59
+ return { exitCode: 0, stdout: binary === 'claude' ? JSON.stringify({ result: reply, total_cost_usd: 1.5 }) : JSON.stringify({ result: reply }), stderr: '' };
43
60
  };
44
61
  }
62
+ const seenNow = () => ({ prompts: [], files: {}, ghArgs: [], ran: [] });
63
+ /** A PATH of our own, so the test sees only the agent CLIs it creates (the fake exec answers for them by name). */
64
+ let bins;
65
+ const originalPath = process.env.PATH;
66
+ function installFake(dir, name) {
67
+ const file = path.join(dir, process.platform === 'win32' ? `${name}.cmd` : name);
68
+ fs.writeFileSync(file, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
69
+ return file;
70
+ }
45
71
  beforeEach(() => {
72
+ bins = [fs.mkdtempSync(path.join(os.tmpdir(), 'bin-a-')), fs.mkdtempSync(path.join(os.tmpdir(), 'bin-b-'))];
73
+ for (const name of ['claude', 'cursor-agent'])
74
+ installFake(bins[0], name);
75
+ process.env.PATH = [...bins, originalPath ?? ''].join(path.delimiter);
46
76
  repo = fs.mkdtempSync(path.join(os.tmpdir(), 'reviewer-'));
47
77
  git('init', '-q', '-b', 'main');
48
78
  git('config', 'user.email', 't@example.com');
@@ -51,49 +81,170 @@ beforeEach(() => {
51
81
  fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
52
82
  git('add', '-A');
53
83
  git('commit', '-qm', 'init');
84
+ git('checkout', '-qb', 'feature');
85
+ fs.mkdirSync(path.join(repo, 'src'));
86
+ fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 1;\n}\n');
87
+ git('add', '-A');
88
+ git('commit', '-qm', 'job');
89
+ });
90
+ afterEach(() => {
91
+ process.env.PATH = originalPath;
92
+ for (const dir of [repo, ...bins])
93
+ fs.rmSync(dir, { recursive: true, force: true });
54
94
  });
55
- afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
56
95
  describe('the reviewer', () => {
57
- it("works from the latest person's review with its inline comments, and blocks on an open point", async () => {
58
- const seen = { prompts: [] };
59
- const answer = JSON.stringify({ prior_points: [{ point: 'lock before read', resolved: false, evidence: 'src/job.ts:12' }], blocking: [], non_blocking: [] });
60
- const result = await runReviewer(repo, 'main', config, fakes(answer, seen), () => undefined);
61
- expect(seen.review).toContain('Reviewer: senior');
62
- expect(seen.review).toContain('- src/job.ts:12: Check the lock before the first read.');
63
- expect(seen.review).not.toContain('automated');
64
- expect(seen.review).not.toContain('self note');
96
+ it('works from every human review with inline comments and the description, written to files, and blocks on an open point', async () => {
97
+ const seen = seenNow();
98
+ const answer = JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: false, evidence: 'src/job.ts:2' }] });
99
+ const result = await runReviewer(repo, 'main', config, fakes(() => answer, seen), () => undefined);
100
+ expect(seen.files['previous-reviews.md']).toContain('Review by senior');
101
+ expect(seen.files['previous-reviews.md']).toContain('- 2026-10-03 src/job.ts:12: Check the lock before the first read.');
102
+ expect(seen.files['previous-reviews.md']).not.toContain('automated');
103
+ expect(seen.files['previous-reviews.md']).not.toContain('self note');
104
+ expect(seen.files['pr-description.md']).toBe('Every read is bounded at both ends.');
105
+ expect(seen.files['full.diff']).toContain('+export function job()');
65
106
  expect(seen.ghToken).toBe('token-for-account');
66
- expect(seen.description).toBe('Every read is bounded at both ends.');
67
- expect(result).toMatchObject({ cached: false, previousReview: 'senior, 2026-10-03 (1 review)' });
107
+ expect(seen.ghArgs[1].slice(0, 3)).toEqual(['pr', 'view', 'feature']); // by branch: the commit is not on the forge yet
108
+ expect(result).toMatchObject({ outcome: 'findings', reviewers: ['claude'], scope: 'full', cached: false, previousReview: 'senior, 2026-10-03 (1 review)', pr: 42, costUsd: 1.5 });
109
+ expect(result.items).toEqual([expect.objectContaining({ kind: 'prior', issue: 'lock before read', evidence: 'src/job.ts:2', reviewer: 'claude' })]);
68
110
  expect(reviewerBlocks(result)).toBe(true);
69
111
  });
70
- it('caches the verdict per commit and review, so the same push is free', async () => {
71
- const seen = { prompts: [] };
72
- const answer = JSON.stringify({ prior_points: [{ point: 'p', resolved: true }], blocking: [] });
73
- await runReviewer(repo, 'main', config, fakes(answer, seen), () => undefined);
74
- const again = await runReviewer(repo, 'main', config, fakes(answer, seen), () => undefined);
112
+ 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 () => {
113
+ const seen = seenNow();
114
+ const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', why: 'x' }] });
115
+ const first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined);
116
+ expect(first.items).toHaveLength(1);
117
+ const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined);
75
118
  expect(again.cached).toBe(true);
76
119
  expect(seen.prompts).toHaveLength(1);
77
- expect(reviewerBlocks(again)).toBe(false);
78
- expect(fs.readdirSync(repo)).toEqual(['.git', 'a.ts']); // nothing written to the working tree
120
+ expect(fs.readdirSync(repo).sort()).toEqual(['.git', 'a.ts', 'src']); // nothing written to the working tree
121
+ fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 2;\n}\n');
122
+ git('commit', '-qam', 'tweak');
123
+ const delta = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [], carried: [], resolved_previous: [] }), seen), () => undefined);
124
+ expect(delta.scope).toBe('delta');
125
+ expect(seen.prompts[1]).toContain('DELTA MODE');
126
+ expect(seen.files['delta.diff']).toContain('- return 1;');
127
+ expect(JSON.parse(seen.files['previous-open.json'])).toHaveLength(1);
128
+ expect(delta.items).toEqual([expect.objectContaining({ issue: 'returns before the lock', status: 'not accounted for' })]);
129
+ expect(delta.outcome).toBe('findings');
130
+ // The human point the previous verdict resolved (a.ts untouched) was carried, not judged again.
131
+ expect(JSON.parse(seen.files['previous-resolved.json'])).toEqual([expect.objectContaining({ point: 'lock before read', resolved: true })]);
132
+ expect(delta.answerInReply).toEqual([]);
133
+ });
134
+ it('resolves a previous item only with evidence, and never blocks on an item that names code the checkout does not have', async () => {
135
+ const seen = seenNow();
136
+ 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' }, { class: 'dead-code', file: 'src/ghost.ts', line: 1, issue: 'unused' }, { class: 'dead-code', file: '', issue: 'somewhere, no file named' }] }), seen), () => undefined);
137
+ expect(first.items.map(i => i.file)).toEqual(['src/job.ts']);
138
+ 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
139
+ const id = first.items[0].id;
140
+ fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 2;\n}\n');
141
+ git('commit', '-qam', 'fix');
142
+ 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);
143
+ expect(delta.outcome).toBe('passed');
144
+ expect(delta.resolved).toEqual([{ item: expect.objectContaining({ id }), evidence: 'src/job.ts:2 the lock comes first now' }]);
145
+ });
146
+ it('at push, asks a model only when someone will read the push: an open, non-draft pull request', async () => {
147
+ const seen = seenNow();
148
+ const none = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'push' });
149
+ expect(none).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('no pull request for feature') });
150
+ expect(reviewerBlocks(none)).toBe(false);
151
+ expect(seen.prompts).toHaveLength(0);
152
+ const draft = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, { ...PR, isDraft: true }), () => undefined, { trigger: 'push' });
153
+ expect(draft.reason).toContain('is a draft');
154
+ const asked = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'review' });
155
+ expect(asked.outcome).toBe('passed'); // on request it reviews without a pull request
156
+ expect(seen.prompts).toHaveLength(1);
157
+ });
158
+ it('for a backtest, reads the named pull request and hides every review and comment from the reviewed moment on', async () => {
159
+ const seen = seenNow();
160
+ const hidden = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { pr: 42, reviewsBefore: '2026-10-03', force: true });
161
+ expect(seen.ghArgs.find(a => a[0] === 'pr')?.slice(0, 3)).toEqual(['pr', 'view', '42']);
162
+ expect(seen.files['previous-reviews.md']).toBe('none\n');
163
+ expect(hidden.outcome).toBe('passed');
164
+ const shown = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { pr: 42, reviewsBefore: '2026-10-04', force: true });
165
+ expect(seen.files['previous-reviews.md']).toContain('Review by senior');
166
+ expect(shown).toMatchObject({ outcome: 'unavailable', reason: 'claude did not report on the human reviews' });
79
167
  });
80
- it('finds the verdict when the reviewer wraps it in a summary or a code fence', () => {
81
- const verdict = '{"prior_points":[{"point":"p","resolved":false}],"blocking":[]}';
82
- for (const result of [`## Summary\nAll checked.\n\n\`\`\`json\n${verdict}\n\`\`\`\nDone.`, `Notes first.\n${verdict}\nThat is all {see above}.`]) {
83
- const parsed = parseVerdict({ exitCode: 0, stdout: JSON.stringify({ result }), stderr: '' }, true);
84
- expect(parsed).toMatchObject({ verdict: { prior_points: [{ point: 'p', resolved: false }] } });
168
+ it('never passes without a verdict: a crash, a malformed answer, an unreadable pull request or no installed reviewer', async () => {
169
+ const crashed = await runReviewer(repo, 'main', config, fakes(() => ({ exitCode: 1, stdout: '', stderr: 'API error' }), seenNow()), () => undefined);
170
+ expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('claude: no answer (exit 1)') });
171
+ const prose = await runReviewer(repo, 'main', config, fakes(() => 'Looks good to me!', seenNow()), () => undefined);
172
+ expect(prose).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
173
+ const broken = async (command, args, options) => command === 'gh' && args[0] === 'pr' ? { exitCode: 1, stdout: '', stderr: 'HTTP 500' } : fakes(() => '', seenNow())(command, args, options);
174
+ expect(await runReviewer(repo, 'main', config, broken, () => undefined)).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('could not read the pull request') });
175
+ const seen = { ...seenNow(), installed: [] };
176
+ expect(await runReviewer(repo, 'main', config, fakes(() => '', seen), () => undefined)).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no reviewer installed') });
177
+ for (const result of [crashed, prose])
178
+ expect(reviewerBlocks(result)).toBe(true);
179
+ });
180
+ it('in full mode runs two vendors and resolves a human point only when both say so', async () => {
181
+ const seen = seenNow();
182
+ 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' }] : [] });
183
+ const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
184
+ expect(result.reviewers).toEqual(['claude', 'cursor']);
185
+ expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor'], ['finding', 'cursor']]);
186
+ expect(seen.prompts).toHaveLength(2);
187
+ });
188
+ });
189
+ describe('choosing reviewers', () => {
190
+ it('runs the newest installed copy of a CLI, not the first on PATH', async () => {
191
+ const seen = seenNow();
192
+ const older = installFake(bins[0], 'claude');
193
+ const newer = installFake(bins[1], 'claude');
194
+ seen.versions = { [older]: '2.0.34 (Claude Code)', [newer]: '2.1.289 (Claude Code)' };
195
+ const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify(EMPTY), seen), () => undefined);
196
+ expect(result.outcome).toBe('passed');
197
+ expect(seen.ran).toEqual([newer]);
198
+ });
199
+ it('reads the vendors on the trailers, prefers another one in cross mode, and pairs two vendors in full mode', () => {
200
+ const authors = vendorsOf('Claude Opus <noreply@example.com>\n');
201
+ expect([...authors]).toEqual(['anthropic']);
202
+ const installed = new Set(['claude', 'cursor', 'codex']);
203
+ expect(selectReviewers(['claude', 'cursor', 'codex'], 'single', authors, installed)).toEqual(['claude']);
204
+ expect(selectReviewers(['claude', 'cursor', 'codex'], 'cross', authors, installed)).toEqual(['cursor']);
205
+ expect(selectReviewers(['claude', 'cursor', 'codex'], 'full', authors, installed)).toEqual(['cursor', 'claude']);
206
+ expect(selectReviewers(['claude', 'cursor'], 'cross', authors, new Set(['claude']))).toEqual(['claude']); // falls back to what is installed
207
+ expect(selectReviewers(['claude'], 'full', new Set(), new Set())).toEqual([]);
208
+ });
209
+ });
210
+ describe('verdicts', () => {
211
+ it('finds the verdict when the reviewer wraps it in a summary or a code fence, and refuses prose', () => {
212
+ const verdict = JSON.stringify(EMPTY);
213
+ for (const text of [`## Summary\nAll checked.\n\n\`\`\`json\n${verdict}\n\`\`\`\nDone.`, `Notes first.\n${verdict}\nThat is all {see above}.`]) {
214
+ expect(parseVerdict(text, true, 'claude', undefined)).toMatchObject({ verdict: { prior_points: [{ point: 'lock before read' }], reviewer: 'claude' } });
85
215
  }
216
+ expect(parseVerdict('Looks good to me!', false, 'claude', undefined)).toMatchObject({ error: expect.stringContaining('no valid verdict') });
217
+ expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude', undefined)).toEqual({ error: 'claude did not report on the human reviews' });
218
+ });
219
+ it('turns reads, scans, redundancy and merge impact into open items with stable ids, and answers non-blocking points in the reply', () => {
220
+ const verdict = {
221
+ ...EMPTY,
222
+ prior_points: [{ point: 'nit: rename', severity: 'non-blocking', resolved: false }],
223
+ 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 }],
224
+ scans: [{ file: 'src/s.ts', line: 3, function: 'later', outer: 'attempts', inner: 'answers', fix: 'index by attempt' }],
225
+ 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 }],
226
+ merge_impact: [{ symbol: 'parseId', main_file: 'src/id.ts', call_site: 'src/link.ts:4', holds: false, why: 'band dropped' }],
227
+ };
228
+ const { open, answerInReply } = account(verdict, undefined, () => true);
229
+ expect(open.map(i => `${i.class} ${i.file}:${i.line}`)).toEqual([
230
+ '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',
231
+ ]);
232
+ expect(new Set(open.map(i => i.id)).size).toBe(open.length);
233
+ expect(account(verdict, undefined, () => true).open.map(i => i.id)).toEqual(open.map(i => i.id)); // stable
234
+ expect(answerInReply).toEqual([expect.objectContaining({ point: 'nit: rename' })]);
86
235
  });
87
- it('holds a push for an open blocking point, not for a nit the reviewer left open', () => {
88
- const verdict = (blocking) => ({ prior_points: [{ point: 'p', resolved: false, blocking }], blocking: [], non_blocking: [] });
89
- expect(reviewerBlocks({ verdict: verdict(true), cached: false })).toBe(true);
90
- expect(reviewerBlocks({ verdict: verdict(false), cached: false })).toBe(false);
236
+ it('merges two verdicts: a point is resolved only when every reviewer resolves it', () => {
237
+ 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' }] };
238
+ const b = { ...EMPTY, reviewer: 'cursor', prior_points: [{ point: 'lock before read.', severity: 'blocking', resolved: false }], resolved_previous: [{ id: 'x', evidence: 'e' }] };
239
+ const merged = mergeVerdicts([a, b]);
240
+ expect(merged.prior_points).toEqual([expect.objectContaining({ resolved: false, reviewer: 'cursor' })]);
241
+ expect(merged.resolved_previous.map(r => r.id)).toEqual(['x']);
242
+ expect(merged.reviewers?.map(r => r.reviewer)).toEqual(['claude', 'cursor']);
91
243
  });
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);
244
+ it('carries a resolved human point into a delta verdict unless the new commits touch its evidence', () => {
245
+ 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' }] };
246
+ const fresh = { ...EMPTY, prior_points: [] };
247
+ expect(carryResolved(fresh, previous, new Set(['src/name.ts'])).prior_points.map(p => p.point)).toEqual(['lock before read']);
248
+ expect(carryResolved({ ...EMPTY, prior_points: [{ point: 'Lock before read', resolved: false }] }, previous, new Set()).prior_points).toHaveLength(2);
98
249
  });
99
250
  });
@@ -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
- export declare function runToolchain(cwd: string, changedFiles: string[], config: Config): Promise<ToolResult[]>;
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[]>;
@@ -2,13 +2,16 @@
2
2
  * The repository's own tools, run on what a change touched: the formatter, the linter, the type
3
3
  * checker and the tests that import changed files. Teams already trust these; Rigour runs them at
4
4
  * the moment that matters (before a push) so nobody has to remember. Only tools the project
5
- * installed are run (node_modules/.bin, never a download); a missing one is reported as skipped.
6
- * A tool configured under `commands:` is left to that command, which every review already runs.
5
+ * installed are run (node_modules/.bin, never a download). A tool the project never declared is
6
+ * skipped and said so; one declared in package.json but not installed FAILS, since a checkout
7
+ * without its dependencies cannot prove anything about the change. A tool configured under
8
+ * `commands:` is left to that command, which every review already runs.
7
9
  */
8
10
  import { execa } from 'execa';
9
11
  import fs from 'fs';
10
12
  import path from 'path';
11
13
  import { installedBin } from '../utils/installed-bin.js';
14
+ import { onChangedLines, overlayUnavailable, writeOverlayConfig } from './lint-overlay.js';
12
15
  import { ownOutputs } from './unused-exports.js';
13
16
  const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|svelte)$/;
14
17
  const FORMATTED = /\.(ts|tsx|js|jsx|mjs|cjs|svelte|json|css|md|ya?ml|html)$/;
@@ -16,7 +19,8 @@ const FORMATTED = /\.(ts|tsx|js|jsx|mjs|cjs|svelte|json|css|md|ya?ml|html)$/;
16
19
  const BATCH = 150;
17
20
  const TIMEOUT_MS = 10 * 60_000;
18
21
  const OUTPUT_LINES = 30;
19
- export async function runToolchain(cwd, changedFiles, config) {
22
+ /** `changedLines`: the lines the change touched, for the tools that report per line (the overlay blocks on those only). */
23
+ export async function runToolchain(cwd, changedFiles, config, changedLines = {}) {
20
24
  const own = ownOutputs(config);
21
25
  const files = changedFiles.filter(file => !own.some(o => file === o || file.startsWith(`${o}/`)) && fs.existsSync(path.join(cwd, file)));
22
26
  const code = files.filter(file => CODE.test(file));
@@ -27,14 +31,72 @@ export async function runToolchain(cwd, changedFiles, config) {
27
31
  };
28
32
  await run('format', () => formatCheck(cwd, files.filter(file => FORMATTED.test(file))));
29
33
  await run('lint', () => lint(cwd, code));
34
+ await run('overlay', () => lintOverlay(cwd, code, changedLines));
30
35
  await run('typecheck', () => typecheck(cwd));
31
36
  await run('test', () => relatedTests(cwd, code));
37
+ await run('knip', () => knip(cwd, files));
32
38
  return results;
33
39
  }
40
+ /**
41
+ * Type-checked rules the repository does not enable (lint-overlay.ts), on changed lines only. The
42
+ * repository's own eslint and typescript-eslint run it; without them it is skipped and said so.
43
+ */
44
+ async function lintOverlay(cwd, files, changedLines) {
45
+ const bin = installedBin(cwd, cwd, 'eslint');
46
+ if (!bin)
47
+ return skipped('overlay', 'eslint is not installed');
48
+ const unavailable = overlayUnavailable(cwd);
49
+ if (unavailable)
50
+ return skipped('overlay', unavailable);
51
+ const lintable = files.filter(file => /\.(ts|mts|cts|js|mjs|svelte)$/.test(file));
52
+ if (lintable.length === 0)
53
+ return { tool: 'overlay', status: 'pass', command: 'no lintable changed files' };
54
+ const configFile = writeOverlayConfig(cwd);
55
+ try {
56
+ const command = `eslint --config <typed overlay> --format json (${lintable.length} files)`;
57
+ const result = await execa(bin, ['--config', configFile, '--no-warn-ignored', '--format', 'json', ...lintable], { cwd, reject: false, timeout: TIMEOUT_MS, env: { ...process.env, CI: '1', FORCE_COLOR: '0' } });
58
+ const report = onChangedLines(String(result.stdout ?? ''), cwd, changedLines);
59
+ if ('error' in report)
60
+ return { tool: 'overlay', status: 'fail', command, output: `${report.error}\n${String(result.stderr ?? '').trim().split('\n').slice(-OUTPUT_LINES).join('\n')}` };
61
+ const lines = report.blocking.map(m => `${m.file}:${m.line}:${m.column} ${m.rule}: ${m.message}`);
62
+ const summary = `${command}: ${lines.length} on changed lines, ${report.preexisting} pre-existing (not blocking)`;
63
+ return lines.length ? { tool: 'overlay', status: 'fail', command: summary, output: lines.join('\n'), lines } : { tool: 'overlay', status: 'pass', command: summary };
64
+ }
65
+ finally {
66
+ fs.rmSync(path.dirname(configFile), { recursive: true, force: true });
67
+ }
68
+ }
69
+ /**
70
+ * knip, when the repository installs it, with the repository's own config: unused files and
71
+ * exports among the files the change touched (a pre-existing dead export elsewhere is backlog).
72
+ */
73
+ async function knip(cwd, files) {
74
+ const bin = installedBin(cwd, cwd, 'knip');
75
+ if (!bin)
76
+ return notInstalled('knip', cwd, 'knip');
77
+ if (files.length === 0)
78
+ return { tool: 'knip', status: 'pass', command: 'no changed files' };
79
+ const command = 'knip --production --reporter json';
80
+ const result = await execa(bin, ['--production', '--no-progress', '--reporter', 'json'], { cwd, reject: false, timeout: TIMEOUT_MS, env: { ...process.env, CI: '1', FORCE_COLOR: '0' } });
81
+ let report;
82
+ try {
83
+ report = JSON.parse(String(result.stdout ?? ''));
84
+ }
85
+ catch {
86
+ return { tool: 'knip', status: 'fail', command, output: `knip printed no JSON (exit ${result.exitCode})\n${String(result.stderr ?? '').trim().split('\n').slice(-OUTPUT_LINES).join('\n')}` };
87
+ }
88
+ const changed = new Set(files);
89
+ const normalize = (file) => path.isAbsolute(file) ? path.relative(cwd, file).split(path.sep).join('/') : file;
90
+ const lines = [
91
+ ...(report.files ?? []).map(normalize).filter(file => changed.has(file)).map(file => `${file}: nothing imports or runs this file`),
92
+ ...(report.issues ?? []).filter(issue => changed.has(normalize(issue.file))).flatMap(issue => [...(issue.exports ?? []), ...(issue.types ?? [])].map(e => `${normalize(issue.file)}${e.line ? `:${e.line}` : ''}: unused export ${e.name}`)),
93
+ ];
94
+ return lines.length ? { tool: 'knip', status: 'fail', command: `${command}: ${lines.length} in changed files`, output: lines.join('\n'), lines } : { tool: 'knip', status: 'pass', command };
95
+ }
34
96
  async function formatCheck(cwd, files) {
35
97
  const bin = installedBin(cwd, cwd, 'prettier');
36
98
  if (!bin)
37
- return skipped('format', 'prettier is not installed');
99
+ return notInstalled('format', cwd, 'prettier');
38
100
  if (files.length === 0)
39
101
  return { tool: 'format', status: 'pass', command: 'no files to format' };
40
102
  return batched('format', cwd, bin, ['--check', '--ignore-unknown'], files);
@@ -42,7 +104,7 @@ async function formatCheck(cwd, files) {
42
104
  async function lint(cwd, files) {
43
105
  const bin = installedBin(cwd, cwd, 'eslint');
44
106
  if (!bin)
45
- return skipped('lint', 'eslint is not installed');
107
+ return notInstalled('lint', cwd, 'eslint');
46
108
  if (files.length === 0)
47
109
  return { tool: 'lint', status: 'pass', command: 'no code files' };
48
110
  // ESLint 9 warns about an explicitly named ignored file, which --max-warnings=0 would count.
@@ -71,8 +133,21 @@ async function typecheck(cwd) {
71
133
  const tsc = fs.existsSync(path.join(cwd, 'tsconfig.json')) && installedBin(cwd, cwd, 'tsc');
72
134
  if (tsc)
73
135
  return once('typecheck', cwd, tsc, ['--noEmit']);
136
+ for (const pkg of ['svelte-check', 'typescript'])
137
+ if (declared(cwd, pkg))
138
+ return notInstalled('typecheck', cwd, pkg);
74
139
  return skipped('typecheck', 'no typecheck/check script, svelte-check or tsc with a tsconfig.json');
75
140
  }
141
+ function declared(cwd, pkg) {
142
+ const manifest = readPackage(cwd);
143
+ return !!(manifest.dependencies?.[pkg] ?? manifest.devDependencies?.[pkg]);
144
+ }
145
+ /** Declared in package.json but absent from node_modules: a checkout that cannot prove anything fails; a tool never declared is skipped. */
146
+ function notInstalled(tool, cwd, pkg) {
147
+ return declared(cwd, pkg)
148
+ ? { tool, status: 'fail', command: `${pkg} is in package.json but not installed`, output: `node_modules has no ${pkg}: install the dependencies (npm install, pnpm install, …) and run again` }
149
+ : skipped(tool, `${pkg} is not installed`);
150
+ }
76
151
  function readPackage(cwd) {
77
152
  try {
78
153
  return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -85,7 +160,7 @@ function readPackage(cwd) {
85
160
  async function relatedTests(cwd, files) {
86
161
  const bin = installedBin(cwd, cwd, 'vitest');
87
162
  if (!bin)
88
- return skipped('test', 'vitest is not installed');
163
+ return notInstalled('test', cwd, 'vitest');
89
164
  if (files.length === 0)
90
165
  return { tool: 'test', status: 'pass', command: 'no code files' };
91
166
  const first = await once('test', cwd, bin, ['related', '--run', '--passWithNoTests', ...files]);
@@ -25,7 +25,45 @@ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
25
25
  describe('runToolchain', () => {
26
26
  it('reports every tool the project did not install as skipped, never downloading one', async () => {
27
27
  const results = await runToolchain(dir, ['src/a.ts'], config());
28
- expect(results.map(r => [r.tool, r.status])).toEqual([['format', 'skipped'], ['lint', 'skipped'], ['typecheck', 'skipped'], ['test', 'skipped']]);
28
+ expect(results.map(r => [r.tool, r.status])).toEqual([['format', 'skipped'], ['lint', 'skipped'], ['overlay', 'skipped'], ['typecheck', 'skipped'], ['test', 'skipped'], ['knip', 'skipped']]);
29
+ });
30
+ it('fails, not skips, a tool the project declares but has not installed: a checkout without its dependencies proves nothing', async () => {
31
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ devDependencies: { eslint: '^9', typescript: '^5' } }));
32
+ const results = await runToolchain(dir, ['src/a.ts'], config());
33
+ expect(results.map(r => [r.tool, r.status])).toEqual([['format', 'skipped'], ['lint', 'fail'], ['overlay', 'skipped'], ['typecheck', 'fail'], ['test', 'skipped'], ['knip', 'skipped']]);
34
+ expect(results.find(r => r.tool === 'lint')).toMatchObject({ command: 'eslint is in package.json but not installed', output: expect.stringContaining('install the dependencies') });
35
+ });
36
+ it('runs type-checked rules the project does not enable, blocking on changed lines only, with the project\'s own eslint and config', async () => {
37
+ fs.writeFileSync(path.join(dir, 'eslint.config.js'), 'export default [];\n');
38
+ fs.mkdirSync(path.join(dir, 'node_modules/typescript-eslint'), { recursive: true });
39
+ fs.writeFileSync(path.join(dir, 'node_modules/typescript-eslint/package.json'), '{"name":"typescript-eslint","main":"index.js"}');
40
+ fs.writeFileSync(path.join(dir, 'node_modules/typescript-eslint/index.js'), 'module.exports = {};');
41
+ const report = JSON.stringify([{ filePath: path.join(dir, 'src/a.ts'), messages: [
42
+ { line: 2, column: 5, ruleId: '@typescript-eslint/no-unnecessary-condition', message: 'Unnecessary conditional, value is always truthy.' },
43
+ { line: 9, column: 1, ruleId: '@typescript-eslint/no-floating-promises', message: 'Promises must be awaited.' },
44
+ ] }]);
45
+ tool('eslint', `if (args[0] === '--version') { console.log('v9.0.0'); process.exit(0); }\nif (args.includes('--format')) { console.log(${JSON.stringify(report)}); process.exit(1); }\nprocess.exit(0);`);
46
+ const results = await runToolchain(dir, ['src/a.ts'], config(), { 'src/a.ts': new Set([1, 2, 3]) });
47
+ const overlay = results.find(r => r.tool === 'overlay');
48
+ expect(overlay).toMatchObject({ status: 'fail', lines: ['src/a.ts:2:5 @typescript-eslint/no-unnecessary-condition: Unnecessary conditional, value is always truthy.'] });
49
+ expect(overlay.command).toContain('1 on changed lines, 1 pre-existing (not blocking)');
50
+ const call = calls().split('\n').find(line => line.includes('--format json'));
51
+ expect(call).toMatch(/eslint --config \S+eslint\.overlay\.config\.mjs --no-warn-ignored --format json src\/a\.ts/);
52
+ expect(fs.readdirSync(dir)).not.toContain('eslint.overlay.config.mjs'); // never written into the repository
53
+ // Nothing on a changed line: pass, backlog counted.
54
+ const clean = await runToolchain(dir, ['src/a.ts'], config(), { 'src/a.ts': new Set([7]) });
55
+ expect(clean.find(r => r.tool === 'overlay')).toMatchObject({ status: 'pass', command: expect.stringContaining('0 on changed lines, 2 pre-existing') });
56
+ });
57
+ it('runs knip when the project installs it, and reports only what is in the changed files', async () => {
58
+ const report = JSON.stringify({ files: [path.join(dir, 'src/dead.ts'), 'src/elsewhere.ts'], issues: [
59
+ { file: 'src/a.ts', exports: [{ name: 'spare', line: 3, col: 1 }], types: [{ name: 'Spare', line: 4, col: 1 }] },
60
+ { file: 'src/other.ts', exports: [{ name: 'old', line: 1, col: 1 }], types: [] },
61
+ ] });
62
+ tool('knip', `console.log(${JSON.stringify(report)}); process.exit(1);`);
63
+ fs.writeFileSync(path.join(dir, 'src/dead.ts'), 'export const dead = 1;\n');
64
+ const results = await runToolchain(dir, ['src/a.ts', 'src/dead.ts'], config());
65
+ expect(results.find(r => r.tool === 'knip')).toMatchObject({ status: 'fail', lines: ['src/dead.ts: nothing imports or runs this file', 'src/a.ts:3: unused export spare', 'src/a.ts:4: unused export Spare'] });
66
+ expect(calls()).toContain('knip --production --no-progress --reporter json');
29
67
  });
30
68
  it('runs the formatter on changed files only and reports what it said', async () => {
31
69
  tool('prettier', "const bad = args.filter(a => a.endsWith('.ts') && fs.readFileSync(a, 'utf8').includes('BAD'));\nif (bad.length) { console.log('[warn] ' + bad.join(' ')); process.exit(1); }");
@@ -0,0 +1,27 @@
1
+ import type * as TS from 'typescript';
2
+ export interface TextFile {
3
+ file: string;
4
+ text: string;
5
+ }
6
+ export interface TypedProgram {
7
+ ts: typeof TS;
8
+ program: TS.Program;
9
+ checker: TS.TypeChecker;
10
+ root: string;
11
+ /** A source file's path relative to the root, posix. */
12
+ rel(sf: TS.SourceFile): string;
13
+ /** The sources (outside node_modules, no declaration files) that are not tests: where a host or a reader counts. */
14
+ appSources: TS.SourceFile[];
15
+ /** Files the program does not type, as text. */
16
+ textFiles: TextFile[];
17
+ inProgram: Set<string>;
18
+ }
19
+ export declare const TEST_FILE: RegExp;
20
+ /** The program, `skip` when this is not a TypeScript project, or `error` when it is one that cannot be loaded. */
21
+ export declare function loadProgram(root: string): {
22
+ program: TypedProgram;
23
+ } | {
24
+ skip: string;
25
+ } | {
26
+ error: string;
27
+ };