@rigour-labs/core 6.4.4 → 6.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/deep/agent-review.d.ts +19 -0
  2. package/dist/deep/agent-review.js +19 -0
  3. package/dist/deep/agent-review.test.d.ts +1 -0
  4. package/dist/deep/agent-review.test.js +53 -0
  5. package/dist/deep/code-pass.d.ts +10 -0
  6. package/dist/deep/code-pass.js +64 -33
  7. package/dist/deep/code-pass.test.js +53 -12
  8. package/dist/deep/code-verifier.d.ts +3 -1
  9. package/dist/deep/code-verifier.js +11 -11
  10. package/dist/deep/code-verifier.test.js +8 -0
  11. package/dist/deep/pr-diff.d.ts +7 -0
  12. package/dist/deep/pr-diff.js +47 -0
  13. package/dist/deep/pr-review.d.ts +33 -0
  14. package/dist/deep/pr-review.js +81 -0
  15. package/dist/deep/pr-review.test.d.ts +1 -0
  16. package/dist/deep/pr-review.test.js +80 -0
  17. package/dist/deep/related-changes.d.ts +13 -0
  18. package/dist/deep/related-changes.js +56 -0
  19. package/dist/deep/related-changes.test.d.ts +1 -0
  20. package/dist/deep/related-changes.test.js +33 -0
  21. package/dist/deep/review-tools.d.ts +16 -0
  22. package/dist/deep/review-tools.js +111 -0
  23. package/dist/deep/review-tools.test.d.ts +1 -0
  24. package/dist/deep/review-tools.test.js +39 -0
  25. package/dist/deep/risk.d.ts +41 -0
  26. package/dist/deep/risk.js +135 -0
  27. package/dist/deep/risk.test.d.ts +1 -0
  28. package/dist/deep/risk.test.js +63 -0
  29. package/dist/deep/router.d.ts +27 -0
  30. package/dist/deep/router.js +15 -0
  31. package/dist/deep/self-check.d.ts +14 -9
  32. package/dist/deep/self-check.js +41 -32
  33. package/dist/deep/tool-loop.d.ts +19 -0
  34. package/dist/deep/tool-loop.js +23 -0
  35. package/dist/gates/deep-analysis.d.ts +27 -0
  36. package/dist/gates/deep-analysis.js +73 -2
  37. package/dist/gates/deep-runner.js +14 -0
  38. package/dist/gates/frontend-secret-exposure.d.ts +2 -0
  39. package/dist/gates/frontend-secret-exposure.js +8 -0
  40. package/dist/gates/frontend-secret-exposure.test.js +14 -0
  41. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +1 -0
  42. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +27 -0
  43. package/dist/gates/hallucinated-imports/framework-modules.d.ts +12 -3
  44. package/dist/gates/hallucinated-imports/framework-modules.js +32 -6
  45. package/dist/gates/hallucinated-imports/index.js +2 -1
  46. package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +1 -0
  47. package/dist/gates/hallucinated-imports/js-resolver-types.test.js +14 -0
  48. package/dist/gates/hallucinated-imports/js-resolver.d.ts +6 -1
  49. package/dist/gates/hallucinated-imports/js-resolver.js +25 -3
  50. package/dist/gates/phantom-apis.js +2 -2
  51. package/dist/gates/phantom-apis.test.js +10 -0
  52. package/dist/gates/promise-safety.js +1 -1
  53. package/dist/gates/test-quality-matchers.js +3 -0
  54. package/dist/gates/test-quality.test.js +13 -0
  55. package/dist/hooks/checker.test.js +9 -0
  56. package/dist/hooks/stop-review.d.ts +2 -0
  57. package/dist/hooks/stop-review.js +19 -3
  58. package/dist/hooks/templates.d.ts +2 -0
  59. package/dist/hooks/templates.js +16 -2
  60. package/dist/hooks/templates.test.d.ts +1 -0
  61. package/dist/hooks/templates.test.js +16 -0
  62. package/dist/index.d.ts +13 -0
  63. package/dist/index.js +13 -0
  64. package/dist/inference/cloud-provider.d.ts +12 -1
  65. package/dist/inference/cloud-provider.js +137 -2
  66. package/dist/inference/cloud-provider.test.d.ts +1 -0
  67. package/dist/inference/cloud-provider.test.js +118 -0
  68. package/dist/inference/pricing.d.ts +15 -0
  69. package/dist/inference/pricing.js +13 -0
  70. package/dist/inference/types.d.ts +42 -0
  71. package/dist/review/changed-function-spans.d.ts +4 -0
  72. package/dist/review/changed-function-spans.js +48 -0
  73. package/dist/review/changed-lines.d.ts +9 -3
  74. package/dist/review/changed-lines.js +19 -2
  75. package/dist/review/deep-runs.d.ts +22 -0
  76. package/dist/review/deep-runs.js +59 -0
  77. package/dist/review/deep-runs.test.d.ts +1 -0
  78. package/dist/review/deep-runs.test.js +23 -0
  79. package/dist/review/generated-files.d.ts +3 -0
  80. package/dist/review/generated-files.js +35 -0
  81. package/dist/review/generated-files.test.d.ts +1 -0
  82. package/dist/review/generated-files.test.js +25 -0
  83. package/dist/review/ledger.d.ts +27 -0
  84. package/dist/review/ledger.js +72 -0
  85. package/dist/review/quiet.d.ts +16 -0
  86. package/dist/review/quiet.js +71 -0
  87. package/dist/review/quiet.test.d.ts +1 -0
  88. package/dist/review/quiet.test.js +27 -0
  89. package/dist/review/review-ack.d.ts +16 -0
  90. package/dist/review/review-ack.js +25 -0
  91. package/dist/review/review-task.d.ts +40 -0
  92. package/dist/review/review-task.js +50 -0
  93. package/dist/review/review-task.test.d.ts +1 -0
  94. package/dist/review/review-task.test.js +80 -0
  95. package/dist/review/review.d.ts +12 -1
  96. package/dist/review/review.js +16 -6
  97. package/dist/review/review.test.js +25 -0
  98. package/dist/review-learning/acted-on.d.ts +25 -0
  99. package/dist/review-learning/acted-on.js +69 -0
  100. package/dist/review-learning/learn-from-reviews.d.ts +34 -0
  101. package/dist/review-learning/learn-from-reviews.js +65 -0
  102. package/dist/review-learning/lessons.d.ts +52 -0
  103. package/dist/review-learning/lessons.js +149 -0
  104. package/dist/review-learning/repo-rules.d.ts +16 -0
  105. package/dist/review-learning/repo-rules.js +84 -0
  106. package/dist/review-learning/repo-rules.test.d.ts +1 -0
  107. package/dist/review-learning/repo-rules.test.js +39 -0
  108. package/dist/review-learning/review-learning.test.d.ts +1 -0
  109. package/dist/review-learning/review-learning.test.js +104 -0
  110. package/dist/review-learning/team-lessons.d.ts +4 -0
  111. package/dist/review-learning/team-lessons.js +24 -0
  112. package/dist/semantic/rules/credential-redirect.js +4 -1
  113. package/dist/settings.js +4 -1
  114. package/dist/telemetry/telemetry.d.ts +28 -0
  115. package/dist/telemetry/telemetry.js +152 -0
  116. package/dist/telemetry/telemetry.test.d.ts +1 -0
  117. package/dist/telemetry/telemetry.test.js +59 -0
  118. package/dist/telemetry/token.d.ts +1 -0
  119. package/dist/telemetry/token.js +1 -0
  120. package/dist/templates/universal-config.js +7 -3
  121. package/dist/types/index.d.ts +276 -10
  122. package/dist/types/index.js +38 -5
  123. package/package.json +6 -6
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Agentic review of one changed file: the model reviews it and may look
3
+ * things up (read files, search the repository) before it answers.
4
+ *
5
+ * Used when a cloud review has no diff to read as a whole (`rigour check
6
+ * <paths> --deep`); a pull request is reviewed as one conversation instead
7
+ * (pr-review.ts). What the model read extends the grounding context, so a
8
+ * finding it supports with code it looked up is still verified against code
9
+ * it actually saw.
10
+ */
11
+ import type { DeepFinding, InferenceOptions, InferenceProvider } from '../inference/types.js';
12
+ import type { CodeContext } from './code-context.js';
13
+ export interface AgentReviewResult {
14
+ findings: DeepFinding[];
15
+ /** The sent context, extended with what the model read in the reviewed file. */
16
+ context: CodeContext;
17
+ toolCalls: number;
18
+ }
19
+ export declare function reviewWithTools(provider: InferenceProvider, context: CodeContext, reference: string, cwd: string, inference: InferenceOptions): Promise<AgentReviewResult>;
@@ -0,0 +1,19 @@
1
+ import { buildCodeReviewPrompt } from './code-review-prompt.js';
2
+ import { parseFindings } from './parse-findings.js';
3
+ import { runToolLoop } from './tool-loop.js';
4
+ const BUDGET = { maxToolCalls: 12, maxTurns: 8 };
5
+ const TOOL_GUIDANCE = `TOOLS: you can call read_file and grep. Before you report a defect that depends on code you were not shown `
6
+ + `(a caller, a callee, a type, a constant, a config value), look it up. Drop a suspicion the code disproves. `
7
+ + `You have at most ${BUDGET.maxToolCalls} tool calls; when you are done, answer with the JSON findings only.`;
8
+ export async function reviewWithTools(provider, context, reference, cwd, inference) {
9
+ const prompt = `${buildCodeReviewPrompt(context, reference)}\n\n${TOOL_GUIDANCE}`;
10
+ const loop = await runToolLoop(provider, prompt, cwd, inference, BUDGET);
11
+ return { findings: parseFindings(loop.text), context: grounded(context, loop.toolbox), toolCalls: loop.toolCalls };
12
+ }
13
+ /** Lines of the reviewed file the model read count as shown; so does any text it read, for cited identifiers. */
14
+ function grounded(context, toolbox) {
15
+ const reads = toolbox.reads.get(context.file) ?? [];
16
+ if (reads.length === 0 && !toolbox.readText)
17
+ return context;
18
+ return { ...context, ranges: [...context.ranges, ...reads], source: `${context.source}\n${toolbox.readText}` };
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
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 { reviewWithTools } from './agent-review.js';
6
+ import { verifyCodeFindings } from './code-verifier.js';
7
+ let repo;
8
+ beforeEach(() => {
9
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-review-'));
10
+ fs.writeFileSync(path.join(repo, 'limits.ts'), 'export const MAX_ROWS = 0;\n');
11
+ fs.writeFileSync(path.join(repo, 'page.ts'), Array.from({ length: 60 }, (_, i) => `// line ${i + 1}`).join('\n'));
12
+ });
13
+ afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
14
+ const context = { file: 'page.ts', language: 'typescript', text: '1| export function page() {}', ranges: [[1, 5]], source: 'export function page() {}' };
15
+ function scripted(replies) {
16
+ const seen = [];
17
+ const provider = {
18
+ name: 'fake', isAvailable: async () => true, setup: async () => { }, dispose: () => { }, analyze: async () => '',
19
+ chat: async (messages, tools, options) => {
20
+ seen.push({ messages: [...messages], tools: tools.length, toolChoice: options?.toolChoice });
21
+ return replies.shift() ?? { text: '{"findings": []}', toolCalls: [] };
22
+ },
23
+ };
24
+ return { provider, seen };
25
+ }
26
+ const findingJson = (line, description) => JSON.stringify({ findings: [{ category: 'correctness', severity: 'high', file: 'page.ts', line, description, suggestion: 's', confidence: 0.9 }] });
27
+ describe('reviewWithTools', () => {
28
+ it('runs the tools the model asks for, then grounds its finding in what it read', async () => {
29
+ const { provider, seen } = scripted([
30
+ { text: '', toolCalls: [{ id: 't1', name: 'read_file', arguments: { path: 'limits.ts' } }, { id: 't2', name: 'read_file', arguments: { path: 'page.ts', start_line: 40, end_line: 45 } }] },
31
+ { text: findingJson(42, '`MAX_ROWS` is 0, so the page loop never runs.'), toolCalls: [] },
32
+ ]);
33
+ const result = await reviewWithTools(provider, context, '', repo, {});
34
+ expect(result.toolCalls).toBe(2);
35
+ const toolResults = seen[1].messages.filter(m => m.role === 'tool').map(m => m.content);
36
+ expect(toolResults[0]).toContain('export const MAX_ROWS = 0;');
37
+ // Line 42 was outside the sent source, but the model read it, and it read MAX_ROWS.
38
+ expect(verifyCodeFindings(result.findings, [result.context])).toHaveLength(1);
39
+ expect(verifyCodeFindings(result.findings, [context])).toHaveLength(0);
40
+ });
41
+ it('forbids tool calls after the budget, keeping the tools defined for the history, and asks for the answer', async () => {
42
+ const greedy = Array.from({ length: 20 }, (_, i) => ({ text: '', toolCalls: [{ id: `t${i}`, name: 'grep', arguments: { pattern: 'line' } }] }));
43
+ const { provider, seen } = scripted(greedy);
44
+ const result = await reviewWithTools(provider, context, '', repo, {});
45
+ expect(result.toolCalls).toBeLessThanOrEqual(12);
46
+ expect(seen.at(-1)).toMatchObject({ tools: 2, toolChoice: 'none' });
47
+ expect(result.findings).toEqual([]);
48
+ });
49
+ it('refuses a provider that cannot call tools', async () => {
50
+ const provider = { name: 'local', isAvailable: async () => true, setup: async () => { }, dispose: () => { }, analyze: async () => '' };
51
+ await expect(reviewWithTools(provider, context, '', repo, {})).rejects.toThrow('cannot call tools');
52
+ });
53
+ });
@@ -18,6 +18,12 @@ export interface CodePassConfig extends PassConfig {
18
18
  removed?: Record<string, RemovedBlock[]>;
19
19
  prBody?: string;
20
20
  };
21
+ /** Epoch ms after which no new file is started (the run budget). */
22
+ deadline?: number;
23
+ /** Files reviewed at once. */
24
+ concurrency?: number;
25
+ /** Let a tool-capable provider read the repository while it reviews (agent-review.ts). */
26
+ agentic?: boolean;
21
27
  }
