@rigour-labs/core 6.4.3 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deep/agent-review.d.ts +19 -0
- package/dist/deep/agent-review.js +19 -0
- package/dist/deep/agent-review.test.d.ts +1 -0
- package/dist/deep/agent-review.test.js +53 -0
- package/dist/deep/code-pass.d.ts +10 -0
- package/dist/deep/code-pass.js +64 -33
- package/dist/deep/code-pass.test.js +53 -12
- package/dist/deep/code-verifier.d.ts +3 -1
- package/dist/deep/code-verifier.js +11 -11
- package/dist/deep/code-verifier.test.js +8 -0
- package/dist/deep/pr-diff.d.ts +7 -0
- package/dist/deep/pr-diff.js +47 -0
- package/dist/deep/pr-review.d.ts +33 -0
- package/dist/deep/pr-review.js +81 -0
- package/dist/deep/pr-review.test.d.ts +1 -0
- package/dist/deep/pr-review.test.js +80 -0
- package/dist/deep/related-changes.d.ts +13 -0
- package/dist/deep/related-changes.js +56 -0
- package/dist/deep/related-changes.test.d.ts +1 -0
- package/dist/deep/related-changes.test.js +33 -0
- package/dist/deep/review-tools.d.ts +16 -0
- package/dist/deep/review-tools.js +111 -0
- package/dist/deep/review-tools.test.d.ts +1 -0
- package/dist/deep/review-tools.test.js +39 -0
- package/dist/deep/risk.d.ts +41 -0
- package/dist/deep/risk.js +135 -0
- package/dist/deep/risk.test.d.ts +1 -0
- package/dist/deep/risk.test.js +63 -0
- package/dist/deep/router.d.ts +27 -0
- package/dist/deep/router.js +15 -0
- package/dist/deep/self-check.d.ts +14 -9
- package/dist/deep/self-check.js +41 -32
- package/dist/deep/tool-loop.d.ts +19 -0
- package/dist/deep/tool-loop.js +23 -0
- package/dist/gates/deep-analysis.d.ts +27 -0
- package/dist/gates/deep-analysis.js +73 -2
- package/dist/gates/deep-runner.js +14 -0
- package/dist/gates/frontend-secret-exposure.d.ts +2 -0
- package/dist/gates/frontend-secret-exposure.js +8 -0
- package/dist/gates/frontend-secret-exposure.test.js +14 -0
- package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +27 -0
- package/dist/gates/hallucinated-imports/framework-modules.d.ts +12 -3
- package/dist/gates/hallucinated-imports/framework-modules.js +32 -6
- package/dist/gates/hallucinated-imports/index.js +2 -1
- package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports/js-resolver-types.test.js +14 -0
- package/dist/gates/hallucinated-imports/js-resolver.d.ts +6 -1
- package/dist/gates/hallucinated-imports/js-resolver.js +25 -3
- package/dist/gates/phantom-apis.js +2 -2
- package/dist/gates/phantom-apis.test.js +10 -0
- package/dist/gates/promise-safety.js +1 -1
- package/dist/gates/test-quality-matchers.js +3 -0
- package/dist/gates/test-quality.test.js +13 -0
- package/dist/hooks/checker.test.js +9 -0
- package/dist/hooks/stop-review.d.ts +2 -0
- package/dist/hooks/stop-review.js +19 -3
- package/dist/hooks/templates.d.ts +2 -0
- package/dist/hooks/templates.js +16 -2
- package/dist/hooks/templates.test.d.ts +1 -0
- package/dist/hooks/templates.test.js +16 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/inference/cloud-provider.d.ts +12 -1
- package/dist/inference/cloud-provider.js +137 -2
- package/dist/inference/cloud-provider.test.d.ts +1 -0
- package/dist/inference/cloud-provider.test.js +118 -0
- package/dist/inference/pricing.d.ts +15 -0
- package/dist/inference/pricing.js +13 -0
- package/dist/inference/types.d.ts +42 -0
- package/dist/review/changed-function-spans.d.ts +4 -0
- package/dist/review/changed-function-spans.js +48 -0
- package/dist/review/changed-lines.d.ts +9 -3
- package/dist/review/changed-lines.js +19 -2
- package/dist/review/deep-runs.d.ts +22 -0
- package/dist/review/deep-runs.js +59 -0
- package/dist/review/deep-runs.test.d.ts +1 -0
- package/dist/review/deep-runs.test.js +23 -0
- package/dist/review/generated-files.d.ts +3 -0
- package/dist/review/generated-files.js +35 -0
- package/dist/review/generated-files.test.d.ts +1 -0
- package/dist/review/generated-files.test.js +25 -0
- package/dist/review/ledger.d.ts +27 -0
- package/dist/review/ledger.js +72 -0
- package/dist/review/quiet.d.ts +14 -0
- package/dist/review/quiet.js +69 -0
- package/dist/review/quiet.test.d.ts +1 -0
- package/dist/review/quiet.test.js +27 -0
- package/dist/review/review-ack.d.ts +16 -0
- package/dist/review/review-ack.js +25 -0
- package/dist/review/review-task.d.ts +40 -0
- package/dist/review/review-task.js +50 -0
- package/dist/review/review-task.test.d.ts +1 -0
- package/dist/review/review-task.test.js +80 -0
- package/dist/review/review.d.ts +11 -1
- package/dist/review/review.js +15 -6
- package/dist/review/review.test.js +25 -0
- package/dist/review-learning/acted-on.d.ts +25 -0
- package/dist/review-learning/acted-on.js +69 -0
- package/dist/review-learning/learn-from-reviews.d.ts +34 -0
- package/dist/review-learning/learn-from-reviews.js +65 -0
- package/dist/review-learning/lessons.d.ts +52 -0
- package/dist/review-learning/lessons.js +149 -0
- package/dist/review-learning/repo-rules.d.ts +16 -0
- package/dist/review-learning/repo-rules.js +84 -0
- package/dist/review-learning/repo-rules.test.d.ts +1 -0
- package/dist/review-learning/repo-rules.test.js +39 -0
- package/dist/review-learning/review-learning.test.d.ts +1 -0
- package/dist/review-learning/review-learning.test.js +104 -0
- package/dist/review-learning/team-lessons.d.ts +4 -0
- package/dist/review-learning/team-lessons.js +24 -0
- package/dist/semantic/rules/credential-redirect.js +4 -1
- package/dist/services/context-telemetry-service.d.ts +9 -0
- package/dist/services/context-telemetry-service.js +4 -0
- package/dist/services/observed-savings.d.ts +36 -0
- package/dist/services/observed-savings.js +94 -0
- package/dist/services/observed-savings.test.d.ts +1 -0
- package/dist/services/observed-savings.test.js +37 -0
- package/dist/settings.js +4 -1
- package/dist/templates/universal-config.js +7 -3
- package/dist/types/index.d.ts +276 -10
- package/dist/types/index.js +38 -5
- package/package.json +6 -6
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { verifyCodeFindings } from './code-verifier.js';
|
|
6
|
+
import { diffSections } from './pr-diff.js';
|
|
7
|
+
import { buildPrPrompt, reviewPullRequest } from './pr-review.js';
|
|
8
|
+
const DIFF = [
|
|
9
|
+
'diff --git a/src/invoice.ts b/src/invoice.ts',
|
|
10
|
+
'--- a/src/invoice.ts',
|
|
11
|
+
'+++ b/src/invoice.ts',
|
|
12
|
+
'@@ -10,3 +10,3 @@ export function invoice(row) {',
|
|
13
|
+
' const headers = {};',
|
|
14
|
+
"- const cls = row.currency;",
|
|
15
|
+
"+ const cls = 'USD';",
|
|
16
|
+
' return deliver(row, cls);',
|
|
17
|
+
'diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml',
|
|
18
|
+
'--- a/pnpm-lock.yaml',
|
|
19
|
+
'+++ b/pnpm-lock.yaml',
|
|
20
|
+
'@@ -1 +1 @@',
|
|
21
|
+
'-a',
|
|
22
|
+
'+b',
|
|
23
|
+
'diff --git a/src/price.ts b/src/price.ts',
|
|
24
|
+
'--- a/src/price.ts',
|
|
25
|
+
'+++ b/src/price.ts',
|
|
26
|
+
'@@ -1,1 +1,2 @@',
|
|
27
|
+
' export function price(row) {',
|
|
28
|
+
'+ return row.currency;',
|
|
29
|
+
].join('\n');
|
|
30
|
+
describe('diffSections', () => {
|
|
31
|
+
it('numbers new-side lines, marks removals, and leaves lockfiles out', () => {
|
|
32
|
+
const sections = diffSections(DIFF);
|
|
33
|
+
expect(sections.map(s => s.file)).toEqual(['src/invoice.ts', 'src/price.ts']);
|
|
34
|
+
expect(sections[0].text).toContain(" 11 + const cls = 'USD';");
|
|
35
|
+
expect(sections[0].text).toContain(" - const cls = row.currency;");
|
|
36
|
+
expect(sections[0].text).toContain(' 12 return deliver(row, cls);');
|
|
37
|
+
expect(sections[1].addedLines).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('buildPrPrompt', () => {
|
|
41
|
+
it('puts the risky files first and points at the riskiest functions', () => {
|
|
42
|
+
const { prompt } = buildPrPrompt({ cwd: '.', diff: DIFF, focus: [{ file: 'src/price.ts', function: 'price', start: 1, questions: ['Do callers still get a currency?'] }], prBody: 'Read the currency live.' });
|
|
43
|
+
expect(prompt.indexOf('FILE src/price.ts')).toBeLessThan(prompt.indexOf('FILE src/invoice.ts'));
|
|
44
|
+
expect(prompt).toContain('- src/price.ts:1 `price`: Do callers still get a currency?');
|
|
45
|
+
expect(prompt).toContain('PR DESCRIPTION (what the author intended):\nRead the currency live.');
|
|
46
|
+
expect(prompt).not.toContain('pnpm-lock');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('reviewPullRequest', () => {
|
|
50
|
+
let repo;
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-review-'));
|
|
53
|
+
fs.mkdirSync(path.join(repo, 'src'));
|
|
54
|
+
fs.writeFileSync(path.join(repo, 'src/invoice.ts'), Array.from({ length: 20 }, (_, i) => i === 10 ? " const cls = 'USD';" : `// ${i + 1}`).join('\n'));
|
|
55
|
+
fs.writeFileSync(path.join(repo, 'src/price.ts'), 'export function price(row) {\n return row.currency;\n}\n');
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
58
|
+
it('reviews the PR in one conversation and grounds a cross-file finding in the files it names', async () => {
|
|
59
|
+
const replies = [
|
|
60
|
+
{ text: '', toolCalls: [{ id: 'r', name: 'read_file', arguments: { path: 'src/price.ts' } }] },
|
|
61
|
+
{ text: JSON.stringify({ findings: [
|
|
62
|
+
{ category: 'consistency', severity: 'high', file: 'src/invoice.ts', line: 11, description: '`cls` is hard-coded while `price` reads `currency` live.', suggestion: 's', confidence: 0.9 },
|
|
63
|
+
{ category: 'correctness', severity: 'high', file: 'src/invoice.ts', line: 11, description: '`invented_symbol` is never set.', suggestion: 's', confidence: 0.9 },
|
|
64
|
+
] }), toolCalls: [] },
|
|
65
|
+
];
|
|
66
|
+
let conversations = 0;
|
|
67
|
+
const provider = {
|
|
68
|
+
name: 'fake', isAvailable: async () => true, setup: async () => { }, dispose: () => { }, analyze: async () => '',
|
|
69
|
+
chat: async (messages) => { if (messages.length === 1)
|
|
70
|
+
conversations++; return replies.shift(); },
|
|
71
|
+
};
|
|
72
|
+
const result = await reviewPullRequest(provider, { cwd: repo, diff: DIFF, focus: [] }, {});
|
|
73
|
+
expect(conversations).toBe(1);
|
|
74
|
+
expect(result.toolCalls).toBe(1);
|
|
75
|
+
const rejected = {};
|
|
76
|
+
const kept = verifyCodeFindings(result.findings, result.contexts, rejected);
|
|
77
|
+
expect(kept.map(f => f.description)).toEqual(['`cls` is hard-coded while `price` reads `currency` live.']);
|
|
78
|
+
expect(rejected).toEqual({ ungrounded_identifier: 1 });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FunctionRisk } from './risk.js';
|
|
2
|
+
export interface RelatedChange {
|
|
3
|
+
/** The changed function, and where it is defined. */
|
|
4
|
+
callee: string;
|
|
5
|
+
calleeFile: string;
|
|
6
|
+
calleeLine: number;
|
|
7
|
+
/** Another changed file that calls it, and the first call line. */
|
|
8
|
+
callerFile: string;
|
|
9
|
+
callerLine: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function relatedChanges(cwd: string, changedFiles: string[], changed: FunctionRisk[]): RelatedChange[];
|
|
12
|
+
/** The caller imports `name` from the callee's module (relative or aliased path). */
|
|
13
|
+
export declare function importsFrom(text: string, name: string, callerFile: string, calleeFile: string): boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Related changes: a function the PR changed, called from another file the
|
|
3
|
+
* PR also changed. When both sides of a call move in one PR, the contract
|
|
4
|
+
* between them is the likeliest thing to break (one side reads a value live,
|
|
5
|
+
* the other still passes a constant), and a reviewer reading file by file,
|
|
6
|
+
* or running out of budget before the second file, misses it.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
const MAX_LINKS = 8;
|
|
11
|
+
const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^|\/)__tests__\//;
|
|
12
|
+
export function relatedChanges(cwd, changedFiles, changed) {
|
|
13
|
+
const exported = changed.filter(f => f.signals.exported && /^[A-Za-z_$][\w$]*$/.test(f.name));
|
|
14
|
+
if (exported.length === 0)
|
|
15
|
+
return [];
|
|
16
|
+
const texts = new Map(changedFiles.map(file => [file, readLines(cwd, file)]));
|
|
17
|
+
const links = [];
|
|
18
|
+
for (const fn of exported) {
|
|
19
|
+
const call = new RegExp(`(^|[^\\w$.])${fn.name.replace(/\$/g, '\\$')}\\s*\\(`);
|
|
20
|
+
for (const [file, lines] of texts) {
|
|
21
|
+
if (file === fn.file || !lines || TEST_FILE.test(file) || !importsFrom(lines.join('\n'), fn.name, file, fn.file))
|
|
22
|
+
continue;
|
|
23
|
+
const index = lines.findIndex(line => call.test(line) && !/^\s*(import|export\s+\{)/.test(line));
|
|
24
|
+
if (index === -1)
|
|
25
|
+
continue;
|
|
26
|
+
links.push({ callee: fn.name, calleeFile: fn.file, calleeLine: fn.start, callerFile: file, callerLine: index + 1 });
|
|
27
|
+
if (links.length >= MAX_LINKS)
|
|
28
|
+
return links;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return links;
|
|
32
|
+
}
|
|
33
|
+
/** The caller imports `name` from the callee's module (relative or aliased path). */
|
|
34
|
+
export function importsFrom(text, name, callerFile, calleeFile) {
|
|
35
|
+
const target = calleeFile.replace(/\.[cm]?[jt]sx?$/, '').replace(/\/index$/, '');
|
|
36
|
+
const imports = text.matchAll(/import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g);
|
|
37
|
+
for (const [, names, spec] of imports) {
|
|
38
|
+
if (!new RegExp(`(^|[\\s,])${name.replace(/\$/g, '\\$')}(\\s|,|$)`).test(names))
|
|
39
|
+
continue;
|
|
40
|
+
const module = spec.replace(/\.[cm]?[jt]sx?$/, '').replace(/\/index$/, '');
|
|
41
|
+
const resolved = module.startsWith('.')
|
|
42
|
+
? path.posix.normalize(path.posix.join(path.posix.dirname(callerFile), module))
|
|
43
|
+
: module.replace(/^[@~]\//, '');
|
|
44
|
+
if (target === resolved || target.endsWith(`/${resolved}`))
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
function readLines(cwd, file) {
|
|
50
|
+
try {
|
|
51
|
+
return fs.readFileSync(path.join(cwd, file), 'utf-8').split('\n');
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { importsFrom, relatedChanges } from './related-changes.js';
|
|
6
|
+
import { rankChangedFunctions } from './risk.js';
|
|
7
|
+
let dir;
|
|
8
|
+
const write = (file, body) => {
|
|
9
|
+
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
|
10
|
+
fs.writeFileSync(path.join(dir, file), body);
|
|
11
|
+
};
|
|
12
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'related-')); });
|
|
13
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
14
|
+
describe('relatedChanges', () => {
|
|
15
|
+
it('links a changed function to another changed file that imports and calls it, never tests or same-named functions', () => {
|
|
16
|
+
write('src/billing/price.ts', 'export function priceOf(row) {\n return row.currency;\n}\n');
|
|
17
|
+
write('src/billing/invoice.ts', "import { priceOf } from './price';\n\nexport function invoice(row) {\n return { cur: 'USD', price: priceOf(row) };\n}\n");
|
|
18
|
+
write('src/billing/price.test.ts', "import { priceOf } from './price';\npriceOf({});\n");
|
|
19
|
+
write('src/other/price.ts', 'export function priceOf(x) {\n return 1;\n}\n');
|
|
20
|
+
write('src/other/use.ts', "import { priceOf } from './price';\nexport const y = () => priceOf(2);\n");
|
|
21
|
+
const focus = { 'src/billing/price.ts': [2], 'src/billing/invoice.ts': [4], 'src/billing/price.test.ts': [2], 'src/other/use.ts': [2] };
|
|
22
|
+
const links = relatedChanges(dir, Object.keys(focus), rankChangedFunctions(dir, focus));
|
|
23
|
+
expect(links).toEqual([{ callee: 'priceOf', calleeFile: 'src/billing/price.ts', calleeLine: 1, callerFile: 'src/billing/invoice.ts', callerLine: 4 }]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('importsFrom', () => {
|
|
27
|
+
it('resolves relative and aliased module paths', () => {
|
|
28
|
+
expect(importsFrom("import { a, b } from '../lib/x';", 'b', 'src/app/y.ts', 'src/lib/x.ts')).toBe(true);
|
|
29
|
+
expect(importsFrom('import { b } from "@/lib/x";', 'b', 'src/app/y.ts', 'src/lib/x.ts')).toBe(true);
|
|
30
|
+
expect(importsFrom("import { bb } from '../lib/x';", 'b', 'src/app/y.ts', 'src/lib/x.ts')).toBe(false);
|
|
31
|
+
expect(importsFrom("import { b } from './x';", 'b', 'src/app/y.ts', 'src/lib/x.ts')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ChatTool, ToolCall } from '../inference/types.js';
|
|
2
|
+
export declare const REVIEW_TOOLS: ChatTool[];
|
|
3
|
+
/** Tools bound to one repository, remembering what was read. */
|
|
4
|
+
export declare class ReviewToolbox {
|
|
5
|
+
private readonly cwd;
|
|
6
|
+
/** Repository-relative path → [start, end] line ranges the model read. */
|
|
7
|
+
readonly reads: Map<string, [number, number][]>;
|
|
8
|
+
/** Text the model read, for checking identifiers it cites. */
|
|
9
|
+
readText: string;
|
|
10
|
+
constructor(cwd: string);
|
|
11
|
+
run(call: ToolCall): string;
|
|
12
|
+
private readFile;
|
|
13
|
+
private grep;
|
|
14
|
+
/** A repository-relative path that stays inside the repository and is not a secrets file. */
|
|
15
|
+
private inside;
|
|
16
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only repository tools for an agentic review.
|
|
3
|
+
*
|
|
4
|
+
* A reviewer that can only see the chunk it was sent guesses about callers,
|
|
5
|
+
* callees and types; one that can look them up checks its suspicion first.
|
|
6
|
+
* These tools only read, only inside the repository, never secrets files,
|
|
7
|
+
* and record which lines of which files the model actually read so its
|
|
8
|
+
* findings can still be grounded (code-verifier.ts).
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
const MAX_READ_LINES = 200;
|
|
14
|
+
const MAX_GREP_LINES = 40;
|
|
15
|
+
const MAX_LINE_CHARS = 240;
|
|
16
|
+
/** Never sent to a model provider: environment files, keys, credentials. */
|
|
17
|
+
const SECRET_FILE = /(^|\/)(\.env(\..*)?|.*\.(pem|key|p12|pfx)|id_[a-z0-9]+|\.npmrc|\.netrc|credentials(\.json)?)$/i;
|
|
18
|
+
export const REVIEW_TOOLS = [
|
|
19
|
+
{
|
|
20
|
+
name: 'read_file',
|
|
21
|
+
description: 'Read lines of a repository file, numbered. Use it to check callers, callees, types and constants before claiming a defect.',
|
|
22
|
+
parameters: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
path: { type: 'string', description: 'Repository-relative path.' },
|
|
26
|
+
start_line: { type: 'integer', description: 'First line, 1-based (default 1).' },
|
|
27
|
+
end_line: { type: 'integer', description: `Last line (at most ${MAX_READ_LINES} lines per call).` },
|
|
28
|
+
},
|
|
29
|
+
required: ['path'],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'grep',
|
|
34
|
+
description: 'Search tracked files with an extended regular expression. Returns path:line:text matches.',
|
|
35
|
+
parameters: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
pattern: { type: 'string' },
|
|
39
|
+
path_glob: { type: 'string', description: 'Optional pathspec, e.g. "src/**/*.ts".' },
|
|
40
|
+
},
|
|
41
|
+
required: ['pattern'],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
/** Tools bound to one repository, remembering what was read. */
|
|
46
|
+
export class ReviewToolbox {
|
|
47
|
+
cwd;
|
|
48
|
+
/** Repository-relative path → [start, end] line ranges the model read. */
|
|
49
|
+
reads = new Map();
|
|
50
|
+
/** Text the model read, for checking identifiers it cites. */
|
|
51
|
+
readText = '';
|
|
52
|
+
constructor(cwd) {
|
|
53
|
+
this.cwd = cwd;
|
|
54
|
+
}
|
|
55
|
+
run(call) {
|
|
56
|
+
try {
|
|
57
|
+
if (call.name === 'read_file')
|
|
58
|
+
return this.readFile(call.arguments);
|
|
59
|
+
if (call.name === 'grep')
|
|
60
|
+
return this.grep(call.arguments);
|
|
61
|
+
return `Unknown tool "${call.name}".`;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
return `Error: ${error?.message ?? String(error)}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
readFile(args) {
|
|
68
|
+
const file = this.inside(String(args.path ?? ''));
|
|
69
|
+
const lines = fs.readFileSync(path.join(this.cwd, file), 'utf-8').split('\n');
|
|
70
|
+
const start = clamp(Number(args.start_line) || 1, 1, lines.length);
|
|
71
|
+
const end = clamp(Number(args.end_line) || start + MAX_READ_LINES - 1, start, Math.min(lines.length, start + MAX_READ_LINES - 1));
|
|
72
|
+
const text = lines.slice(start - 1, end).map((line, i) => `${String(start + i).padStart(5)}| ${line}`).join('\n');
|
|
73
|
+
this.reads.set(file, [...(this.reads.get(file) ?? []), [start, end]]);
|
|
74
|
+
this.readText += `\n${lines.slice(start - 1, end).join('\n')}`;
|
|
75
|
+
return `${file} lines ${start}-${end} of ${lines.length}:\n${text}`;
|
|
76
|
+
}
|
|
77
|
+
grep(args) {
|
|
78
|
+
const pattern = String(args.pattern ?? '');
|
|
79
|
+
if (!pattern || pattern.length > 200)
|
|
80
|
+
return 'Error: pattern must be 1-200 characters.';
|
|
81
|
+
const spec = typeof args.path_glob === 'string' && args.path_glob ? [args.path_glob] : [];
|
|
82
|
+
let out = '';
|
|
83
|
+
try {
|
|
84
|
+
out = execFileSync('git', ['grep', '-n', '-I', '-E', '-e', pattern, '--', ...spec], {
|
|
85
|
+
cwd: this.cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return 'No matches.';
|
|
90
|
+
}
|
|
91
|
+
const matches = out.split('\n').filter(line => line && !SECRET_FILE.test(line.split(':')[0]));
|
|
92
|
+
const shown = matches.slice(0, MAX_GREP_LINES).map(line => line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line);
|
|
93
|
+
this.readText += `\n${shown.join('\n')}`;
|
|
94
|
+
return shown.length ? shown.join('\n') + (matches.length > shown.length ? `\n… ${matches.length - shown.length} more` : '') : 'No matches.';
|
|
95
|
+
}
|
|
96
|
+
/** A repository-relative path that stays inside the repository and is not a secrets file. */
|
|
97
|
+
inside(requested) {
|
|
98
|
+
const root = fs.realpathSync(this.cwd);
|
|
99
|
+
const resolved = fs.realpathSync(path.resolve(root, requested));
|
|
100
|
+
const relative = path.relative(root, resolved);
|
|
101
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
102
|
+
throw new Error('path is outside the repository');
|
|
103
|
+
const posix = relative.split(path.sep).join('/');
|
|
104
|
+
if (posix.startsWith('.git/') || SECRET_FILE.test(posix))
|
|
105
|
+
throw new Error('this file is not readable by the reviewer');
|
|
106
|
+
return posix;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function clamp(value, low, high) {
|
|
110
|
+
return Math.max(low, Math.min(high, Math.floor(value)));
|
|
111
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { ReviewToolbox } from './review-tools.js';
|
|
7
|
+
let repo;
|
|
8
|
+
const call = (name, args) => ({ id: 'c1', name, arguments: args });
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'review-tools-'));
|
|
11
|
+
fs.mkdirSync(path.join(repo, 'src'));
|
|
12
|
+
fs.writeFileSync(path.join(repo, 'src/a.ts'), Array.from({ length: 300 }, (_, i) => `const v${i + 1} = ${i + 1};`).join('\n'));
|
|
13
|
+
fs.writeFileSync(path.join(repo, '.env'), 'API_KEY=secret');
|
|
14
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
15
|
+
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
18
|
+
describe('ReviewToolbox', () => {
|
|
19
|
+
it('reads numbered lines, caps a call at 200 lines, and records what was read', () => {
|
|
20
|
+
const tools = new ReviewToolbox(repo);
|
|
21
|
+
const out = tools.run(call('read_file', { path: 'src/a.ts', start_line: 10, end_line: 900 }));
|
|
22
|
+
expect(out).toContain('src/a.ts lines 10-209 of 300');
|
|
23
|
+
expect(out).toContain(' 10| const v10 = 10;');
|
|
24
|
+
expect(tools.reads.get('src/a.ts')).toEqual([[10, 209]]);
|
|
25
|
+
expect(tools.readText).toContain('const v209');
|
|
26
|
+
});
|
|
27
|
+
it('refuses paths outside the repository and secrets files, without throwing', () => {
|
|
28
|
+
const tools = new ReviewToolbox(repo);
|
|
29
|
+
expect(tools.run(call('read_file', { path: '../../etc/passwd' }))).toMatch(/^Error:/);
|
|
30
|
+
expect(tools.run(call('read_file', { path: '.env' }))).toBe('Error: this file is not readable by the reviewer');
|
|
31
|
+
expect(tools.reads.size).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
it('greps tracked files but never shows a secrets file', () => {
|
|
34
|
+
const tools = new ReviewToolbox(repo);
|
|
35
|
+
expect(tools.run(call('grep', { pattern: 'v12 =' }))).toBe('src/a.ts:12:const v12 = 12;');
|
|
36
|
+
expect(tools.run(call('grep', { pattern: 'API_KEY' }))).toBe('No matches.');
|
|
37
|
+
expect(tools.run(call('shell', {}))).toBe('Unknown tool "shell".');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { RemovedBlock } from '../utils/diff.js';
|
|
2
|
+
/** Kinds of operation where a wrong line costs the most; matched on the function's text. */
|
|
3
|
+
export declare const SENSITIVE: ReadonlyArray<{
|
|
4
|
+
kind: string;
|
|
5
|
+
pattern: RegExp;
|
|
6
|
+
}>;
|
|
7
|
+
export interface RiskSignals {
|
|
8
|
+
exported: boolean;
|
|
9
|
+
async: boolean;
|
|
10
|
+
/** Deepest nesting of branches and loops. */
|
|
11
|
+
nesting: number;
|
|
12
|
+
lines: number;
|
|
13
|
+
/** The change removed more conditions, early returns or throws from this function than it added. */
|
|
14
|
+
removedGuard: boolean;
|
|
15
|
+
/** SENSITIVE kinds this function touches. */
|
|
16
|
+
sensitive: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface FunctionRisk {
|
|
19
|
+
file: string;
|
|
20
|
+
name: string;
|
|
21
|
+
start: number;
|
|
22
|
+
end: number;
|
|
23
|
+
signals: RiskSignals;
|
|
24
|
+
score: number;
|
|
25
|
+
/** sha256 of the function's text with whitespace collapsed: any edit to it changes this. */
|
|
26
|
+
hash: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Score one changed function from its signals; higher is riskier, 0 means
|
|
30
|
+
* "rules only". The router reviews functions at or above `min_score`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function scoreRisk(signals: RiskSignals): number;
|
|
33
|
+
/** Changed functions across the change, riskiest first. */
|
|
34
|
+
export declare function rankChangedFunctions(cwd: string, focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>): FunctionRisk[];
|
|
35
|
+
/** The current text hash and span of a named function in a file, if the file parses and has it. */
|
|
36
|
+
export declare function findFunction(cwd: string, file: string, name: string): {
|
|
37
|
+
hash: string;
|
|
38
|
+
start: number;
|
|
39
|
+
end: number;
|
|
40
|
+
} | undefined;
|
|
41
|
+
export declare function functionHash(text: string): string;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk of each changed function: which code is worth a paid model's review.
|
|
3
|
+
*
|
|
4
|
+
* Sending every changed file to a frontier model cost $0.75 per PR on real
|
|
5
|
+
* PRs; most changed functions are glue, rename or plumbing that never carries
|
|
6
|
+
* a defect a reviewer acts on. The router reviews the riskiest functions and
|
|
7
|
+
* leaves the rest to the deterministic gates. Signals are syntactic and cheap
|
|
8
|
+
* (no type checker), so ranking a large PR takes milliseconds.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import ts from 'typescript';
|
|
14
|
+
import { changedFunctions, isExported } from './changed-functions.js';
|
|
15
|
+
const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
|
|
16
|
+
/** Kinds of operation where a wrong line costs the most; matched on the function's text. */
|
|
17
|
+
export const SENSITIVE = [
|
|
18
|
+
{ kind: 'data-write', pattern: /\.(?:insert|update|upsert|delete|rpc)\s*\(|\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO\s+)?\w|\bON CONFLICT\b/ },
|
|
19
|
+
{ kind: 'paging', pattern: /\b(?:cursor|offset|nextPage|pageToken|hasMore|limit)\b/i },
|
|
20
|
+
{ kind: 'auth', pattern: /\b(?:token|session|password|secret|jwt|permission|role|authori[sz]e)\w*/i },
|
|
21
|
+
{ kind: 'money-or-time', pattern: /\b(?:amount|price|cents|currency|refund|timezone|Date\.now|new Date|setTimeout|toISOString)\b/ },
|
|
22
|
+
{ kind: 'concurrency', pattern: /\bPromise\.(?:all|race|allSettled)\b|\b(?:mutex|lock|semaphore|retry|backoff)\b/i },
|
|
23
|
+
{ kind: 'network', pattern: /\bfetch\w*\s*\(|\bnew Request\s*\(|\bAbort(?:Controller|Signal)\b|\baxios\b|\bhttps?\.request\b/ },
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Score one changed function from its signals; higher is riskier, 0 means
|
|
27
|
+
* "rules only". The router reviews functions at or above `min_score`.
|
|
28
|
+
*/
|
|
29
|
+
export function scoreRisk(signals) {
|
|
30
|
+
// Strong signals clear the default bar (1) on their own; shape alone (size, nesting) never does.
|
|
31
|
+
let score = signals.removedGuard ? 2 : 0;
|
|
32
|
+
score += signals.sensitive.includes('data-write') ? 2 : 0;
|
|
33
|
+
// Other sensitive kinds add, capped so a function that mentions everything does not drown the rest.
|
|
34
|
+
score += Math.min(2, signals.sensitive.filter(kind => kind !== 'data-write').length);
|
|
35
|
+
// Weak proxies only add to something already risky, or push a big, branchy public function over.
|
|
36
|
+
score += (signals.nesting >= 3 ? 0.5 : 0) + (signals.lines >= 40 ? 0.5 : 0);
|
|
37
|
+
score += signals.exported && signals.async ? 0.5 : 0;
|
|
38
|
+
return score;
|
|
39
|
+
}
|
|
40
|
+
/** Changed functions across the change, riskiest first. */
|
|
41
|
+
export function rankChangedFunctions(cwd, focusLines, removed = {}) {
|
|
42
|
+
const ranked = [];
|
|
43
|
+
for (const [file, lines] of Object.entries(focusLines)) {
|
|
44
|
+
if (!PARSEABLE.test(file) || lines.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
let text;
|
|
47
|
+
try {
|
|
48
|
+
text = fs.readFileSync(path.join(cwd, file), 'utf-8');
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
54
|
+
const textLines = text.split('\n');
|
|
55
|
+
for (const fn of changedFunctions(sourceFile, lines)) {
|
|
56
|
+
const start = sourceFile.getLineAndCharacterOfPosition(fn.getStart(sourceFile)).line + 1;
|
|
57
|
+
const end = sourceFile.getLineAndCharacterOfPosition(fn.getEnd()).line + 1;
|
|
58
|
+
const added = lines.filter(l => l >= start && l <= end).map(l => textLines[l - 1] ?? '');
|
|
59
|
+
const signals = signalsOf(fn, sourceFile, start, end, removed[file] ?? [], added);
|
|
60
|
+
ranked.push({ file, name: nameOf(fn, sourceFile), start, end, signals, score: scoreRisk(signals), hash: functionHash(fn.getText(sourceFile)) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return ranked.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file) || a.start - b.start);
|
|
64
|
+
}
|
|
65
|
+
/** The current text hash and span of a named function in a file, if the file parses and has it. */
|
|
66
|
+
export function findFunction(cwd, file, name) {
|
|
67
|
+
if (!PARSEABLE.test(file))
|
|
68
|
+
return undefined;
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = fs.readFileSync(path.join(cwd, file), 'utf-8');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
77
|
+
let found;
|
|
78
|
+
const visit = (node) => {
|
|
79
|
+
if (found)
|
|
80
|
+
return;
|
|
81
|
+
if ((ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node))
|
|
82
|
+
&& node.body && nameOf(node, sourceFile) === name) {
|
|
83
|
+
found = {
|
|
84
|
+
hash: functionHash(node.getText(sourceFile)),
|
|
85
|
+
start: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
|
|
86
|
+
end: sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1,
|
|
87
|
+
};
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
ts.forEachChild(node, visit);
|
|
91
|
+
};
|
|
92
|
+
visit(sourceFile);
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
export function functionHash(text) {
|
|
96
|
+
return crypto.createHash('sha256').update(text.replace(/\s+/g, ' ').trim()).digest('hex');
|
|
97
|
+
}
|
|
98
|
+
const GUARD = /\b(?:if|else|return|throw|break|continue|catch)\b|\?\?|\?\./g;
|
|
99
|
+
function guards(lines) {
|
|
100
|
+
return lines.reduce((count, line) => count + (line.match(GUARD)?.length ?? 0), 0);
|
|
101
|
+
}
|
|
102
|
+
function signalsOf(fn, sourceFile, start, end, removed, added) {
|
|
103
|
+
const body = fn.getText(sourceFile);
|
|
104
|
+
const removedLines = removed.filter(block => block.line >= start && block.line <= end + 1).flatMap(block => block.text);
|
|
105
|
+
return {
|
|
106
|
+
exported: isExported(fn),
|
|
107
|
+
async: !!ts.getModifiers(fn)?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
|
|
108
|
+
nesting: maxNesting(fn),
|
|
109
|
+
lines: end - start + 1,
|
|
110
|
+
removedGuard: guards(removedLines) > guards(added),
|
|
111
|
+
sensitive: SENSITIVE.filter(s => s.pattern.test(body)).map(s => s.kind),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function maxNesting(fn) {
|
|
115
|
+
let deepest = 0;
|
|
116
|
+
const walk = (node, depth) => {
|
|
117
|
+
const nests = ts.isIfStatement(node) || ts.isIterationStatement(node, false) || ts.isSwitchStatement(node)
|
|
118
|
+
|| ts.isTryStatement(node) || ts.isConditionalExpression(node);
|
|
119
|
+
const next = nests ? depth + 1 : depth;
|
|
120
|
+
deepest = Math.max(deepest, next);
|
|
121
|
+
if (node !== fn && (ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)))
|
|
122
|
+
return;
|
|
123
|
+
ts.forEachChild(node, child => walk(child, next));
|
|
124
|
+
};
|
|
125
|
+
ts.forEachChild(fn, child => walk(child, 0));
|
|
126
|
+
return deepest;
|
|
127
|
+
}
|
|
128
|
+
function nameOf(fn, sourceFile) {
|
|
129
|
+
if (fn.name)
|
|
130
|
+
return fn.name.getText(sourceFile);
|
|
131
|
+
const parent = fn.parent;
|
|
132
|
+
if (parent && (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent)) && parent.name)
|
|
133
|
+
return parent.name.getText(sourceFile);
|
|
134
|
+
return `<anonymous>@${sourceFile.getLineAndCharacterOfPosition(fn.getStart(sourceFile)).line + 1}`;
|
|
135
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { rankChangedFunctions, scoreRisk } from './risk.js';
|
|
6
|
+
import { routeFiles } from './router.js';
|
|
7
|
+
let dir;
|
|
8
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'risk-')); });
|
|
9
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
10
|
+
const SYNC = [
|
|
11
|
+
'export async function syncOrders(db, cursor) {', // 1
|
|
12
|
+
' for (const page of pages(cursor)) {', // 2
|
|
13
|
+
' if (page.rows.length) {', // 3
|
|
14
|
+
" await db.from('orders').upsert(page.rows);", // 4
|
|
15
|
+
' }', // 5
|
|
16
|
+
' }', // 6
|
|
17
|
+
'}', // 7
|
|
18
|
+
'function label(name) {', // 8
|
|
19
|
+
' return name.trim();', // 9
|
|
20
|
+
'}', // 10
|
|
21
|
+
].join('\n');
|
|
22
|
+
const plain = { exported: false, async: false, nesting: 0, lines: 3, removedGuard: false, sensitive: [] };
|
|
23
|
+
describe('rankChangedFunctions', () => {
|
|
24
|
+
it('reads the signals of each changed function', () => {
|
|
25
|
+
fs.writeFileSync(path.join(dir, 'sync.ts'), SYNC);
|
|
26
|
+
const ranked = rankChangedFunctions(dir, { 'sync.ts': [4, 9] }, { 'sync.ts': [{ line: 4, text: [' if (!page.ok) return;'] }] });
|
|
27
|
+
const byName = Object.fromEntries(ranked.map(f => [f.name, f]));
|
|
28
|
+
expect(byName.syncOrders.signals).toEqual({
|
|
29
|
+
exported: true, async: true, nesting: 2, lines: 7, removedGuard: true, sensitive: ['data-write', 'paging'],
|
|
30
|
+
});
|
|
31
|
+
expect(byName.label.signals).toMatchObject({ exported: false, async: false, nesting: 0, removedGuard: false, sensitive: [] });
|
|
32
|
+
expect([byName.label.start, byName.label.end]).toEqual([8, 10]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('scoreRisk', () => {
|
|
36
|
+
it('ranks a data write behind a removed guard above a plain helper', () => {
|
|
37
|
+
const risky = { exported: true, async: true, nesting: 2, lines: 30, removedGuard: true, sensitive: ['data-write', 'paging'] };
|
|
38
|
+
expect(scoreRisk(risky)).toBeGreaterThan(scoreRisk(plain));
|
|
39
|
+
});
|
|
40
|
+
it('gives a short, flat, private, non-sensitive function no model review', () => {
|
|
41
|
+
expect(scoreRisk(plain)).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
it('never scores below zero', () => {
|
|
44
|
+
expect(scoreRisk({ ...plain, lines: 1 })).toBeGreaterThanOrEqual(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('routeFiles', () => {
|
|
48
|
+
it('always keeps files it cannot rank, and reports what it left to the gates', () => {
|
|
49
|
+
fs.writeFileSync(path.join(dir, 'helper.ts'), 'function label(name) {\n return name.trim();\n}\n');
|
|
50
|
+
fs.writeFileSync(path.join(dir, 'query.py'), 'def f():\n return 1\n');
|
|
51
|
+
const routed = routeFiles(dir, ['helper.ts', 'query.py'], { 'helper.ts': [2], 'query.py': [2] }, {}, { min_score: 1 });
|
|
52
|
+
expect([...routed.files]).toEqual(['query.py']);
|
|
53
|
+
expect(routed.stats).toEqual({ functions: 1, routed: 0, files_skipped: 1, already_reviewed: 0 });
|
|
54
|
+
});
|
|
55
|
+
it('skips a risky function reviewed before at exactly this code, and only that code', () => {
|
|
56
|
+
fs.writeFileSync(path.join(dir, 'sync.ts'), SYNC);
|
|
57
|
+
const [risky] = rankChangedFunctions(dir, { 'sync.ts': [4] });
|
|
58
|
+
const cleared = routeFiles(dir, ['sync.ts'], { 'sync.ts': [4] }, {}, {}, [{ file: 'sync.ts', function: 'syncOrders', hash: risky.hash }]);
|
|
59
|
+
expect(cleared.stats).toMatchObject({ routed: 0, already_reviewed: 1, files_skipped: 1 });
|
|
60
|
+
const stale = routeFiles(dir, ['sync.ts'], { 'sync.ts': [4] }, {}, {}, [{ file: 'sync.ts', function: 'syncOrders', hash: 'old' }]);
|
|
61
|
+
expect(stale.stats).toMatchObject({ routed: 1, already_reviewed: 0 });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router: which changed files a paid model reviews.
|
|
3
|
+
*
|
|
4
|
+
* Files are chosen through their riskiest changed function (risk.ts). A file
|
|
5
|
+
* the ranking cannot see into (another language, or a change outside any
|
|
6
|
+
* function) is always reviewed: the router only cuts what it understands.
|
|
7
|
+
*/
|
|
8
|
+
import type { RemovedBlock } from '../utils/diff.js';
|
|
9
|
+
import { type ReviewedKey } from '../review/ledger.js';
|
|
10
|
+
export interface RouterPolicy {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
min_score?: number;
|
|
13
|
+
max_functions?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface RouterStats {
|
|
16
|
+
functions: number;
|
|
17
|
+
routed: number;
|
|
18
|
+
files_skipped: number;
|
|
19
|
+
/** Risky functions skipped because this exact code was reviewed before (ledger or reviewed.json). */
|
|
20
|
+
already_reviewed: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const DEFAULT_MIN_SCORE = 1;
|
|
23
|
+
export declare const DEFAULT_MAX_FUNCTIONS = 12;
|
|
24
|
+
export declare function routeFiles(cwd: string, files: string[], focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>, policy?: RouterPolicy, reviewed?: ReviewedKey[]): {
|
|
25
|
+
files: Set<string>;
|
|
26
|
+
stats: RouterStats;
|
|
27
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { rankChangedFunctions } from './risk.js';
|
|
2
|
+
import { isReviewed } from '../review/ledger.js';
|
|
3
|
+
export const DEFAULT_MIN_SCORE = 1;
|
|
4
|
+
export const DEFAULT_MAX_FUNCTIONS = 12;
|
|
5
|
+
export function routeFiles(cwd, files, focusLines, removed = {}, policy = {}, reviewed = []) {
|
|
6
|
+
const inScope = Object.fromEntries(files.filter(f => focusLines[f]?.length).map(f => [f, focusLines[f]]));
|
|
7
|
+
const ranked = rankChangedFunctions(cwd, inScope, removed);
|
|
8
|
+
const minScore = policy.min_score ?? DEFAULT_MIN_SCORE;
|
|
9
|
+
const risky = ranked.filter(f => f.score >= minScore);
|
|
10
|
+
const unreviewed = risky.filter(f => !isReviewed(reviewed, { file: f.file, function: f.name, hash: f.hash }));
|
|
11
|
+
const routed = unreviewed.slice(0, policy.max_functions ?? DEFAULT_MAX_FUNCTIONS);
|
|
12
|
+
const ranks = new Set(ranked.map(f => f.file));
|
|
13
|
+
const chosen = new Set([...routed.map(f => f.file), ...files.filter(f => !ranks.has(f))]);
|
|
14
|
+
return { files: chosen, stats: { functions: ranked.length, routed: routed.length, files_skipped: files.length - chosen.size, already_reviewed: risky.length - unreviewed.length } };
|
|
15
|
+
}
|