@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { ConfigSchema } from '../types/index.js';
|
|
7
|
+
import { backtestPassed, formatBacktest, ledgerProblems, loadLedger, runBacktest, score, LEDGER_PATH } from './backtest.js';
|
|
8
|
+
const item = (over = {}) => ({ gate: 'unused-export', file: 'src/lib/server/billing/invoices.ts', line: 40, text: 'Unused export `isRefund` is not imported anywhere', blocking: true, ...over });
|
|
9
|
+
describe('a ledger point matches a finding', () => {
|
|
10
|
+
const caught = (row, finding) => score({ id: 'r', commit: 'abcdef0', base: 'main', points: [{ id: 'P', point: 'p', ...row }], must_not_flag: [] }, 'h', [finding], 0, undefined).points[0].caught;
|
|
11
|
+
it('by file, then by line window or text pattern, case-insensitively', () => {
|
|
12
|
+
expect(caught({ file: 'invoices', lines: [30, 50] }, item())).toBe(true);
|
|
13
|
+
expect(caught({ file: 'invoices', lines: [41, 50] }, item())).toBe(false);
|
|
14
|
+
expect(caught({ file: 'invoices', text: 'isrefund|twice' }, item())).toBe(true);
|
|
15
|
+
expect(caught({ file: 'invoices', lines: [41, 50], text: 'isRefund' }, item())).toBe(true); // either is enough
|
|
16
|
+
expect(caught({ file: 'receipts', text: 'isRefund' }, item())).toBe(false); // the file must match
|
|
17
|
+
expect(caught({ file: 'invoices', lines: [30, 50] }, item({ line: undefined }))).toBe(false); // a lineless finding cannot be placed in a window
|
|
18
|
+
});
|
|
19
|
+
it('rejects rows that can match nothing before any round runs', () => {
|
|
20
|
+
const ledger = { rounds: [{ id: 'r1', commit: 'abcdef0', base: 'main', points: [
|
|
21
|
+
{ id: 'A', point: 'p', file: 'x', lines: [1, 2] },
|
|
22
|
+
{ id: 'B', point: 'p', file: '', text: 'x' },
|
|
23
|
+
{ id: 'C', point: 'p', file: 'x' },
|
|
24
|
+
{ id: 'D', point: 'p', file: 'x', text: 'x', needs: 'a text pattern' },
|
|
25
|
+
{ id: 'E', point: 'p', file: '[', text: 'x' },
|
|
26
|
+
], must_not_flag: [{ file: 'x' }] }] };
|
|
27
|
+
expect(ledgerProblems(ledger)).toEqual([
|
|
28
|
+
'r1 B: no file pattern',
|
|
29
|
+
'r1 C: needs a line window or a text pattern',
|
|
30
|
+
'r1 D: needs a text pattern',
|
|
31
|
+
'r1 E: not a regular expression',
|
|
32
|
+
'r1 must_not_flag[0]: needs a line window or a text pattern',
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('scoring a round', () => {
|
|
37
|
+
const round = {
|
|
38
|
+
id: 'r1', commit: 'abcdef0', base: 'main',
|
|
39
|
+
points: [
|
|
40
|
+
{ id: 'P1', point: 'dead export', file: 'invoices', lines: [30, 50] },
|
|
41
|
+
{ id: 'P2', point: 'copies the accumulator', file: 'totals', text: 'copied|quadratic' },
|
|
42
|
+
{ id: 'P3', point: 'drops the currency', file: 'checkout', text: 'currency' },
|
|
43
|
+
],
|
|
44
|
+
must_not_flag: [{ file: 'session', text: 'cookie' }],
|
|
45
|
+
};
|
|
46
|
+
it('counts a point as caught only by a blocking finding; an advisory match is noted and still missed', () => {
|
|
47
|
+
const items = [item(), item({ gate: 'quadratic-copy', file: 'src/totals.ts', line: 128, text: 'accumulator copied whole', blocking: false }), item({ gate: 'reviewer', file: 'src/hooks/session.ts', line: 53, text: 'the cookie is cleared' })];
|
|
48
|
+
const result = score(round, 'abcdef0123456', items, 1200, undefined);
|
|
49
|
+
expect(result.points).toEqual([
|
|
50
|
+
{ id: 'P1', point: 'dead export', caught: true, noted: false, by: 'unused-export src/lib/server/billing/invoices.ts:40' },
|
|
51
|
+
{ id: 'P2', point: 'copies the accumulator', caught: false, noted: true },
|
|
52
|
+
{ id: 'P3', point: 'drops the currency', caught: false, noted: false },
|
|
53
|
+
]);
|
|
54
|
+
expect(result.falseBlocks).toHaveLength(1);
|
|
55
|
+
expect(backtestPassed([result])).toBe(false);
|
|
56
|
+
expect(formatBacktest([result])).toContain('r1 at abcdef012: 1/3 caught, 1 false block(s), 3 finding(s), 1s; caught by unused-export 1');
|
|
57
|
+
expect(formatBacktest([result])).toContain('noted P2 copies the accumulator (advisory only)');
|
|
58
|
+
expect(formatBacktest([result])).toContain('MISSED P3');
|
|
59
|
+
expect(formatBacktest([result])).toContain('FALSE reviewer src/hooks/session.ts:53');
|
|
60
|
+
});
|
|
61
|
+
it('passes only when every point is caught, nothing good is flagged and the reviewer answered', () => {
|
|
62
|
+
const all = [item(), item({ gate: 'quadratic-copy', file: 'src/totals.ts', text: 'copied whole' }), item({ gate: 'reviewer', file: 'src/checkout.ts', text: 'drops the currency' })];
|
|
63
|
+
expect(backtestPassed([score(round, 'h', all, 1, undefined)])).toBe(true);
|
|
64
|
+
expect(backtestPassed([score(round, 'h', all, 1, 'the reviewer did not answer')])).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('rigour backtest on a repository', () => {
|
|
68
|
+
let repo;
|
|
69
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
70
|
+
const write = (rel, body) => {
|
|
71
|
+
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
72
|
+
fs.writeFileSync(path.join(repo, rel), body);
|
|
73
|
+
};
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'backtest-'));
|
|
76
|
+
git('init', '-q', '-b', 'main');
|
|
77
|
+
git('config', 'user.email', 't@example.com');
|
|
78
|
+
git('config', 'user.name', 't');
|
|
79
|
+
git('config', 'commit.gpgsign', 'false');
|
|
80
|
+
write('src/app.ts', 'export const app = 1;\n');
|
|
81
|
+
git('add', '-A');
|
|
82
|
+
git('commit', '-qm', 'base');
|
|
83
|
+
git('checkout', '-qb', 'feature');
|
|
84
|
+
write('src/lib/util.ts', 'export function helper(): number {\n return 1;\n}\n');
|
|
85
|
+
git('add', '-A');
|
|
86
|
+
git('commit', '-qm', 'add a helper nothing uses');
|
|
87
|
+
});
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
try {
|
|
90
|
+
git('worktree', 'prune');
|
|
91
|
+
}
|
|
92
|
+
catch { /* the repository may be gone */ }
|
|
93
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
it('reviews the reviewed commit in a worktree with the review hidden, scores it, and leaves the checkout alone', async () => {
|
|
96
|
+
const reviewed = git('rev-parse', 'HEAD');
|
|
97
|
+
const base = git('rev-parse', 'main');
|
|
98
|
+
git('checkout', '-q', 'main'); // the person runs it from wherever they are
|
|
99
|
+
write(LEDGER_PATH, JSON.stringify({ rounds: [{
|
|
100
|
+
id: 'r1', commit: reviewed, base,
|
|
101
|
+
points: [
|
|
102
|
+
{ id: 'R1-1', point: 'helper is never imported', file: 'util\\.ts', lines: [1, 3] },
|
|
103
|
+
{ id: 'R1-2', point: 'something no gate knows', file: 'util', text: 'quadratic' },
|
|
104
|
+
],
|
|
105
|
+
must_not_flag: [{ file: 'app\\.ts', lines: [1, 1] }],
|
|
106
|
+
}] }));
|
|
107
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
108
|
+
const results = await runBacktest(repo, config, loadLedger(repo), { progress: () => undefined });
|
|
109
|
+
expect(results).toHaveLength(1);
|
|
110
|
+
expect(results[0].points).toMatchObject([{ id: 'R1-1', caught: true, by: 'unused-export src/lib/util.ts:1' }, { id: 'R1-2', caught: false, noted: false }]);
|
|
111
|
+
expect(results[0].falseBlocks).toEqual([]);
|
|
112
|
+
expect(results[0].head).toBe(reviewed);
|
|
113
|
+
expect(backtestPassed(results)).toBe(false);
|
|
114
|
+
expect(git('branch', '--show-current')).toBe('main');
|
|
115
|
+
expect(fs.existsSync(path.join(repo, 'src/lib/util.ts'))).toBe(false); // the checkout did not move
|
|
116
|
+
expect(fs.existsSync(path.join(repo, '.git/rigour-backtest', reviewed.slice(0, 12), 'src/lib/util.ts'))).toBe(true);
|
|
117
|
+
expect(fs.readdirSync(path.join(repo, '.rigour/backtest'))).toEqual([`r1-${reviewed.slice(0, 12)}.json`]);
|
|
118
|
+
// The worktree is reused on the next run.
|
|
119
|
+
const again = await runBacktest(repo, config, loadLedger(repo), { round: 'r1', progress: () => undefined });
|
|
120
|
+
expect(again[0].points[0].caught).toBe(true);
|
|
121
|
+
}, 60_000); // a real worktree and a typed review: over 10s on a Windows runner
|
|
122
|
+
it('names a round or a commit it cannot find', async () => {
|
|
123
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
124
|
+
const ledger = { rounds: [{ id: 'r1', commit: '0000000000000000000000000000000000000000', base: 'main', points: [{ id: 'P', point: 'p', file: 'x', text: 'x' }], must_not_flag: [] }] };
|
|
125
|
+
await expect(runBacktest(repo, config, ledger, { round: 'r9' })).rejects.toThrow('no round "r9"');
|
|
126
|
+
await expect(runBacktest(repo, config, ledger, { progress: () => undefined })).rejects.toThrow('is not in this repository');
|
|
127
|
+
expect(() => loadLedger(repo)).toThrow('no ledger');
|
|
128
|
+
}, 60_000); // a real worktree and a typed review: over 10s on a Windows runner
|
|
129
|
+
});
|
package/dist/review/baseline.js
CHANGED
|
@@ -39,7 +39,8 @@ export async function baseFindings(cwd, config, commit, files) {
|
|
|
39
39
|
const modules = path.join(cwd, 'node_modules');
|
|
40
40
|
if (fs.existsSync(modules))
|
|
41
41
|
fs.symlinkSync(modules, path.join(dir, 'node_modules'), 'junction');
|
|
42
|
-
|
|
42
|
+
// The team's commands find nothing to compare per finding (they report no file), so the base copy never runs them.
|
|
43
|
+
const report = await new GateRunner({ ...config, commands: {} }).run(dir, await normalizeScopePatterns(dir, files), undefined, { record: false });
|
|
43
44
|
return { failures: report.failures, added };
|
|
44
45
|
}
|
|
45
46
|
finally {
|
|
@@ -35,6 +35,14 @@ describe('unused exports', () => {
|
|
|
35
35
|
expect(failures.map(f => [f.files?.[0], f.line, f.id])).toEqual([['src/app.ts', 4, 'unused-export']]);
|
|
36
36
|
expect(failures[0].details).toContain('`lonely`');
|
|
37
37
|
});
|
|
38
|
+
it('does not count a test as a consumer: an export only its tests import is dead in production', () => {
|
|
39
|
+
write('src/app.ts', 'export function start() {}\n\nexport function onlyTested() { return 1; }\nexport function used() { return 2; }\n');
|
|
40
|
+
write('src/app.test.ts', "import { onlyTested, used } from './app';\nonlyTested();\nused();\n");
|
|
41
|
+
write('tests/helpers.ts', "import { onlyTested } from '../src/app';\nonlyTested();\n");
|
|
42
|
+
write('src/use.ts', "import { used } from './app';\nused();\n");
|
|
43
|
+
const names = unusedExportFailures(repo, diffFromGit(repo), config()).map(f => f.details.match(/`([^`]+)`/)[1]);
|
|
44
|
+
expect(names).toEqual(['onlyTested']);
|
|
45
|
+
});
|
|
38
46
|
it('reports a re-export nothing imports, without counting the module it comes from as a use', () => {
|
|
39
47
|
write('src/types.ts', 'export type Row = { id: string };\nexport type Used = { n: number };\n');
|
|
40
48
|
write('src/index.ts', "import { start } from './app';\nstart();\nexport type { Row, Used } from './types';\n");
|
|
@@ -43,6 +51,13 @@ describe('unused exports', () => {
|
|
|
43
51
|
expect(names).toContainEqual(['src/index.ts', 'Row']);
|
|
44
52
|
expect(names).not.toContainEqual(['src/index.ts', 'Used']);
|
|
45
53
|
});
|
|
54
|
+
it('never reports a type-test file as orphaned: tsd runs test-d/ and *.test-d.ts by itself', () => {
|
|
55
|
+
write('test-d/types.ts', "import { start } from '../src/app';\nstart();\n");
|
|
56
|
+
write('src/app.test-d.ts', "import { start } from './app';\nstart();\n");
|
|
57
|
+
const orphans = orphanFileFailures(repo, diffFromGit(repo), config()).map(f => f.files?.[0]);
|
|
58
|
+
expect(orphans).not.toContain('test-d/types.ts');
|
|
59
|
+
expect(orphans).not.toContain('src/app.test-d.ts');
|
|
60
|
+
});
|
|
46
61
|
it('skips what a framework calls by name, and names the team allows', () => {
|
|
47
62
|
write('src/routes/+page.server.ts', 'export const load = () => ({});\nexport const helper = 1;\n');
|
|
48
63
|
write('src/plugin.ts', 'export const register = () => {};\n');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Exec } from './reviewer/exec.js';
|
|
2
|
+
export interface FixScope {
|
|
3
|
+
pr: number;
|
|
4
|
+
review: {
|
|
5
|
+
id: number;
|
|
6
|
+
login: string;
|
|
7
|
+
submittedAt: string;
|
|
8
|
+
commit: string;
|
|
9
|
+
};
|
|
10
|
+
cited: string[];
|
|
11
|
+
changed: string[];
|
|
12
|
+
extra: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare function fixScope(cwd: string, options: {
|
|
15
|
+
pr: number | undefined;
|
|
16
|
+
review: number | undefined;
|
|
17
|
+
githubAccount: string | undefined;
|
|
18
|
+
}, exec?: Exec): Promise<{
|
|
19
|
+
scope?: FixScope;
|
|
20
|
+
error?: string;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fix round's scope: the files the branch's own commits changed since the review they answer,
|
|
3
|
+
* less the files that review cited. A fix round changes what the review asked for and nothing
|
|
4
|
+
* else, so every extra file is either a point the reviewer never made or work for a follow-up.
|
|
5
|
+
*
|
|
6
|
+
* Cited means: the path of an inline comment of that review round (every review a person
|
|
7
|
+
* submitted on the same commit), or a tracked file the review text names (`src/a.ts:12`,
|
|
8
|
+
* `a.ts`, a blob link), when the name points at exactly one file. A test of a cited file is in
|
|
9
|
+
* scope when it sits beside it (or in its `__tests__`). Merges are left out (a merge from main brings main's files, not the round's), and so is
|
|
10
|
+
* a file the round changed and then put back.
|
|
11
|
+
*/
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { defaultExec, githubEnv, parseJsonArrays } from './reviewer/exec.js';
|
|
14
|
+
import { findPullRequest, ghFor, isHuman } from './reviewer/inputs.js';
|
|
15
|
+
import { isTestFile } from './test-files.js';
|
|
16
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
17
|
+
export async function fixScope(cwd, options, exec = defaultExec) {
|
|
18
|
+
const git = async (args) => exec('git', args, { cwd, timeoutMs: GIT_TIMEOUT_MS });
|
|
19
|
+
const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
|
|
20
|
+
const head = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
21
|
+
if (!head)
|
|
22
|
+
return { error: 'not a git repository' };
|
|
23
|
+
const gh = ghFor(cwd, exec, await githubEnv(cwd, options.githubAccount, exec));
|
|
24
|
+
const found = await findPullRequest(gh, branch, head, options.pr);
|
|
25
|
+
if (!found.pr)
|
|
26
|
+
return { error: found.error ?? `no pull request for ${branch}: name one with --scope <number>` };
|
|
27
|
+
const pr = found.pr;
|
|
28
|
+
const reviews = await gh(['api', `repos/{owner}/{repo}/pulls/${pr.number}/reviews`, '--paginate']);
|
|
29
|
+
const comments = await gh(['api', `repos/{owner}/{repo}/pulls/${pr.number}/comments`, '--paginate']);
|
|
30
|
+
if (reviews.exitCode !== 0 || comments.exitCode !== 0)
|
|
31
|
+
return { error: `could not read the reviews of pull request ${pr.number}: ${(reviews.stderr || comments.stderr).trim().slice(0, 200)}` };
|
|
32
|
+
const human = parseJsonArrays(reviews.stdout).filter((r) => isHuman(r, pr.author) && r.commit_id);
|
|
33
|
+
const target = options.review !== undefined ? human.find((r) => r.id === options.review) : human.at(-1);
|
|
34
|
+
if (!target)
|
|
35
|
+
return { error: options.review !== undefined ? `no review ${options.review} by a person on pull request ${pr.number}` : `pull request ${pr.number} has no review by a person yet` };
|
|
36
|
+
if ((await git(['merge-base', '--is-ancestor', target.commit_id, 'HEAD'])).exitCode !== 0) {
|
|
37
|
+
return { error: `the reviewed commit ${String(target.commit_id).slice(0, 9)} is not behind HEAD: fetch it, or the branch was rewritten since the review` };
|
|
38
|
+
}
|
|
39
|
+
const round = human.filter((r) => r.commit_id === target.commit_id);
|
|
40
|
+
const roundIds = new Set(round.map((r) => r.id));
|
|
41
|
+
const inline = parseJsonArrays(comments.stdout).filter((c) => isHuman(c, pr.author) && roundIds.has(c.pull_request_review_id));
|
|
42
|
+
const tracked = trackedIndex((await git(['ls-files'])).stdout.split('\n').filter(Boolean));
|
|
43
|
+
const cited = new Set(inline.map((c) => String(c.path)));
|
|
44
|
+
for (const text of [...round.map((r) => r.body), ...inline.map((c) => c.body)])
|
|
45
|
+
for (const file of mentioned(String(text ?? ''), tracked))
|
|
46
|
+
cited.add(file);
|
|
47
|
+
const changed = await changedByTheRound(git, target.commit_id);
|
|
48
|
+
const covered = (file) => cited.has(file) || (isTestFile(file) && [...cited].some(c => subject(c) === subject(file)));
|
|
49
|
+
return {
|
|
50
|
+
scope: {
|
|
51
|
+
pr: pr.number,
|
|
52
|
+
review: { id: target.id, login: target.user.login, submittedAt: String(target.submitted_at ?? ''), commit: target.commit_id },
|
|
53
|
+
cited: [...cited].sort(),
|
|
54
|
+
changed,
|
|
55
|
+
extra: changed.filter(file => !covered(file)),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Files the branch's own commits (not its merges) changed since `commit`, and that still differ from it. */
|
|
60
|
+
async function changedByTheRound(git, commit) {
|
|
61
|
+
const own = new Set((await git(['log', '--first-parent', '--no-merges', '--name-only', '--format=', `${commit}..HEAD`])).stdout.split('\n').filter(Boolean));
|
|
62
|
+
const differ = (await git(['diff', '--name-only', commit, 'HEAD'])).stdout.split('\n').filter(Boolean);
|
|
63
|
+
return differ.filter(file => own.has(file)).sort();
|
|
64
|
+
}
|
|
65
|
+
/** Tracked files by base name, built once for every review text. */
|
|
66
|
+
function trackedIndex(files) {
|
|
67
|
+
const index = new Map();
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const base = path.posix.basename(file);
|
|
70
|
+
const same = index.get(base);
|
|
71
|
+
if (same)
|
|
72
|
+
same.push(file);
|
|
73
|
+
else
|
|
74
|
+
index.set(base, [file]);
|
|
75
|
+
}
|
|
76
|
+
return index;
|
|
77
|
+
}
|
|
78
|
+
/** Tracked files a review's text names, by path, path suffix or blob link, when the name is unambiguous. */
|
|
79
|
+
function mentioned(text, tracked) {
|
|
80
|
+
const found = new Set();
|
|
81
|
+
for (const raw of text.match(/[\w@$./-]+\.[A-Za-z][\w]*/g) ?? []) {
|
|
82
|
+
const name = raw.replace(/^.*\/blob\/[^/]+\//, '').replace(/^\.\//, '');
|
|
83
|
+
const matches = (tracked.get(path.posix.basename(name)) ?? []).filter(file => file === name || file.endsWith(`/${name}`));
|
|
84
|
+
const exact = matches.find(file => file === name);
|
|
85
|
+
if (exact || matches.length === 1)
|
|
86
|
+
found.add(exact ?? matches[0]);
|
|
87
|
+
}
|
|
88
|
+
return [...found];
|
|
89
|
+
}
|
|
90
|
+
/** What a file is about: `src/a.ts`, `src/a.test.ts` and `src/__tests__/a.spec.ts` are all `src/a`. */
|
|
91
|
+
function subject(file) {
|
|
92
|
+
const dir = path.posix.dirname(file).replace(/\/(__tests__|tests?)$/, '');
|
|
93
|
+
return path.posix.join(dir, path.posix.basename(file).replace(/\.(test|spec|test-d)(?=\.)/, '').replace(/\.[^.]+$/, ''));
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { fixScope as scopeWith } from './fix-scope.js';
|
|
7
|
+
let repo;
|
|
8
|
+
let reviewed;
|
|
9
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
10
|
+
const write = (rel, body) => {
|
|
11
|
+
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
12
|
+
fs.writeFileSync(path.join(repo, rel), body);
|
|
13
|
+
};
|
|
14
|
+
const commit = (message) => {
|
|
15
|
+
git('add', '-A');
|
|
16
|
+
git('commit', '-qm', message);
|
|
17
|
+
};
|
|
18
|
+
const PR = { number: 42, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: '' };
|
|
19
|
+
const user = (login, type = 'User') => ({ login, type });
|
|
20
|
+
let reviews;
|
|
21
|
+
let comments;
|
|
22
|
+
/** Real git; gh answers from `reviews` and `comments`. */
|
|
23
|
+
const exec = async (command, args, options) => {
|
|
24
|
+
if (command === 'git') {
|
|
25
|
+
try {
|
|
26
|
+
return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8', stdio: 'pipe' }), stderr: '' };
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return { exitCode: 1, stdout: '', stderr: String(error.message) };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (args[0] === 'pr')
|
|
33
|
+
return { exitCode: 0, stdout: JSON.stringify(PR), stderr: '' };
|
|
34
|
+
if (args[1]?.endsWith('/reviews'))
|
|
35
|
+
return { exitCode: 0, stdout: JSON.stringify(reviews), stderr: '' };
|
|
36
|
+
if (args[1]?.endsWith('/comments'))
|
|
37
|
+
return { exitCode: 0, stdout: JSON.stringify(comments), stderr: '' };
|
|
38
|
+
return { exitCode: 1, stdout: '', stderr: 'unexpected gh call' };
|
|
39
|
+
};
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'fix-scope-'));
|
|
42
|
+
git('init', '-q', '-b', 'main');
|
|
43
|
+
git('config', 'user.email', 't@example.com');
|
|
44
|
+
git('config', 'user.name', 't');
|
|
45
|
+
git('config', 'commit.gpgsign', 'false');
|
|
46
|
+
for (const file of ['src/job.ts', 'src/job.test.ts', 'src/util.ts', 'src/deep/thing.ts', 'src/other.ts', 'src/temp.ts', 'src/mainside.ts', 'lib/util.ts'])
|
|
47
|
+
write(file, 'export const x = 1;\n');
|
|
48
|
+
commit('base');
|
|
49
|
+
git('checkout', '-qb', 'feature');
|
|
50
|
+
write('src/job.ts', 'export const x = 2;\n');
|
|
51
|
+
commit('the change under review');
|
|
52
|
+
reviewed = git('rev-parse', 'HEAD');
|
|
53
|
+
reviews = [
|
|
54
|
+
{ id: 1, user: user('ci-bot', 'Bot'), body: 'see src/other.ts', submitted_at: '2026-10-01', commit_id: reviewed },
|
|
55
|
+
{ id: 2, user: user('author'), body: 'I will also tidy src/other.ts', submitted_at: '2026-10-02', commit_id: reviewed },
|
|
56
|
+
{ id: 3, user: user('senior'), body: 'Bound the window in `src/util.ts:4`. The scan in https://github.com/o/r/blob/abc123/src/deep/thing.ts#L3 is quadratic.', submitted_at: '2026-10-03', commit_id: reviewed },
|
|
57
|
+
{ id: 4, user: user('second'), body: '', submitted_at: '2026-10-03', commit_id: reviewed },
|
|
58
|
+
];
|
|
59
|
+
comments = [
|
|
60
|
+
{ id: 9, pull_request_review_id: 4, user: user('second'), path: 'src/job.ts', body: 'Lock before the first read.' },
|
|
61
|
+
{ id: 10, pull_request_review_id: 2, user: user('author'), path: 'src/temp.ts', body: 'my own note' },
|
|
62
|
+
];
|
|
63
|
+
});
|
|
64
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
65
|
+
const fixScope = (cwd, named, run) => scopeWith(cwd, { pr: undefined, review: named.review, githubAccount: undefined }, run);
|
|
66
|
+
describe('the scope of a fix round', () => {
|
|
67
|
+
it('lists the files the round changed that no point of the review cited', async () => {
|
|
68
|
+
for (const file of ['src/job.ts', 'src/job.test.ts', 'src/util.ts', 'src/deep/thing.ts', 'src/other.ts'])
|
|
69
|
+
write(file, 'export const x = 3;\n');
|
|
70
|
+
commit('fix round');
|
|
71
|
+
write('src/temp.ts', 'export const x = 9;\n');
|
|
72
|
+
commit('try something');
|
|
73
|
+
write('src/temp.ts', 'export const x = 1;\n');
|
|
74
|
+
commit('put it back');
|
|
75
|
+
git('checkout', '-q', 'main');
|
|
76
|
+
write('src/mainside.ts', 'export const x = 4;\n');
|
|
77
|
+
commit('main moves on');
|
|
78
|
+
git('checkout', '-q', 'feature');
|
|
79
|
+
git('merge', '-q', '--no-edit', 'main');
|
|
80
|
+
const { scope, error } = await fixScope(repo, {}, exec);
|
|
81
|
+
expect(error).toBeUndefined();
|
|
82
|
+
expect(scope).toMatchObject({ pr: 42, review: { id: 4, login: 'second', commit: reviewed } });
|
|
83
|
+
expect(scope.cited).toEqual(['src/deep/thing.ts', 'src/job.ts', 'src/util.ts']);
|
|
84
|
+
expect(scope.changed).toEqual(['src/deep/thing.ts', 'src/job.test.ts', 'src/job.ts', 'src/other.ts', 'src/util.ts']);
|
|
85
|
+
expect(scope.extra).toEqual(['src/other.ts']);
|
|
86
|
+
});
|
|
87
|
+
it('takes a named review as the round, and an ambiguous file name cites nothing', async () => {
|
|
88
|
+
write('src/job.ts', 'export const x = 3;\n');
|
|
89
|
+
commit('first fix round');
|
|
90
|
+
reviews.push({ id: 5, user: user('senior'), body: 'and util.ts too', submitted_at: '2026-10-04', commit_id: git('rev-parse', 'HEAD') });
|
|
91
|
+
write('src/util.ts', 'export const x = 3;\n');
|
|
92
|
+
commit('second fix round');
|
|
93
|
+
const named = await fixScope(repo, { review: 5 }, exec);
|
|
94
|
+
expect(named.scope.cited).toEqual([]);
|
|
95
|
+
expect(named.scope.extra).toEqual(['src/util.ts']);
|
|
96
|
+
});
|
|
97
|
+
it('says why it cannot answer', async () => {
|
|
98
|
+
expect((await fixScope(repo, { review: 99 }, exec)).error).toBe('no review 99 by a person on pull request 42');
|
|
99
|
+
reviews = reviews.filter((r) => r.user.type === 'Bot');
|
|
100
|
+
expect((await fixScope(repo, {}, exec)).error).toBe('pull request 42 has no review by a person yet');
|
|
101
|
+
reviews = [{ id: 3, user: user('senior'), body: 'x', submitted_at: '2026-10-03', commit_id: 'f'.repeat(40) }];
|
|
102
|
+
expect((await fixScope(repo, {}, exec)).error).toMatch(/^the reviewed commit fffffffff is not behind HEAD/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface OverlayMessage {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
column: number;
|
|
5
|
+
rule: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
/** Why the overlay cannot run here, or undefined when it can. */
|
|
9
|
+
export declare function overlayUnavailable(cwd: string): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Writes the overlay config to a scratch directory and returns its path. It is an ES module that
|
|
12
|
+
* imports the repository's config by absolute path, so it is never written into the repository.
|
|
13
|
+
*/
|
|
14
|
+
export declare function writeOverlayConfig(cwd: string): string;
|
|
15
|
+
/** ESLint's JSON report, kept to messages on changed lines; the rest is counted. */
|
|
16
|
+
export declare function onChangedLines(json: string, cwd: string, changedLines: Record<string, Set<number>>): {
|
|
17
|
+
blocking: OverlayMessage[];
|
|
18
|
+
preexisting: number;
|
|
19
|
+
} | {
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-checked lint rules the repository does not enable yet, on changed lines only. They are the
|
|
3
|
+
* mechanical dead-code classes a person keeps finding after lint is green: a condition that can
|
|
4
|
+
* never be true once the types say so, a `?.` on a member every host supplies, a redundant
|
|
5
|
+
* comparison, a promise nobody awaits. The overlay loads the repository's own flat config first,
|
|
6
|
+
* so nothing it enforces is lost, and uses its own `typescript-eslint` (never a download). A
|
|
7
|
+
* message on a line the change did not touch is the team's backlog: counted, not blocking.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { pathToFileURL } from 'url';
|
|
14
|
+
const CONFIG_FILES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts'];
|
|
15
|
+
const TYPED_RULES = {
|
|
16
|
+
'@typescript-eslint/no-unnecessary-condition': 'error',
|
|
17
|
+
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error',
|
|
18
|
+
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
|
19
|
+
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
|
20
|
+
'@typescript-eslint/prefer-optional-chain': 'error',
|
|
21
|
+
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
|
22
|
+
'@typescript-eslint/no-floating-promises': 'error',
|
|
23
|
+
'@typescript-eslint/no-misused-promises': 'error',
|
|
24
|
+
};
|
|
25
|
+
/** Why the overlay cannot run here, or undefined when it can. */
|
|
26
|
+
export function overlayUnavailable(cwd) {
|
|
27
|
+
if (!CONFIG_FILES.some(name => fs.existsSync(path.join(cwd, name))))
|
|
28
|
+
return 'no eslint.config.* in this repository';
|
|
29
|
+
try {
|
|
30
|
+
createRequire(path.join(cwd, 'package.json')).resolve('typescript-eslint');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 'typescript-eslint is not installed';
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Writes the overlay config to a scratch directory and returns its path. It is an ES module that
|
|
39
|
+
* imports the repository's config by absolute path, so it is never written into the repository.
|
|
40
|
+
*/
|
|
41
|
+
export function writeOverlayConfig(cwd) {
|
|
42
|
+
const base = CONFIG_FILES.find(name => fs.existsSync(path.join(cwd, name)));
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-lint-overlay-'));
|
|
44
|
+
const file = path.join(dir, 'eslint.overlay.config.mjs');
|
|
45
|
+
fs.writeFileSync(file, `import path from 'node:path';
|
|
46
|
+
import { createRequire } from 'node:module';
|
|
47
|
+
const repo = ${JSON.stringify(cwd)};
|
|
48
|
+
const require = createRequire(path.join(repo, 'package.json'));
|
|
49
|
+
const base = (await import(${JSON.stringify(pathToFileURL(path.join(cwd, base)).href)})).default;
|
|
50
|
+
const ts = require('typescript-eslint');
|
|
51
|
+
const typed = ${JSON.stringify(TYPED_RULES, null, 4)};
|
|
52
|
+
export default [
|
|
53
|
+
...base,
|
|
54
|
+
{
|
|
55
|
+
files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.js', '**/*.mjs', '**/*.svelte.ts', '**/*.svelte.js'],
|
|
56
|
+
ignores: ['**/*.d.ts'],
|
|
57
|
+
languageOptions: { parser: ts.parser, parserOptions: { projectService: { allowDefaultProject: ['*.mjs', 'scripts/*.mjs'] }, tsconfigRootDir: repo } },
|
|
58
|
+
rules: typed,
|
|
59
|
+
},
|
|
60
|
+
{ files: ['**/*.svelte'], rules: typed },
|
|
61
|
+
// A test fake deliberately covers cases the types call impossible.
|
|
62
|
+
{ files: ['**/*.test.ts', '**/*.spec.ts', '**/*.test-d.ts', '**/*.svelte.test.ts', 'tests/**', 'test/**', 'test-d/**', '**/__tests__/**', '**/__mocks__/**'], rules: { '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-floating-promises': 'off' } },
|
|
63
|
+
];
|
|
64
|
+
`);
|
|
65
|
+
return file;
|
|
66
|
+
}
|
|
67
|
+
/** ESLint's JSON report, kept to messages on changed lines; the rest is counted. */
|
|
68
|
+
export function onChangedLines(json, cwd, changedLines) {
|
|
69
|
+
let results;
|
|
70
|
+
try {
|
|
71
|
+
results = JSON.parse(json);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { error: `eslint printed no JSON: ${json.trim().slice(0, 200)}` };
|
|
75
|
+
}
|
|
76
|
+
const blocking = [];
|
|
77
|
+
let preexisting = 0;
|
|
78
|
+
for (const result of results) {
|
|
79
|
+
const file = path.relative(cwd, result.filePath).split(path.sep).join('/');
|
|
80
|
+
const lines = changedLines[file];
|
|
81
|
+
for (const message of result.messages) {
|
|
82
|
+
if (lines?.has(message.line))
|
|
83
|
+
blocking.push({ file, line: message.line, column: message.column, rule: message.ruleId ?? 'error', message: message.message });
|
|
84
|
+
else
|
|
85
|
+
preexisting++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { blocking, preexisting };
|
|
89
|
+
}
|
|
@@ -16,9 +16,10 @@ import micromatch from 'micromatch';
|
|
|
16
16
|
import { addedFiles } from './migration-order.js';
|
|
17
17
|
import { sourceStem } from './package-layout.js';
|
|
18
18
|
import { ownOutputs } from './unused-exports.js';
|
|
19
|
+
import { isTestFile } from './test-files.js';
|
|
19
20
|
const GIT_TIMEOUT_MS = 10_000;
|
|
20
21
|
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|svelte)$/;
|
|
21
|
-
const FOUND_BY_RUNNER = /(^|\/)\+[^/]+$|(^|\/)hooks(\.server|\.client)?\.(ts|js)$|^netlify\/functions\/|^src\/params\/|\.
|
|
22
|
+
const FOUND_BY_RUNNER = /(^|\/)\+[^/]+$|(^|\/)hooks(\.server|\.client)?\.(ts|js)$|^netlify\/functions\/|^src\/params\/|\.d\.ts$|^(migrations|supabase)\/|\.config\.[a-z]+$|^\./;
|
|
22
23
|
/** Files that can run or import another: code, package.json, CI and config. */
|
|
23
24
|
const REFERRER = /\.(ts|tsx|js|jsx|mjs|cjs|svelte|json|ya?ml|toml|sh)$/;
|
|
24
25
|
/** Lockfiles and generated bundles name no source file and can be megabytes. */
|
|
@@ -32,7 +33,7 @@ export function orphanFileFailures(cwd, diff, config) {
|
|
|
32
33
|
const settings = config.gates.orphan_files;
|
|
33
34
|
if (!settings?.enabled)
|
|
34
35
|
return [];
|
|
35
|
-
const added = addedFiles(diff).filter(file => CODE.test(file) && !FOUND_BY_RUNNER.test(file) && !BUILD_OUTPUT.test(file) && !micromatch.isMatch(file, settings.allow));
|
|
36
|
+
const added = addedFiles(diff).filter(file => CODE.test(file) && !FOUND_BY_RUNNER.test(file) && !isTestFile(file) && !BUILD_OUTPUT.test(file) && !micromatch.isMatch(file, settings.allow));
|
|
36
37
|
if (added.length === 0)
|
|
37
38
|
return [];
|
|
38
39
|
const excluded = ownOutputs(config);
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import { spawnSync } from 'child_process';
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import path from 'path';
|
|
11
|
+
import { isTestFile } from './test-files.js';
|
|
11
12
|
const MOUNTING = /\.(svelte|tsx|jsx)$/;
|
|
12
|
-
|
|
13
|
+
/** Tests and stories mount components to exercise them, not to wire behaviour. */
|
|
14
|
+
const skipped = (file) => isTestFile(file) || /\.stories\./.test(file);
|
|
13
15
|
const GIT_TIMEOUT_MS = 10_000;
|
|
14
16
|
export function partialWiringFailures(cwd, changedLines, config) {
|
|
15
17
|
if (!config.gates.change_sweep?.enabled)
|
|
@@ -17,7 +19,7 @@ export function partialWiringFailures(cwd, changedLines, config) {
|
|
|
17
19
|
const failures = [];
|
|
18
20
|
const seen = new Set();
|
|
19
21
|
for (const [file, lines] of Object.entries(changedLines)) {
|
|
20
|
-
if (!MOUNTING.test(file) ||
|
|
22
|
+
if (!MOUNTING.test(file) || skipped(file) || lines.size === 0)
|
|
21
23
|
continue;
|
|
22
24
|
for (const mount of mountsIn(cwd, file)) {
|
|
23
25
|
for (const { prop, line } of addedProps(cwd, mount, lines)) {
|
|
@@ -75,7 +77,7 @@ function mountsOf(cwd, component) {
|
|
|
75
77
|
if (cached)
|
|
76
78
|
return cached;
|
|
77
79
|
const grep = spawnSync('git', ['grep', '--untracked', '-l', '-F', '-e', `<${component}`], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
|
|
78
|
-
const files = grep.status === 0 ? grep.stdout.split('\n').filter(f => f && MOUNTING.test(f) && !
|
|
80
|
+
const files = grep.status === 0 ? grep.stdout.split('\n').filter(f => f && MOUNTING.test(f) && !skipped(f)) : [];
|
|
79
81
|
const mounts = files.flatMap(file => mountsIn(cwd, file).filter(m => m.component === component));
|
|
80
82
|
mountCache.set(key, mounts);
|
|
81
83
|
return mounts;
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Read from the syntax tree, so a comment or string never matches.
|
|
16
16
|
*/
|
|
17
|
+
import { isTestFile } from './test-files.js';
|
|
17
18
|
import fs from 'fs';
|
|
18
19
|
import path from 'path';
|
|
19
20
|
import ts from 'typescript';
|
|
20
21
|
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
21
|
-
const
|
|
22
|
+
const DECLARATIONS = /\.d\.ts$/;
|
|
22
23
|
const TIME_COLUMN = /(_at|_on|At|On|date|Date|time|Time|timestamp|Timestamp)$|^(at|since|until|created|updated)$/;
|
|
23
24
|
const LOWER = new Set(['gt', 'gte']);
|
|
24
25
|
const UPPER = new Set(['lt', 'lte']);
|
|
@@ -27,7 +28,7 @@ export function queryPatternFailures(cwd, changedLines, config) {
|
|
|
27
28
|
return [];
|
|
28
29
|
const failures = [];
|
|
29
30
|
for (const [file, lines] of Object.entries(changedLines)) {
|
|
30
|
-
if (!CODE.test(file) ||
|
|
31
|
+
if (!CODE.test(file) || DECLARATIONS.test(file) || isTestFile(file) || lines.size === 0)
|
|
31
32
|
continue;
|
|
32
33
|
let text;
|
|
33
34
|
try {
|
package/dist/review/quiet.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Failure } from '../types/index.js';
|
|
2
2
|
export declare const DISMISSED_FILE: string;
|
|
3
3
|
export declare function isProven(failure: Failure): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* What a change must fix: a proven finding, a critical one, or a high one the semantic engine
|
|
6
|
+
* verified or a security gate found. The one rule behind the review's verdict, the stop hook and
|
|
7
|
+
* the push gate, so the three never disagree about the same finding.
|
|
8
|
+
*/
|
|
9
|
+
export declare function mustFix(failure: Failure): boolean;
|
|
4
10
|
/** The same finding across runs and pushes: gate, file and message, never the line (lines move). */
|
|
5
11
|
export declare function findingKey(failure: Failure): string;
|
|
6
12
|
export interface QuietSplit {
|
package/dist/review/quiet.js
CHANGED
|
@@ -20,11 +20,21 @@ import fs from 'fs';
|
|
|
20
20
|
import path from 'path';
|
|
21
21
|
import { checkId, isMuted, readOutcomes, recordOutcome, reportedCheck } from './check-outcomes.js';
|
|
22
22
|
import { readStateFile } from './trusted-state.js';
|
|
23
|
-
const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests', 'unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function', 'partial-fix', 'partial-wiring'
|
|
23
|
+
const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests', 'unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function', 'partial-fix', 'partial-wiring', 'migration-order',
|
|
24
|
+
'duplicate-null-filter', 'nullable-filtered-column', 'optional-always-supplied', 'write-only-property', 'typed-checks-unavailable']);
|
|
24
25
|
export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
|
|
25
26
|
export function isProven(failure) {
|
|
26
27
|
return PROVEN_GATES.has(failure.id);
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* What a change must fix: a proven finding, a critical one, or a high one the semantic engine
|
|
31
|
+
* verified or a security gate found. The one rule behind the review's verdict, the stop hook and
|
|
32
|
+
* the push gate, so the three never disagree about the same finding.
|
|
33
|
+
*/
|
|
34
|
+
export function mustFix(failure) {
|
|
35
|
+
const severity = failure.severity ?? 'medium';
|
|
36
|
+
return isProven(failure) || severity === 'critical' || (severity === 'high' && (failure.verified === true || failure.provenance === 'security'));
|
|
37
|
+
}
|
|
28
38
|
/** The same finding across runs and pushes: gate, file and message, never the line (lines move). */
|
|
29
39
|
export function findingKey(failure) {
|
|
30
40
|
return crypto.createHash('sha256').update(`${failure.id}\u0000${failure.files?.[0] ?? ''}\u0000${failure.details}`).digest('hex').slice(0, 16);
|
|
@@ -39,7 +49,7 @@ export function quietSplit(cwd, findings, includeHeuristics = false, trustedRef)
|
|
|
39
49
|
split.dismissed++;
|
|
40
50
|
split.dismissedByGate[finding.id] = (split.dismissedByGate[finding.id] ?? 0) + 1;
|
|
41
51
|
}
|
|
42
|
-
else if (includeHeuristics ||
|
|
52
|
+
else if (includeHeuristics || mustFix(finding))
|
|
43
53
|
split.speaking.push(finding);
|
|
44
54
|
else if (isMuted(outcomes[checkId(finding)]))
|
|
45
55
|
split.muted++;
|