22
28
  export interface CodePassResult extends PassResult {
23
29
  contexts: CodeContext[];
@@ -25,5 +31,9 @@ export interface CodePassResult extends PassResult {
25
31
  proposed: number;
26
32
  /** Of those, withdrawn by the self-check. */
27
33
  withdrawn: number;
34
+ /** Files not started before the deadline. */
35
+ skipped: number;
36
+ /** Repository lookups the model made (agentic review). */
37
+ toolCalls: number;
28
38
  }
29
39
  export declare function runCodePass(provider: InferenceProvider, facts: FileFacts[], config: CodePassConfig): Promise<CodePassResult>;
@@ -9,47 +9,78 @@ import { buildCodeReviewPrompt } from './code-review-prompt.js';
9
9
  import { parseFindings } from './parse-findings.js';
10
10
  import { buildReferencePack } from './reference-pack.js';
11
11
  import { selfCheck } from './self-check.js';
12
+ import { reviewWithTools } from './agent-review.js';
12
13
  import { Logger } from '../utils/logger.js';
13
14
  export async function runCodePass(provider, facts, config) {
14
- const result = { findings: [], chunksTotal: facts.length, chunksFailed: 0, contexts: [], proposed: 0, withdrawn: 0 };
15
- for (let i = 0; i < facts.length; i++) {
16
- const file = facts[i];
17
- config.onProgress?.(` Reviewing ${file.path} (${i + 1}/${facts.length})...`);
18
- let context;
19
- try {
20
- const content = await fs.readFile(path.join(config.cwd, file.path), 'utf-8');
21
- context = buildCodeContext(file, content, {
22
- maxChars: config.maxSourceChars,
23
- focusLines: config.focusLines?.[file.path],
24
- });
15
+ const result = { findings: [], chunksTotal: facts.length, chunksFailed: 0, contexts: [], proposed: 0, withdrawn: 0, skipped: 0, toolCalls: 0 };
16
+ const reviews = new Array(facts.length);
17
+ let next = 0;
18
+ const worker = async () => {
19
+ while (next < facts.length) {
20
+ const index = next++;
21
+ if (config.deadline !== undefined && Date.now() >= config.deadline)
22
+ continue;
23
+ config.onProgress?.(` Reviewing ${facts[index].path} (${index + 1}/${facts.length})...`);
24
+ reviews[index] = await reviewFile(provider, facts[index], config);
25
25
  }
26
- catch (error) {
27
- recordFailure(result, `${file.path}: ${error.message}`, config);
26
+ };
27
+ const workers = Math.max(1, Math.min(config.concurrency ?? 1, facts.length));
28
+ await Promise.all(Array.from({ length: workers }, worker));
29
+ // Merge in file order, so the result does not depend on which call finished first.
30
+ for (const review of reviews) {
31
+ if (!review) {
32
+ result.skipped++;
28
33
  continue;
29
34
  }
30
- const pack = config.reference ? packFor(file.path, config) : undefined;
31
- if (pack)
32
- context = { ...context, source: `${context.source}\n${pack.source}` };
33
- result.contexts.push(context);
34
- try {
35
- if (pack) {
36
- const checked = await reviewTwiceAndCheck(provider, context, pack, config);
37
- result.findings.push(...checked.kept);
38
- result.proposed += checked.proposed;
39
- result.withdrawn += checked.withdrawn;
40
- }
41
- else {
42
- const found = await reviewOnce(provider, context, '', config);
43
- result.findings.push(...found);
44
- result.proposed += found.length;
45
- }
46
- }
47
- catch (error) {
48
- recordFailure(result, `${file.path}: ${error.message}`, config);
49
- }
35
+ if (review.context)
36
+ result.contexts.push(review.context);
37
+ if (review.error)
38
+ recordFailure(result, review.error, config);
39
+ result.findings.push(...review.findings);
40
+ result.proposed += review.proposed;
41
+ result.withdrawn += review.withdrawn;
42
+ result.toolCalls += review.toolCalls ?? 0;
50
43
  }
51
44
  return result;
52
45
  }
46
+ async function reviewFile(provider, file, config) {
47
+ const empty = { findings: [], proposed: 0, withdrawn: 0 };
48
+ let context;
49
+ try {
50
+ const content = await fs.readFile(path.join(config.cwd, file.path), 'utf-8');
51
+ context = buildCodeContext(file, content, {
52
+ maxChars: config.maxSourceChars,
53
+ focusLines: config.focusLines?.[file.path],
54
+ });
55
+ }
56
+ catch (error) {
57
+ return { ...empty, error: `${file.path}: ${error.message}` };
58
+ }
59
+ const pack = config.reference ? packFor(file.path, config) : undefined;
60
+ if (pack)
61
+ context = { ...context, source: `${context.source}\n${pack.source}` };
62
+ try {
63
+ if (pack && config.agentic && provider.chat)
64
+ return await reviewAgentically(provider, context, pack, config);
65
+ if (pack) {
66
+ const checked = await reviewTwiceAndCheck(provider, context, pack, config);
67
+ return { context, findings: checked.kept, proposed: checked.proposed, withdrawn: checked.withdrawn };
68
+ }
69
+ const found = await reviewOnce(provider, context, '', config);
70
+ return { context, findings: found, proposed: found.length, withdrawn: 0 };
71
+ }
72
+ catch (error) {
73
+ return { ...empty, context, error: `${file.path}: ${error.message}` };
74
+ }
75
+ }
76
+ async function reviewAgentically(provider, context, pack, config) {
77
+ const review = await reviewWithTools(provider, context, pack.text, config.cwd, config.inference);
78
+ const found = dedupe(review.findings);
79
+ if (found.length === 0)
80
+ return { context: review.context, findings: [], proposed: 0, withdrawn: 0, toolCalls: review.toolCalls };
81
+ const checked = await selfCheck(provider, review.context, pack.text, found, config.inference);
82
+ return { context: review.context, findings: checked.kept, proposed: found.length, withdrawn: checked.withdrawn, toolCalls: review.toolCalls };
83
+ }
53
84
  async function reviewOnce(provider, context, reference, config) {
54
85
  return parseFindings(await provider.analyze(buildCodeReviewPrompt(context, reference), config.inference));
55
86
  }
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
5
  import { runCodePass } from './code-pass.js';
6
- import { parseVerdict } from './self-check.js';
6
+ import { parseVerdicts } from './self-check.js';
7
7
  let dir;
8
8
  afterEach(() => { if (dir)
9
9
  fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; });
@@ -12,16 +12,17 @@ const facts = { path: 'total.ts', language: 'typescript', classes: [], functions
12
12
  function finding(line, category, description) {
13
13
  return { category, severity: 'high', file: 'total.ts', line, description, suggestion: 's', confidence: 0.9 };
14
14
  }
15
- /** Review passes answer from `reviews` in turn; self-checks withdraw findings whose line is in `withdraw`. */
15
+ const isCheck = (prompt) => /^You reported \d+ defect/.test(prompt);
16
+ /** Review passes answer from `reviews` in turn; the self-check withdraws findings whose line is in `withdraw`. */
16
17
  function fakeProvider(reviews, withdraw) {
17
18
  const prompts = [];
18
19
  const provider = {
19
20
  name: 'fake', isAvailable: async () => true, setup: async () => { }, dispose: () => { },
20
21
  analyze: async (prompt) => {
21
22
  prompts.push(prompt);
22
- if (prompt.startsWith('You reported a defect')) {
23
- const line = Number(prompt.match(/REPORTED at total\.ts:(\d+)/)?.[1]);
24
- return JSON.stringify({ real: !withdraw.includes(line) });
23
+ if (isCheck(prompt)) {
24
+ const listed = [...prompt.matchAll(/^(\d+)\. total\.ts:(\d+):/gm)];
25
+ return JSON.stringify({ verdicts: listed.map(m => ({ n: Number(m[1]), real: !withdraw.includes(Number(m[2])) })) });
25
26
  }
26
27
  return JSON.stringify({ findings: reviews.shift() ?? [] });
27
28
  },
@@ -46,7 +47,7 @@ describe('runCodePass', () => {
46
47
  await run(provider, { maxChars: 4000, prBody: 'Sum the basket.' });
47
48
  expect(prompts).toHaveLength(1);
48
49
  });
49
- it('with reference material, reviews twice in opposite orders, merges, and keeps what the self-check confirms', async () => {
50
+ it('with reference material, reviews twice in opposite orders, merges, and keeps what one self-check call confirms', async () => {
50
51
  const { provider, prompts } = fakeProvider([
51
52
  [finding(3, 'correctness', 'reads past the end of `items`')],
52
53
  [finding(3, 'correctness', 'same bug seen again'), finding(2, 'consistency', 'style-ish claim about `sum`')],
@@ -55,17 +56,57 @@ describe('runCodePass', () => {
55
56
  maxChars: 4000, prBody: 'Sum the basket.',
56
57
  removed: { 'total.ts': [{ line: 3, text: [' for (let i = 0; i < items.length; i++) sum += items[i];'] }] },
57
58
  });
58
- const reviews = prompts.filter(p => !p.startsWith('You reported a defect'));
59
+ const reviews = prompts.filter(p => !isCheck(p));
59
60
  expect(reviews).toHaveLength(2);
60
61
  expect(reviews[0].indexOf('PR DESCRIPTION')).toBeLessThan(reviews[0].indexOf('REMOVED by this change'));
61
62
  expect(reviews[1].indexOf('REMOVED by this change')).toBeLessThan(reviews[1].indexOf('PR DESCRIPTION'));
62
- expect(prompts.filter(p => p.startsWith('You reported a defect'))).toHaveLength(2);
63
+ expect(prompts.filter(isCheck)).toHaveLength(1);
63
64
  expect(result.findings.map(f => [f.line, f.category])).toEqual([[3, 'correctness']]);
64
65
  expect([result.proposed, result.withdrawn]).toEqual([2, 1]); // two distinct findings, one withdrawn
65
66
  });
66
- it('reads a self-check reply only when it is a verdict', () => {
67
- expect(parseVerdict('{"real": false, "reason": "handled"}')).toBe(false);
68
- expect(parseVerdict('Sure! {"real": true}')).toBe(true);
69
- expect(parseVerdict('I am not sure')).toBeUndefined();
67
+ it('reads only well-formed verdicts from a self-check reply', () => {
68
+ expect([...parseVerdicts('Sure! {"verdicts": [{"n": 1, "real": false}, {"n": 2, "real": true}, {"n": "3", "real": false}]}')])
69
+ .toEqual([[1, false], [2, true]]);
70
+ expect(parseVerdicts('I am not sure').size).toBe(0);
71
+ });
72
+ it('keeps every finding when the self-check call fails', async () => {
73
+ const { provider } = fakeProvider([[finding(3, 'correctness', 'reads past the end of `items`')], []], []);
74
+ const analyze = provider.analyze;
75
+ provider.analyze = async (prompt, options) => isCheck(prompt) ? Promise.reject(new Error('timeout')) : analyze(prompt, options);
76
+ const result = await run(provider, { maxChars: 4000, prBody: 'Sum the basket.', removed: { 'total.ts': [{ line: 3, text: ['x'] }] } });
77
+ expect(result.findings.map(f => f.line)).toEqual([3]);
78
+ expect(result.withdrawn).toBe(0);
79
+ });
80
+ it('starts no file after the deadline, and counts what it skipped', async () => {
81
+ const { provider, prompts } = fakeProvider([[]], []);
82
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-pass-'));
83
+ fs.writeFileSync(path.join(dir, 'total.ts'), SOURCE);
84
+ const result = await runCodePass(provider, [facts, facts], { cwd: dir, inference: {}, maxSourceChars: 10_000, deadline: Date.now() - 1 });
85
+ expect(prompts).toHaveLength(0);
86
+ expect(result.skipped).toBe(2);
87
+ });
88
+ it('reviews files concurrently but reports them in file order', async () => {
89
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-pass-'));
90
+ const names = ['a.ts', 'b.ts', 'c.ts'];
91
+ for (const name of names)
92
+ fs.writeFileSync(path.join(dir, name), SOURCE);
93
+ let inFlight = 0;
94
+ let peak = 0;
95
+ const provider = {
96
+ name: 'fake', isAvailable: async () => true, setup: async () => { }, dispose: () => { },
97
+ analyze: async (prompt) => {
98
+ inFlight++;
99
+ peak = Math.max(peak, inFlight);
100
+ const file = names.find(n => prompt.includes(n));
101
+ await new Promise(resolve => setTimeout(resolve, file === 'a.ts' ? 30 : 5));
102
+ inFlight--;
103
+ return JSON.stringify({ findings: [{ ...finding(3, 'correctness', 'off by one'), file }] });
104
+ },
105
+ };
106
+ const many = names.map(name => ({ ...facts, path: name }));
107
+ const result = await runCodePass(provider, many, { cwd: dir, inference: {}, maxSourceChars: 10_000, concurrency: 2 });
108
+ expect(peak).toBe(2);
109
+ expect(result.findings.map(f => f.file)).toEqual(names);
110
+ expect(result.contexts.map(c => c.file)).toEqual(names);
70
111
  });
71
112
  });
@@ -14,6 +14,8 @@
14
14
  import type { DeepFinding } from '../inference/types.js';
15
15
  import type { CodeContext } from './code-context.js';
16
16
  import type { VerifiedFinding } from './verifier.js';
17
- export declare function verifyCodeFindings(findings: DeepFinding[], contexts: CodeContext[]): VerifiedFinding[];
17
+ export type RejectionReason = 'no_context' | 'low_confidence' | 'no_line' | 'out_of_range' | 'ungrounded_identifier';
18
+ /** Grounded findings, and how many were dropped for each reason (so a drop is never silent). */
19
+ export declare function verifyCodeFindings(findings: DeepFinding[], contexts: CodeContext[], rejected?: Partial<Record<RejectionReason, number>>): VerifiedFinding[];
18
20
  /** Backticked, identifier-like tokens (skips quoted prose and code fragments). */
19
21
  export declare function quotedIdentifiers(text: string): string[];
@@ -1,28 +1,28 @@
1
1
  const MIN_CONFIDENCE = 0.3;
2
- export function verifyCodeFindings(findings, contexts) {
2
+ /** Grounded findings, and how many were dropped for each reason (so a drop is never silent). */
3
+ export function verifyCodeFindings(findings, contexts, rejected = {}) {
3
4
  const verified = [];
4
5
  for (const finding of findings) {
5
6
  const context = findContext(finding.file, contexts);
6
- if (!context)
7
- continue;
8
- const note = rejectionReason(finding, context);
9
- if (note)
7
+ const reason = context ? rejectionReason(finding, context) : 'no_context';
8
+ if (reason) {
9
+ rejected[reason] = (rejected[reason] ?? 0) + 1;
10
10
  continue;
11
+ }
11
12
  verified.push({ ...finding, file: context.file, verified: true, verificationNotes: 'grounded in sent source' });
12
13
  }
13
14
  return verified;
14
15
  }
15
16
  function rejectionReason(finding, context) {
16
17
  if (finding.confidence < MIN_CONFIDENCE)
17
- return 'low confidence';
18
+ return 'low_confidence';
18
19
  const line = typeof finding.line === 'number' && finding.line > 0 ? finding.line : null;
19
20
  if (line === null)
20
- return 'no line';
21
+ return 'no_line';
21
22
  if (!context.ranges.some(([s, e]) => line >= s && line <= e))
22
- return 'line outside sent source';
23
- const missing = quotedIdentifiers(finding.description).find(id => !context.source.includes(id));
24
- if (missing)
25
- return `identifier \`${missing}\` not in sent source`;
23
+ return 'out_of_range';
24
+ if (quotedIdentifiers(finding.description).some(id => !context.source.includes(id)))
25
+ return 'ungrounded_identifier';
26
26
  return null;
27
27
  }
28
28
  /** Backticked, identifier-like tokens (skips quoted prose and code fragments). */
@@ -15,6 +15,14 @@ function finding(overrides) {
15
15
  };
16
16
  }
17
17
  describe('verifyCodeFindings', () => {
18
+ it('counts every dropped finding by reason, so a drop is never silent', () => {
19
+ const rejected = {};
20
+ verifyCodeFindings([
21
+ finding({}), finding({ file: 'src/other.ts' }), finding({ line: 80 }), finding({ line: undefined }),
22
+ finding({ confidence: 0.1 }), finding({ description: '`fetchEverything` has no limit.' }),
23
+ ], [context], rejected);
24
+ expect(rejected).toEqual({ no_context: 1, out_of_range: 1, no_line: 1, low_confidence: 1, ungrounded_identifier: 1 });
25
+ });
18
26
  it('keeps a finding grounded in the sent source', () => {
19
27
  const [kept] = verifyCodeFindings([finding({})], [context]);
20
28
  expect(kept).toMatchObject({ file: 'src/api/report.ts', verified: true });
@@ -0,0 +1,7 @@
1
+ export interface DiffSection {
2
+ file: string;
3
+ /** The section with new-side line numbers. */
4
+ text: string;
5
+ addedLines: number;
6
+ }
7
+ export declare function diffSections(diff: string): DiffSection[];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A diff as a reviewer reads it: one section per file, every new-side line
3
+ * carrying its line number, so a finding can name the exact line to fix.
4
+ * Lockfiles, build output and minified files carry no reviewable logic and
5
+ * are left out.
6
+ */
7
+ const GENERATED = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|Cargo\.lock|poetry\.lock|go\.sum)$|(^|\/)(dist|build|out|coverage|\.next|node_modules)\/|\.min\.(js|css)$|\.(snap|map)$|(\.|-|_)(gen|generated|pb)\.[cm]?[jt]sx?$|(^|\/)__generated__\//;
8
+ export function diffSections(diff) {
9
+ const sections = [];
10
+ let current = null;
11
+ let newLine = 0;
12
+ let out = [];
13
+ const flush = () => {
14
+ if (current && !GENERATED.test(current.file))
15
+ sections.push({ ...current, text: out.join('\n') });
16
+ };
17
+ for (const line of diff.split('\n')) {
18
+ if (line.startsWith('diff --git ')) {
19
+ flush();
20
+ current = { file: '', text: '', addedLines: 0 };
21
+ out = [];
22
+ }
23
+ else if (!current) {
24
+ continue;
25
+ }
26
+ else if (line.startsWith('+++ ')) {
27
+ current.file = line.startsWith('+++ b/') ? line.slice(6).trim() : '';
28
+ out.push(`FILE ${current.file || '(deleted)'}`);
29
+ }
30
+ else if (line.startsWith('@@')) {
31
+ newLine = Number(/\+(\d+)/.exec(line)?.[1] ?? 1);
32
+ out.push(line);
33
+ }
34
+ else if (line.startsWith('+')) {
35
+ out.push(`${String(newLine++).padStart(5)} + ${line.slice(1)}`);
36
+ current.addedLines++;
37
+ }
38
+ else if (line.startsWith('-') && !line.startsWith('--- ')) {
39
+ out.push(` - ${line.slice(1)}`);
40
+ }
41
+ else if (line.startsWith(' ')) {
42
+ out.push(`${String(newLine++).padStart(5)} ${line.slice(1)}`);
43
+ }
44
+ }
45
+ flush();
46
+ return sections.filter(s => s.file);
47
+ }
@@ -0,0 +1,33 @@
1
+ import type { DeepFinding, InferenceOptions, InferenceProvider } from '../inference/types.js';
2
+ import type { CodeContext } from './code-context.js';
3
+ import type { RelatedChange } from './related-changes.js';
4
+ export interface FocusItem {
5
+ file: string;
6
+ function: string;
7
+ start: number;
8
+ questions: string[];
9
+ }
10
+ export interface PrReviewInput {
11
+ cwd: string;
12
+ diff: string;
13
+ /** Risk-ranked changed functions to look at first, with what to check. */
14
+ focus: FocusItem[];
15
+ /** Changed functions called from other changed files: both sides of a contract moved. */
16
+ related?: RelatedChange[];
17
+ /** The team's past review lessons that apply to this change, already rendered. */
18
+ lessons?: string;
19
+ /** The repository's own rules that apply to this change, already rendered. */
20
+ rules?: string;
21
+ prBody?: string;
22
+ }
23
+ export interface PrReviewResult {
24
+ findings: DeepFinding[];
25
+ /** Changed files and files the model read, whole, plus what it read: what findings are grounded against. */
26
+ contexts: CodeContext[];
27
+ toolCalls: number;
28
+ }
29
+ export declare function reviewPullRequest(provider: InferenceProvider, input: PrReviewInput, inference: InferenceOptions): Promise<PrReviewResult>;
30
+ export declare function buildPrPrompt(input: PrReviewInput): {
31
+ prompt: string;
32
+ sentDiff: string;
33
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Review a pull request as one conversation, the way a senior reviewer does:
3
+ * read the whole diff, start with the riskiest changed functions, and look up
4
+ * whatever a suspicion depends on before reporting it.
5
+ *
6
+ * Reviewing file by file, a model never sees that one file now reads a value
7
+ * live while another still hard-codes it; on real PRs, the same model family
8
+ * pre-empted 1 of 10 acted-on review comments file by file, and 4 of 10
9
+ * reading the PR whole with repository tools, with fewer findings per PR.
10
+ */
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { REVIEW_CATEGORIES } from './code-review-prompt.js';
14
+ import { parseFindings } from './parse-findings.js';
15
+ import { diffSections } from './pr-diff.js';
16
+ import { runToolLoop } from './tool-loop.js';
17
+ const BUDGET = { maxToolCalls: 24, maxTurns: 14 };
18
+ const MAX_DIFF_CHARS = 80_000;
19
+ const MAX_FINDINGS = 5;
20
+ const PR_BODY_CHARS = 1500;
21
+ export async function reviewPullRequest(provider, input, inference) {
22
+ const { prompt, sentDiff } = buildPrPrompt(input);
23
+ const loop = await runToolLoop(provider, prompt, input.cwd, inference, BUDGET);
24
+ const findings = parseFindings(loop.text).slice(0, MAX_FINDINGS);
25
+ const files = new Set([...diffSections(input.diff).map(s => s.file), ...loop.toolbox.reads.keys(), ...findings.map(f => f.file)]);
26
+ const shown = `${sentDiff}\n${loop.toolbox.readText}`;
27
+ return { findings, contexts: [...files].flatMap(file => wholeFile(input.cwd, file, shown)), toolCalls: loop.toolCalls };
28
+ }
29
+ export function buildPrPrompt(input) {
30
+ const related = input.related ?? [];
31
+ const relatedFiles = new Set(related.flatMap(r => [r.calleeFile, r.callerFile]));
32
+ const focusFiles = new Set(input.focus.map(f => f.file));
33
+ const rank = (file) => (relatedFiles.has(file) ? 2 : 0) + (focusFiles.has(file) ? 1 : 0);
34
+ const sections = diffSections(input.diff).sort((a, b) => rank(b.file) - rank(a.file));
35
+ const sent = [];
36
+ const omitted = [];
37
+ let used = 0;
38
+ for (const section of sections) {
39
+ if (used + section.text.length <= MAX_DIFF_CHARS) {
40
+ sent.push(section.text);
41
+ used += section.text.length;
42
+ }
43
+ else {
44
+ omitted.push(`- ${section.file} (+${section.addedLines} lines): use read_file`);
45
+ }
46
+ }
47
+ const sentDiff = sent.join('\n\n');
48
+ const categories = REVIEW_CATEGORIES.map(c => `- ${c.key}: ${c.focus}`).join('\n');
49
+ const focus = input.focus.map(f => `- ${f.file}:${f.start} \`${f.function}\`: ${f.questions.join(' ')}`).join('\n');
50
+ const contracts = related.map(r => `- \`${r.callee}\` changed at ${r.calleeFile}:${r.calleeLine}; called from ${r.callerFile}:${r.callerLine}, a file this PR also changes. Does the caller still match what the function now expects and returns?`).join('\n');
51
+ const prompt = [
52
+ 'You are a senior engineer reviewing a pull request before it merges. Find defects this change introduces that would make the code behave wrongly, unsafely, or fail in production.',
53
+ `CATEGORIES:\n${categories}`,
54
+ `RULES:
55
+ 1. Report only defects the change introduces, anchored on a line it added or changed: use the new-side line numbers shown in the diff, and the file path after FILE.
56
+ 2. Before reporting anything that depends on code not in the diff (a caller, a callee, a type, a constant, a config value, another changed file), look it up with read_file or grep. Drop a suspicion the code disproves.
57
+ 3. Do not report style, naming, formatting, tests, documentation, or speculation about code you have not read.
58
+ 4. Put identifiers in backticks, spelled exactly as in the code.
59
+ 5. At most ${MAX_FINDINGS} findings, only ones you are confident are real. An empty list is a good answer.
60
+ 6. You have at most ${BUDGET.maxToolCalls} tool calls. Finish with ONLY JSON: {"findings":[{"category","severity","file","line","description","suggestion","confidence"}]}.`,
61
+ input.prBody ? `PR DESCRIPTION (what the author intended):\n${input.prBody.slice(0, PR_BODY_CHARS)}` : '',
62
+ input.rules ?? '',
63
+ input.lessons ?? '',
64
+ contracts ? `BOTH SIDES OF A CALL CHANGED (check these contracts first):\n${contracts}` : '',
65
+ focus ? `LOOK FIRST (riskiest changed functions, and what to check):\n${focus}` : '',
66
+ `PR DIFF (new-side line numbers on the left; "-" lines were removed):\n${sentDiff}`,
67
+ omitted.length ? `NOT SHOWN (diff too large):\n${omitted.join('\n')}` : '',
68
+ ].filter(Boolean).join('\n\n');
69
+ return { prompt, sentDiff };
70
+ }
71
+ /** A file the review may cite anywhere in, with what the model was shown for identifier checks. */
72
+ function wholeFile(cwd, file, shown) {
73
+ try {
74
+ const content = fs.readFileSync(path.join(cwd, file), 'utf-8');
75
+ const lines = content.split('\n').length;
76
+ return [{ file, language: '', text: '', ranges: [[1, lines]], source: `${shown}\n${content}` }];
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
@@ -0,0 +1 @@
1
+ export {};