@rigour-labs/core 6.6.5 → 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.
- 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 +4 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/stop-review.d.ts +6 -9
- package/dist/hooks/stop-review.js +5 -10
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- 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.d.ts +315 -0
- package/dist/review/backtest.js +197 -0
- package/dist/review/backtest.test.d.ts +1 -0
- package/dist/review/backtest.test.js +129 -0
- package/dist/review/dead-code.test.js +8 -0
- package/dist/review/lint-overlay.d.ts +21 -0
- package/dist/review/lint-overlay.js +89 -0
- 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 +8 -1
- package/dist/review/reviewer/adapters.d.ts +35 -0
- package/dist/review/reviewer/adapters.js +156 -0
- package/dist/review/reviewer/background.d.ts +34 -0
- package/dist/review/reviewer/background.js +128 -0
- package/dist/review/reviewer/background.test.d.ts +1 -0
- package/dist/review/reviewer/background.test.js +104 -0
- package/dist/review/reviewer/exec.d.ts +17 -0
- package/dist/review/reviewer/exec.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +37 -0
- package/dist/review/reviewer/inputs.js +103 -0
- package/dist/review/reviewer/merge-impact.d.ts +2 -0
- package/dist/review/reviewer/merge-impact.js +64 -0
- package/dist/review/reviewer/prompt.d.ts +21 -0
- package/dist/review/reviewer/prompt.js +123 -0
- package/dist/review/reviewer/store.d.ts +26 -0
- package/dist/review/reviewer/store.js +57 -0
- package/dist/review/reviewer/verdict.d.ts +127 -0
- package/dist/review/reviewer/verdict.js +156 -0
- package/dist/review/reviewer.d.ts +41 -49
- package/dist/review/reviewer.js +210 -202
- package/dist/review/reviewer.test.js +199 -48
- package/dist/review/toolchain.d.ts +5 -2
- package/dist/review/toolchain.js +81 -6
- package/dist/review/toolchain.test.js +39 -1
- package/dist/review/typed/program.d.ts +27 -0
- package/dist/review/typed/program.js +90 -0
- package/dist/review/typed/redundancy.d.ts +10 -0
- package/dist/review/typed/redundancy.js +339 -0
- package/dist/review/typed/redundancy.test.d.ts +1 -0
- package/dist/review/typed/redundancy.test.js +121 -0
- package/dist/review/unused-exports.js +8 -4
- package/dist/templates/universal-config.js +8 -1
- package/dist/types/index.d.ts +94 -8
- package/dist/types/index.js +24 -2
- 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 {
|
|
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
|
-
|
|
18
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
71
|
-
const seen =
|
|
72
|
-
const
|
|
73
|
-
await runReviewer(repo, 'main', config, fakes(
|
|
74
|
-
|
|
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(
|
|
78
|
-
|
|
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('
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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('
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
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('
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
expect(
|
|
96
|
-
|
|
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
|
-
|
|
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[]>;
|
package/dist/review/toolchain.js
CHANGED
|
@@ -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)
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|