@rigour-labs/core 6.2.3 → 6.2.5

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 (100) hide show
  1. package/dist/deep/code-context.d.ts +28 -0
  2. package/dist/deep/code-context.js +93 -0
  3. package/dist/deep/code-context.test.d.ts +1 -0
  4. package/dist/deep/code-context.test.js +48 -0
  5. package/dist/deep/code-pass.d.ts +15 -0
  6. package/dist/deep/code-pass.js +44 -0
  7. package/dist/deep/code-review-prompt.d.ts +17 -0
  8. package/dist/deep/code-review-prompt.js +30 -0
  9. package/dist/deep/code-review-prompt.test.d.ts +1 -0
  10. package/dist/deep/code-review-prompt.test.js +20 -0
  11. package/dist/deep/code-verifier.d.ts +19 -0
  12. package/dist/deep/code-verifier.js +48 -0
  13. package/dist/deep/code-verifier.test.d.ts +1 -0
  14. package/dist/deep/code-verifier.test.js +46 -0
  15. package/dist/deep/fact-extractor.d.ts +7 -1
  16. package/dist/deep/fact-extractor.js +16 -8
  17. package/dist/deep/fact-extractor.test.js +34 -1
  18. package/dist/deep/facts-pass.d.ts +24 -0
  19. package/dist/deep/facts-pass.js +69 -0
  20. package/dist/deep/index.d.ts +6 -1
  21. package/dist/deep/index.js +5 -1
  22. package/dist/deep/parse-findings.d.ts +12 -0
  23. package/dist/deep/parse-findings.js +73 -0
  24. package/dist/deep/parse-findings.test.d.ts +1 -0
  25. package/dist/deep/parse-findings.test.js +25 -0
  26. package/dist/deep/prompts.d.ts +1 -1
  27. package/dist/deep/prompts.js +7 -8
  28. package/dist/deep/prompts.test.js +15 -0
  29. package/dist/gates/context.js +7 -0
  30. package/dist/gates/deep-analysis.d.ts +31 -8
  31. package/dist/gates/deep-analysis.js +165 -306
  32. package/dist/gates/deep-analysis.test.d.ts +1 -0
  33. package/dist/gates/deep-analysis.test.js +86 -0
  34. package/dist/gates/deep-runner.d.ts +15 -0
  35. package/dist/gates/deep-runner.js +65 -0
  36. package/dist/gates/dependency.js +4 -0
  37. package/dist/gates/hallucinated-imports/index.js +13 -9
  38. package/dist/gates/hallucinated-imports.test.js +10 -0
  39. package/dist/gates/js-style-context.d.ts +3 -0
  40. package/dist/gates/js-style-context.js +65 -0
  41. package/dist/gates/js-style-context.test.d.ts +1 -0
  42. package/dist/gates/js-style-context.test.js +35 -0
  43. package/dist/gates/logic-drift-git-base.d.ts +7 -0
  44. package/dist/gates/logic-drift-git-base.js +41 -0
  45. package/dist/gates/logic-drift.d.ts +3 -3
  46. package/dist/gates/logic-drift.js +39 -7
  47. package/dist/gates/logic-drift.test.d.ts +1 -0
  48. package/dist/gates/logic-drift.test.js +45 -0
  49. package/dist/gates/runner.js +5 -40
  50. package/dist/gates/runner.test.js +37 -25
  51. package/dist/gates/scoped-gates.test.d.ts +1 -0
  52. package/dist/gates/scoped-gates.test.js +52 -0
  53. package/dist/gates/security-command-execution.d.ts +5 -0
  54. package/dist/gates/security-command-execution.js +123 -0
  55. package/dist/gates/security-patterns-data.js +1 -17
  56. package/dist/gates/security-patterns.d.ts +0 -5
  57. package/dist/gates/security-patterns.js +11 -19
  58. package/dist/gates/security-patterns.test.js +37 -2
  59. package/dist/gates/style-drift.d.ts +4 -4
  60. package/dist/gates/style-drift.js +15 -16
  61. package/dist/gates/style-drift.test.d.ts +1 -0
  62. package/dist/gates/style-drift.test.js +26 -0
  63. package/dist/index.d.ts +6 -1
  64. package/dist/index.js +6 -1
  65. package/dist/inference/http-download.d.ts +22 -0
  66. package/dist/inference/http-download.js +132 -0
  67. package/dist/inference/http-download.test.d.ts +1 -0
  68. package/dist/inference/http-download.test.js +109 -0
  69. package/dist/inference/index.d.ts +5 -1
  70. package/dist/inference/index.js +2 -1
  71. package/dist/inference/llama-engine.d.ts +27 -0
  72. package/dist/inference/llama-engine.js +152 -0
  73. package/dist/inference/llama-engine.test.d.ts +1 -0
  74. package/dist/inference/llama-engine.test.js +51 -0
  75. package/dist/inference/llama-process.d.ts +72 -0
  76. package/dist/inference/llama-process.js +126 -0
  77. package/dist/inference/llama-process.test.d.ts +1 -0
  78. package/dist/inference/llama-process.test.js +61 -0
  79. package/dist/inference/model-download.test.d.ts +1 -0
  80. package/dist/inference/model-download.test.js +73 -0
  81. package/dist/inference/model-manager.d.ts +14 -1
  82. package/dist/inference/model-manager.js +65 -80
  83. package/dist/inference/sidecar-provider.d.ts +19 -10
  84. package/dist/inference/sidecar-provider.js +115 -343
  85. package/dist/templates/universal-config.js +0 -2
  86. package/dist/types/index.d.ts +58 -14
  87. package/dist/types/index.js +9 -2
  88. package/dist/utils/deep-status.d.ts +7 -0
  89. package/dist/utils/deep-status.js +11 -0
  90. package/dist/utils/diff.d.ts +3 -0
  91. package/dist/utils/diff.js +52 -0
  92. package/dist/utils/diff.test.d.ts +1 -0
  93. package/dist/utils/diff.test.js +38 -0
  94. package/dist/utils/scanner.js +13 -2
  95. package/dist/utils/scanner.test.js +22 -8
  96. package/dist/utils/scope.d.ts +15 -0
  97. package/dist/utils/scope.js +51 -0
  98. package/dist/utils/scope.test.d.ts +1 -0
  99. package/dist/utils/scope.test.js +38 -0
  100. package/package.json +6 -6
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Source context for code-aware review of scoped files.
3
+ *
4
+ * The facts-only prompt gives the model signatures and counts; defects such
5
+ * as an unbounded read, a missing fetch option or a wrong response header
6
+ * live in function bodies. For a scoped run each file is sent with its
7
+ * numbered source. Files over the budget are cut into function/class
8
+ * segments (the ones around changed lines first when a diff is known), and
9
+ * every segment that does not fit is listed as omitted rather than dropped.
10
+ */
11
+ import type { FileFacts } from './fact-extractor.js';
12
+ export type LineRange = [start: number, end: number];
13
+ export interface CodeContext {
14
+ file: string;
15
+ language: string;
16
+ /** Prompt block: header plus numbered source. */
17
+ text: string;
18
+ /** 1-based inclusive line ranges whose source was sent. */
19
+ ranges: LineRange[];
20
+ /** Raw source that was sent, for identifier verification. */
21
+ source: string;
22
+ }
23
+ export interface CodeContextOptions {
24
+ maxChars: number;
25
+ /** Changed line numbers (from a diff); their enclosing segments go first. */
26
+ focusLines?: number[];
27
+ }
28
+ export declare function buildCodeContext(facts: FileFacts, content: string, options: CodeContextOptions): CodeContext;
@@ -0,0 +1,93 @@
1
+ export function buildCodeContext(facts, content, options) {
2
+ const lines = content.split('\n');
3
+ const header = `FILE: ${facts.path} (${facts.language}, ${lines.length} lines)`;
4
+ const whole = [1, lines.length];
5
+ const wholeText = renderRange(lines, whole);
6
+ if (header.length + wholeText.length <= options.maxChars) {
7
+ return { file: facts.path, language: facts.language, text: `${header}\n${wholeText}`, ranges: [whole], source: content };
8
+ }
9
+ const segments = orderSegments(segmentsFor(facts, lines.length), options.focusLines);
10
+ const parts = [header];
11
+ const ranges = [];
12
+ const omitted = [];
13
+ let used = header.length;
14
+ for (const segment of segments) {
15
+ const text = renderRange(lines, segment.range);
16
+ const remaining = options.maxChars - used;
17
+ if (text.length <= remaining) {
18
+ parts.push(text);
19
+ ranges.push(segment.range);
20
+ used += text.length + 1;
21
+ }
22
+ else if (ranges.length === 0 && remaining > 200) {
23
+ const truncated = truncateRange(lines, segment.range, remaining);
24
+ parts.push(truncated.text);
25
+ ranges.push(truncated.range);
26
+ used += truncated.text.length + 1;
27
+ }
28
+ else {
29
+ omitted.push(`${segment.label} (lines ${segment.range[0]}-${segment.range[1]})`);
30
+ }
31
+ }
32
+ if (omitted.length > 0)
33
+ parts.push(`… omitted for size: ${omitted.join(', ')}`);
34
+ return {
35
+ file: facts.path,
36
+ language: facts.language,
37
+ text: parts.join('\n'),
38
+ ranges,
39
+ source: ranges.map(([s, e]) => lines.slice(s - 1, e).join('\n')).join('\n'),
40
+ };
41
+ }
42
+ function segmentsFor(facts, lineCount) {
43
+ const segments = [
44
+ ...facts.classes.map(c => ({ label: `class ${c.name}`, range: [c.lineStart, c.lineEnd] })),
45
+ ...facts.functions.map(f => ({ label: `function ${f.name}`, range: [f.lineStart, f.lineEnd] })),
46
+ ]
47
+ .map(s => ({ ...s, range: clampRange(s.range, lineCount) }))
48
+ .sort((a, b) => a.range[0] - b.range[0]);
49
+ // Drop segments nested inside an earlier one (methods inside a class).
50
+ const outer = [];
51
+ for (const segment of segments) {
52
+ const last = outer[outer.length - 1];
53
+ if (last && segment.range[1] <= last.range[1])
54
+ continue;
55
+ outer.push(segment);
56
+ }
57
+ return outer.length > 0 ? outer : [{ label: 'file', range: [1, lineCount] }];
58
+ }
59
+ function orderSegments(segments, focusLines) {
60
+ if (!focusLines || focusLines.length === 0)
61
+ return segments;
62
+ const touches = (s) => focusLines.some(l => l >= s.range[0] && l <= s.range[1]);
63
+ return [...segments.filter(touches), ...segments.filter(s => !touches(s))];
64
+ }
65
+ function clampRange([start, end], lineCount) {
66
+ const s = Math.max(1, Math.min(start, lineCount));
67
+ return [s, Math.max(s, Math.min(end, lineCount))];
68
+ }
69
+ function renderRange(lines, [start, end]) {
70
+ const width = String(end).length;
71
+ return lines
72
+ .slice(start - 1, end)
73
+ .map((line, i) => `${String(start + i).padStart(width)}| ${line}`)
74
+ .join('\n');
75
+ }
76
+ function truncateRange(lines, [start, end], maxChars) {
77
+ const width = String(end).length;
78
+ const budget = maxChars - 40;
79
+ const kept = [];
80
+ let used = 0;
81
+ let last = start - 1;
82
+ while (last < end) {
83
+ const rendered = `${String(last + 1).padStart(width)}| ${lines[last]}`;
84
+ if (kept.length > 0 && used + rendered.length + 1 > budget)
85
+ break;
86
+ kept.push(rendered);
87
+ used += rendered.length + 1;
88
+ last++;
89
+ }
90
+ const omitted = end - last;
91
+ const text = kept.join('\n') + (omitted > 0 ? `\n… [${omitted} lines omitted]` : '');
92
+ return { text, range: [start, last] };
93
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildCodeContext } from './code-context.js';
3
+ function fn(name, lineStart, lineEnd) {
4
+ return {
5
+ name, lineStart, lineEnd, lineCount: lineEnd - lineStart + 1, paramCount: 0, params: [],
6
+ maxNesting: 1, hasReturn: true, isAsync: false, isExported: true,
7
+ };
8
+ }
9
+ function facts(path, functions) {
10
+ return {
11
+ path, language: 'typescript', lineCount: 0, classes: [], functions, imports: [], exports: [],
12
+ errorHandling: [], testAssertions: 0, hasTests: false,
13
+ };
14
+ }
15
+ const source = (n) => Array.from({ length: n }, (_, i) => `const line${i + 1} = ${i + 1};`).join('\n');
16
+ describe('buildCodeContext', () => {
17
+ it('sends a small file whole, with line numbers', () => {
18
+ const ctx = buildCodeContext(facts('src/a.ts', []), 'const a = 1;\nexport { a };', { maxChars: 10_000 });
19
+ expect(ctx.text).toBe('FILE: src/a.ts (typescript, 2 lines)\n1| const a = 1;\n2| export { a };');
20
+ expect(ctx.ranges).toEqual([[1, 2]]);
21
+ expect(ctx.source).toContain('export { a }');
22
+ });
23
+ it('cuts a large file into function segments and names what it omitted', () => {
24
+ const content = source(300);
25
+ const ctx = buildCodeContext(facts('src/big.ts', [fn('first', 1, 100), fn('second', 101, 200), fn('third', 201, 300)]), content, { maxChars: 4_000 });
26
+ expect(ctx.ranges[0]).toEqual([1, 100]);
27
+ expect(ctx.text).toMatch(/omitted for size: .*function second \(lines 101-200\)/);
28
+ expect(ctx.text).toContain('function third (lines 201-300)');
29
+ expect(ctx.source).not.toContain('line150 ');
30
+ });
31
+ it('puts the segment around changed lines first', () => {
32
+ const content = source(300);
33
+ const ctx = buildCodeContext(facts('src/big.ts', [fn('first', 1, 100), fn('second', 101, 200), fn('third', 201, 300)]), content, {
34
+ maxChars: 4_000, focusLines: [250],
35
+ });
36
+ expect(ctx.ranges[0]).toEqual([201, 300]);
37
+ expect(ctx.text).toContain('function first (lines 1-100)');
38
+ });
39
+ it('truncates a single oversized function explicitly instead of dropping it', () => {
40
+ const content = source(2_000);
41
+ const ctx = buildCodeContext(facts('src/huge.ts', [fn('huge', 1, 2_000)]), content, { maxChars: 2_000 });
42
+ expect(ctx.ranges).toHaveLength(1);
43
+ expect(ctx.ranges[0][0]).toBe(1);
44
+ expect(ctx.ranges[0][1]).toBeLessThan(2_000);
45
+ expect(ctx.text).toMatch(/… \[\d+ lines omitted\]$/);
46
+ expect(ctx.text.length).toBeLessThanOrEqual(2_000);
47
+ });
48
+ });
@@ -0,0 +1,15 @@
1
+ import type { InferenceProvider } from '../inference/types.js';
2
+ import type { FileFacts } from './fact-extractor.js';
3
+ import { type CodeContext } from './code-context.js';
4
+ import type { PassConfig, PassResult } from './facts-pass.js';
5
+ export interface CodePassConfig extends PassConfig {
6
+ cwd: string;
7
+ /** Character budget for one file's source in the prompt. */
8
+ maxSourceChars: number;
9
+ /** Changed lines per file (cwd-relative path → line numbers). */
10
+ focusLines?: Record<string, number[]>;
11
+ }
12
+ export interface CodePassResult extends PassResult {
13
+ contexts: CodeContext[];
14
+ }
15
+ export declare function runCodePass(provider: InferenceProvider, facts: FileFacts[], config: CodePassConfig): Promise<CodePassResult>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Code pass: per-file review of scoped files with their numbered source.
3
+ * Used for `rigour check <paths> --deep` and `rigour review --deep`.
4
+ */
5
+ import path from 'path';
6
+ import fs from 'fs-extra';
7
+ import { buildCodeContext } from './code-context.js';
8
+ import { buildCodeReviewPrompt } from './code-review-prompt.js';
9
+ import { parseFindings } from './parse-findings.js';
10
+ import { Logger } from '../utils/logger.js';
11
+ export async function runCodePass(provider, facts, config) {
12
+ const result = { findings: [], chunksTotal: facts.length, chunksFailed: 0, contexts: [] };
13
+ for (let i = 0; i < facts.length; i++) {
14
+ const file = facts[i];
15
+ config.onProgress?.(` Reviewing ${file.path} (${i + 1}/${facts.length})...`);
16
+ let context;
17
+ try {
18
+ const content = await fs.readFile(path.join(config.cwd, file.path), 'utf-8');
19
+ context = buildCodeContext(file, content, {
20
+ maxChars: config.maxSourceChars,
21
+ focusLines: config.focusLines?.[file.path],
22
+ });
23
+ }
24
+ catch (error) {
25
+ recordFailure(result, `${file.path}: ${error.message}`, config);
26
+ continue;
27
+ }
28
+ result.contexts.push(context);
29
+ try {
30
+ const response = await provider.analyze(buildCodeReviewPrompt(context), config.inference);
31
+ result.findings.push(...parseFindings(response));
32
+ }
33
+ catch (error) {
34
+ recordFailure(result, `${file.path}: ${error.message}`, config);
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+ function recordFailure(result, message, config) {
40
+ result.chunksFailed++;
41
+ result.firstError ??= message;
42
+ Logger.warn(`Code review failed for ${message}`);
43
+ config.onProgress?.(` ⚠ ${message}`);
44
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Prompt for code-aware review of scoped files.
3
+ *
4
+ * Unlike the facts prompt, the model sees numbered source and is asked for
5
+ * defects it can point to at a line. Categories are a general review
6
+ * taxonomy; they must not be tuned to any specific benchmark bug.
7
+ */
8
+ import type { CodeContext } from './code-context.js';
9
+ /**
10
+ * Review categories: `key` is the category the model reports, `focus` is
11
+ * the one-line instruction for what to look for.
12
+ */
13
+ export declare const REVIEW_CATEGORIES: ReadonlyArray<{
14
+ key: string;
15
+ focus: string;
16
+ }>;
17
+ export declare function buildCodeReviewPrompt(context: CodeContext): string;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Review categories: `key` is the category the model reports, `focus` is
3
+ * the one-line instruction for what to look for.
4
+ */
5
+ export const REVIEW_CATEGORIES = [
6
+ { key: 'correctness', focus: 'logic that returns or does the wrong thing: wrong conditions, off-by-one, wrong variable, unhandled case' },
7
+ { key: 'scalability', focus: 'work, memory or request count that grows with data size without a bound' },
8
+ { key: 'security', focus: 'untrusted input reaching sensitive operations, secrets exposed or sent where they should not go, missing authorization' },
9
+ { key: 'error_handling', focus: 'failures that are swallowed, crash more than they should, or leave state inconsistent' },
10
+ { key: 'state_and_caching', focus: 'stale, shared or cached state that can serve the wrong result' },
11
+ { key: 'api_misuse', focus: 'a library, framework or protocol used against its documented contract' },
12
+ { key: 'consistency', focus: 'code, messages or output that contradict what the data or other code actually does' },
13
+ ];
14
+ const REVIEW_RULES = `RULES:
15
+ 1. Report only defects you can point to in the source shown, with its line number.
16
+ 2. A defect is behaviour that is wrong, unsafe or will fail in production. Do not report style, naming, formatting or missing comments.
17
+ 3. Put identifiers from the code in backticks in the description, spelled exactly as in the source.
18
+ 4. If you find nothing concrete, return {"findings": []}. An empty list is a good answer.
19
+ 5. confidence: 0.9+ only when the defect is certain from the code shown; 0.5-0.7 when it depends on how callers use it.
20
+ 6. Respond ONLY with JSON: {"findings":[{"category","severity","file","line","description","suggestion","confidence"}]}.`;
21
+ export function buildCodeReviewPrompt(context) {
22
+ const categories = REVIEW_CATEGORIES.map(c => `- ${c.key}: ${c.focus}`).join('\n');
23
+ return [
24
+ 'You are a senior engineer reviewing a change before it ships. Read the source and report real defects.',
25
+ `CATEGORIES:\n${categories}`,
26
+ REVIEW_RULES,
27
+ `SOURCE (line numbers on the left):\n${context.text}`,
28
+ `Review ${context.file}. Return findings as JSON.`,
29
+ ].join('\n\n');
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildCodeReviewPrompt, REVIEW_CATEGORIES } from './code-review-prompt.js';
3
+ const context = { file: 'src/a.ts', language: 'typescript', text: 'FILE: src/a.ts\n1| export const a = 1;', ranges: [[1, 1]], source: '' };
4
+ describe('buildCodeReviewPrompt', () => {
5
+ it('includes every category, the rules and the numbered source', () => {
6
+ const prompt = buildCodeReviewPrompt(context);
7
+ for (const c of REVIEW_CATEGORIES)
8
+ expect(prompt).toContain(`- ${c.key}: ${c.focus}`);
9
+ expect(prompt).toContain('Report only defects you can point to');
10
+ expect(prompt).toContain('1| export const a = 1;');
11
+ expect(prompt).toContain('Review src/a.ts');
12
+ });
13
+ it('allows an empty answer so the model is not pushed to invent findings', () => {
14
+ expect(buildCodeReviewPrompt(context)).toContain('{"findings": []}');
15
+ });
16
+ it('uses unique category keys', () => {
17
+ const keys = REVIEW_CATEGORIES.map(c => c.key);
18
+ expect(new Set(keys).size).toBe(keys.length);
19
+ });
20
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Verification for code-aware findings.
3
+ *
4
+ * A finding survives only if it is about code the model was actually shown:
5
+ * the file was sent, it cites a line inside a sent range, and every
6
+ * backticked identifier in the description appears in the sent source.
7
+ * This keeps the "AST grounds the LLM" guarantee when the prompt carries
8
+ * source instead of facts.
9
+ *
10
+ * A line is required. In a backtest of 120 local runs every line-less
11
+ * finding was generic ("does not handle null input") or not a defect at
12
+ * all ("the code is well-structured"), and nothing can be verified about it.
13
+ */
14
+ import type { DeepFinding } from '../inference/types.js';
15
+ import type { CodeContext } from './code-context.js';
16
+ import type { VerifiedFinding } from './verifier.js';
17
+ export declare function verifyCodeFindings(findings: DeepFinding[], contexts: CodeContext[]): VerifiedFinding[];
18
+ /** Backticked, identifier-like tokens (skips quoted prose and code fragments). */
19
+ export declare function quotedIdentifiers(text: string): string[];
@@ -0,0 +1,48 @@
1
+ const MIN_CONFIDENCE = 0.3;
2
+ export function verifyCodeFindings(findings, contexts) {
3
+ const verified = [];
4
+ for (const finding of findings) {
5
+ const context = findContext(finding.file, contexts);
6
+ if (!context)
7
+ continue;
8
+ const note = rejectionReason(finding, context);
9
+ if (note)
10
+ continue;
11
+ verified.push({ ...finding, file: context.file, verified: true, verificationNotes: 'grounded in sent source' });
12
+ }
13
+ return verified;
14
+ }
15
+ function rejectionReason(finding, context) {
16
+ if (finding.confidence < MIN_CONFIDENCE)
17
+ return 'low confidence';
18
+ const line = typeof finding.line === 'number' && finding.line > 0 ? finding.line : null;
19
+ if (line === null)
20
+ return 'no line';
21
+ 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`;
26
+ return null;
27
+ }
28
+ /** Backticked, identifier-like tokens (skips quoted prose and code fragments). */
29
+ export function quotedIdentifiers(text) {
30
+ const ids = [];
31
+ for (const match of text.matchAll(/`([^`\n]+)`/g)) {
32
+ const token = match[1].trim();
33
+ if (/^[A-Za-z_$][\w$.]*(\(\))?$/.test(token))
34
+ ids.push(token.replace(/\(\)$/, ''));
35
+ }
36
+ return ids;
37
+ }
38
+ function findContext(file, contexts) {
39
+ const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
40
+ const exact = contexts.find(c => c.file === normalized);
41
+ if (exact)
42
+ return exact;
43
+ const bySuffix = contexts.filter(c => c.file.endsWith('/' + normalized) || normalized.endsWith('/' + c.file));
44
+ if (bySuffix.length === 1)
45
+ return bySuffix[0];
46
+ // A single-file review may be reported by basename only.
47
+ return contexts.length === 1 && contexts[0].file.split('/').pop() === normalized.split('/').pop() ? contexts[0] : null;
48
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { quotedIdentifiers, verifyCodeFindings } from './code-verifier.js';
3
+ const context = {
4
+ file: 'src/api/report.ts',
5
+ language: 'typescript',
6
+ text: '',
7
+ ranges: [[10, 40]],
8
+ source: 'export async function loadReport(days) {\n const rows = await fetchAll(days);\n}',
9
+ };
10
+ function finding(overrides) {
11
+ return {
12
+ category: 'scalability', severity: 'high', file: 'src/api/report.ts', line: 12,
13
+ description: '`fetchAll` loads every row for the window into memory.', suggestion: 'Aggregate in the database.',
14
+ confidence: 0.8, ...overrides,
15
+ };
16
+ }
17
+ describe('verifyCodeFindings', () => {
18
+ it('keeps a finding grounded in the sent source', () => {
19
+ const [kept] = verifyCodeFindings([finding({})], [context]);
20
+ expect(kept).toMatchObject({ file: 'src/api/report.ts', verified: true });
21
+ });
22
+ it('resolves a basename-only file for a single-file review', () => {
23
+ expect(verifyCodeFindings([finding({ file: 'report.ts' })], [context])).toHaveLength(1);
24
+ });
25
+ it('drops findings for files that were not sent', () => {
26
+ expect(verifyCodeFindings([finding({ file: 'src/other.ts' })], [context])).toHaveLength(0);
27
+ });
28
+ it('drops findings whose line is outside the sent ranges', () => {
29
+ expect(verifyCodeFindings([finding({ line: 80 })], [context])).toHaveLength(0);
30
+ });
31
+ it('drops findings that cite an identifier the model was never shown', () => {
32
+ expect(verifyCodeFindings([finding({ description: '`fetchEverything` has no limit.' })], [context])).toHaveLength(0);
33
+ });
34
+ it('drops findings that cite no line, however confident', () => {
35
+ expect(verifyCodeFindings([finding({ line: undefined, confidence: 0.9 })], [context])).toHaveLength(0);
36
+ expect(verifyCodeFindings([finding({ line: null, description: 'The code is well-structured.' })], [context])).toHaveLength(0);
37
+ });
38
+ it('drops very low confidence findings', () => {
39
+ expect(verifyCodeFindings([finding({ confidence: 0.2 })], [context])).toHaveLength(0);
40
+ });
41
+ });
42
+ describe('quotedIdentifiers', () => {
43
+ it('extracts identifier-like backticked tokens only', () => {
44
+ expect(quotedIdentifiers('`fetchAll()` and `rows.length` but not `a + b` or `"quoted"`')).toEqual(['fetchAll', 'rows.length']);
45
+ });
46
+ });
@@ -72,7 +72,13 @@ export interface ErrorHandlingFact {
72
72
  * Works across languages without tree-sitter grammar loading.
73
73
  * Fast enough for the deep analysis pipeline.
74
74
  */
75
- export declare function extractFacts(cwd: string, ignore?: string[]): Promise<FileFacts[]>;
75
+ export declare function extractFacts(cwd: string, ignore?: string[], scope?: string[]): Promise<FileFacts[]>;
76
+ /**
77
+ * Test file by path convention, or by a test-runner call at the start of a
78
+ * line. A bare substring check misreads `exit(`, `submit(` or `regex.test(`
79
+ * as tests, and `latest/` as a test directory.
80
+ */
81
+ export declare function isTestFile(filePath: string, content: string): boolean;
76
82
  /**
77
83
  * Serialize facts into a compact string for LLM prompts.
78
84
  * Keeps only the most relevant information within token budget.
@@ -6,20 +6,22 @@
6
6
  import fs from 'fs-extra';
7
7
  import path from 'path';
8
8
  import { globby } from 'globby';
9
+ const ANALYZABLE_EXTENSION = /\.(ts|js|tsx|jsx|py|go|rs|cs|java|rb|kt)$/i;
9
10
  /**
10
11
  * Lightweight regex-based fact extraction.
11
12
  * Works across languages without tree-sitter grammar loading.
12
13
  * Fast enough for the deep analysis pipeline.
13
14
  */
14
- export async function extractFacts(cwd, ignore) {
15
- const patterns = ['**/*.{ts,js,tsx,jsx,py,go,rs,cs,java,rb,kt}'];
15
+ export async function extractFacts(cwd, ignore, scope) {
16
+ const patterns = scope && scope.length > 0 ? scope : ['**/*.{ts,js,tsx,jsx,py,go,rs,cs,java,rb,kt}'];
16
17
  const ignorePatterns = [
17
18
  ...(ignore || []),
18
19
  '**/node_modules/**', '**/dist/**', '**/build/**',
19
20
  '**/.git/**', '**/vendor/**', '**/__pycache__/**',
20
21
  '**/*.min.js', '**/*.bundle.js',
21
22
  ];
22
- const files = await globby(patterns, { cwd, ignore: ignorePatterns, followSymbolicLinks: false });
23
+ const matched = await globby(patterns, { cwd, ignore: ignorePatterns, followSymbolicLinks: false });
24
+ const files = matched.filter(f => ANALYZABLE_EXTENSION.test(f));
23
25
  const allFacts = [];
24
26
  for (const file of files) {
25
27
  try {
@@ -364,14 +366,20 @@ function countAssertions(content) {
364
366
  }
365
367
  return count;
366
368
  }
367
- function isTestFile(filePath, content) {
368
- if (filePath.match(/\.(test|spec|_test)\./))
369
+ /**
370
+ * Test file by path convention, or by a test-runner call at the start of a
371
+ * line. A bare substring check misreads `exit(`, `submit(` or `regex.test(`
372
+ * as tests, and `latest/` as a test directory.
373
+ */
374
+ export function isTestFile(filePath, content) {
375
+ const posixPath = filePath.replace(/\\/g, '/');
376
+ if (/\.(test|spec)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/.test(posixPath))
369
377
  return true;
370
- if (filePath.includes('__tests__') || filePath.includes('test/') || filePath.includes('tests/'))
378
+ if (/(^|\/)(__tests__|tests?)\//.test(posixPath))
371
379
  return true;
372
- if (content.includes('describe(') || content.includes('it(') || content.includes('test('))
380
+ if (/^\s*(describe|it|test)(\.\w+)?\s*\(/m.test(content))
373
381
  return true;
374
- if (content.includes('def test_') || content.includes('@pytest'))
382
+ if (/^\s*def test_\w+\s*\(/m.test(content) || content.includes('@pytest'))
375
383
  return true;
376
384
  return false;
377
385
  }
@@ -11,7 +11,7 @@ vi.mock('fs-extra', () => ({
11
11
  readFile: mockReadFile,
12
12
  },
13
13
  }));
14
- import { extractFacts, factsToPromptString } from './fact-extractor.js';
14
+ import { extractFacts, factsToPromptString, isTestFile } from './fact-extractor.js';
15
15
  describe('Fact Extractor', () => {
16
16
  beforeEach(() => {
17
17
  vi.clearAllMocks();
@@ -545,3 +545,36 @@ function bar() {
545
545
  });
546
546
  });
547
547
  });
548
+ describe('extractFacts scope', () => {
549
+ beforeEach(() => { vi.clearAllMocks(); });
550
+ it('globs only the scoped patterns and skips non-code files they match', async () => {
551
+ mockGlobby.mockResolvedValue(['src/a.ts', 'src/README.md', 'src/data.json']);
552
+ mockReadFile.mockResolvedValue('export const a = 1;\nexport const b = 2;\nexport const c = 3;\n');
553
+ const facts = await extractFacts('/project', [], ['src/**/*']);
554
+ expect(mockGlobby.mock.calls[0][0]).toEqual(['src/**/*']);
555
+ expect(facts.map(f => f.path)).toEqual(['src/a.ts']);
556
+ });
557
+ it('uses the whole-repo pattern when unscoped', async () => {
558
+ mockGlobby.mockResolvedValue([]);
559
+ await extractFacts('/project');
560
+ expect(mockGlobby.mock.calls[0][0]).toEqual(['**/*.{ts,js,tsx,jsx,py,go,rs,cs,java,rb,kt}']);
561
+ });
562
+ });
563
+ describe('isTestFile', () => {
564
+ it('recognises test files by path convention', () => {
565
+ for (const p of ['src/a.test.ts', 'src/a.spec.tsx', '__tests__/a.ts', 'tests/a.py', 'test/a.js', 'pkg/a_test.go', 'test_a.py']) {
566
+ expect(isTestFile(p, ''), p).toBe(true);
567
+ }
568
+ });
569
+ it('recognises test-runner calls at the start of a line', () => {
570
+ expect(isTestFile('src/a.ts', "describe('x', () => {});")).toBe(true);
571
+ expect(isTestFile('src/a.ts', " it.each([1])('x', () => {});")).toBe(true);
572
+ expect(isTestFile('src/a.py', 'def test_x():\n pass')).toBe(true);
573
+ });
574
+ it('does not mistake ordinary code or paths for tests', () => {
575
+ expect(isTestFile('src/route.ts', 'process.exit(1);\nform.submit();\nawait wait(5);')).toBe(false);
576
+ expect(isTestFile('src/check.ts', 'if (/^a/.test(value)) return;')).toBe(false);
577
+ expect(isTestFile('src/latest/report.ts', 'export {}')).toBe(false);
578
+ expect(isTestFile('src/contest/entry.ts', 'export {}')).toBe(false);
579
+ });
580
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Facts pass: repo-wide analysis from AST facts (no source sent).
3
+ * Used for unscoped `rigour check --deep`.
4
+ */
5
+ import type { InferenceOptions, InferenceProvider, DeepFinding } from '../inference/types.js';
6
+ import type { FileFacts } from './fact-extractor.js';
7
+ export interface PassResult {
8
+ findings: DeepFinding[];
9
+ chunksTotal: number;
10
+ chunksFailed: number;
11
+ /** Message of the first failed chunk, for reporting. */
12
+ firstError?: string;
13
+ }
14
+ export interface PassConfig {
15
+ inference: InferenceOptions;
16
+ checks?: Record<string, boolean>;
17
+ onProgress?: (message: string) => void;
18
+ /** Cloud only: fan chunks out over N providers. */
19
+ agents?: {
20
+ count: number;
21
+ create: () => Promise<InferenceProvider>;
22
+ };
23
+ }
24
+ export declare function runFactsPass(provider: InferenceProvider, facts: FileFacts[], config: PassConfig): Promise<PassResult>;
@@ -0,0 +1,69 @@
1
+ import { factsToPromptString } from './fact-extractor.js';
2
+ import { buildAnalysisPrompt, buildCrossFilePrompt, chunkFacts } from './prompts.js';
3
+ import { parseFindings } from './parse-findings.js';
4
+ import { Logger } from '../utils/logger.js';
5
+ export async function runFactsPass(provider, facts, config) {
6
+ const chunks = chunkFacts(facts);
7
+ const result = config.agents && config.agents.count > 1
8
+ ? await analyzeWithAgents(chunks, config)
9
+ : await analyzeSequentially(provider, chunks, config);
10
+ if (facts.length >= 3 && result.chunksFailed < result.chunksTotal) {
11
+ config.onProgress?.(' Running cross-file analysis...');
12
+ try {
13
+ const response = await provider.analyze(buildCrossFilePrompt(facts), config.inference);
14
+ result.findings.push(...parseFindings(response));
15
+ }
16
+ catch (error) {
17
+ Logger.warn(`Cross-file analysis failed: ${error.message}`);
18
+ }
19
+ }
20
+ return result;
21
+ }
22
+ async function analyzeSequentially(provider, chunks, config) {
23
+ const result = { findings: [], chunksTotal: chunks.length, chunksFailed: 0 };
24
+ for (let i = 0; i < chunks.length; i++) {
25
+ config.onProgress?.(` Analyzing batch ${i + 1}/${chunks.length}...`);
26
+ await analyzeChunk(provider, chunks[i], config, result, `Batch ${i + 1}`);
27
+ }
28
+ return result;
29
+ }
30
+ async function analyzeWithAgents(chunks, config) {
31
+ const { count, create } = config.agents;
32
+ config.onProgress?.(` Spawning ${count} parallel agents...`);
33
+ const buckets = Array.from({ length: count }, () => []);
34
+ chunks.forEach((chunk, i) => buckets[i % count].push(chunk));
35
+ const result = { findings: [], chunksTotal: chunks.length, chunksFailed: 0 };
36
+ await Promise.all(buckets.filter(b => b.length > 0).map(async (bucket, agent) => {
37
+ let provider;
38
+ try {
39
+ provider = await create();
40
+ }
41
+ catch (error) {
42
+ result.chunksFailed += bucket.length;
43
+ result.firstError ??= error.message;
44
+ return;
45
+ }
46
+ try {
47
+ for (let i = 0; i < bucket.length; i++) {
48
+ await analyzeChunk(provider, bucket[i], config, result, `Agent ${agent + 1} batch ${i + 1}`);
49
+ }
50
+ }
51
+ finally {
52
+ provider.dispose();
53
+ }
54
+ }));
55
+ config.onProgress?.(` All ${count} agents completed.`);
56
+ return result;
57
+ }
58
+ async function analyzeChunk(provider, chunk, config, result, label) {
59
+ const prompt = buildAnalysisPrompt(factsToPromptString(chunk, Number.POSITIVE_INFINITY), config.checks);
60
+ try {
61
+ result.findings.push(...parseFindings(await provider.analyze(prompt, config.inference)));
62
+ }
63
+ catch (error) {
64
+ result.chunksFailed++;
65
+ result.firstError ??= error.message;
66
+ Logger.warn(`${label} inference failed: ${error.message}`);
67
+ config.onProgress?.(` ⚠ ${label} failed: ${error.message}`);
68
+ }
69
+ }
@@ -7,8 +7,13 @@
7
7
  *
8
8
  * Neither AST nor LLM works alone. Together they're accurate.
9
9
  */
10
- export { extractFacts, factsToPromptString } from './fact-extractor.js';
10
+ export { extractFacts, factsToPromptString, isTestFile } from './fact-extractor.js';
11
11
  export type { FileFacts, ClassFact, FunctionFact, ErrorHandlingFact, StructFact, InterfaceFact } from './fact-extractor.js';
12
12
  export { buildAnalysisPrompt, buildCrossFilePrompt, chunkFacts, DEEP_SYSTEM_PROMPT } from './prompts.js';
13
13
  export { verifyFindings } from './verifier.js';
14
14
  export type { VerifiedFinding } from './verifier.js';
15
+ export { parseFindings } from './parse-findings.js';
16
+ export { buildCodeContext } from './code-context.js';
17
+ export type { CodeContext, LineRange } from './code-context.js';
18
+ export { buildCodeReviewPrompt, REVIEW_CATEGORIES } from './code-review-prompt.js';
19
+ export { verifyCodeFindings } from './code-verifier.js';