@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
@@ -1,28 +1,40 @@
1
1
  /**
2
- * Deep Analysis Gate — LLM-powered code quality analysis.
2
+ * Deep Analysis Gate — LLM-powered code analysis.
3
3
  *
4
- * Three-step pipeline:
5
- * 1. AST extracts facts → "UserService has 8 public methods touching 4 domains"
6
- * 2. LLM interprets facts → "UserService violates Single Responsibility"
7
- * 3. AST verifies LLM → Does UserService actually have those methods? ✓
4
+ * Two modes:
5
+ * - Unscoped (`rigour check --deep`): repo-wide analysis from AST facts.
6
+ * AST extracts facts, the LLM interprets them, AST verifies the findings.
7
+ * - Scoped (`rigour check <paths> --deep`, `rigour review --deep`): each
8
+ * scoped file is reviewed with its numbered source; findings must cite a
9
+ * line and identifiers that were actually sent.
8
10
  *
9
- * AST grounds LLM. LLM interprets AST. Neither works alone.
11
+ * The gate records an outcome. A run where setup failed or every chunk
12
+ * failed is an error, never a clean pass.
10
13
  */
11
14
  import { Gate } from './base.js';
12
15
  import { createProvider } from '../inference/index.js';
13
- import { extractFacts, factsToPromptString, chunkFacts, buildAnalysisPrompt, buildCrossFilePrompt, verifyFindings } from '../deep/index.js';
16
+ import { SidecarProvider } from '../inference/sidecar-provider.js';
17
+ import { extractFacts, verifyFindings } from '../deep/index.js';
18
+ import { runFactsPass } from '../deep/facts-pass.js';
19
+ import { runCodePass } from '../deep/code-pass.js';
20
+ import { verifyCodeFindings } from '../deep/code-verifier.js';
14
21
  import { checkLocalPatterns } from '../storage/local-memory.js';
22
+ import { isScoped } from '../utils/scope.js';
15
23
  import { Logger } from '../utils/logger.js';
16
24
  import path from 'path';
17
- /** Default batch size for streaming analysis of large repos.
18
- * Files are processed in batches to avoid OOM on huge repos.
19
- * Optional soft limit via rigour.yml: deep.maxFiles */
20
- const DEFAULT_BATCH_SIZE = 200;
21
- /** Setup timeout: 120s for model download, 30s for API connection */
22
- const SETUP_TIMEOUT_MS = 120_000;
25
+ /** Cloud setup (API connection) must not hang a check. Local setup is bounded by download stall timeouts instead. */
26
+ const CLOUD_SETUP_TIMEOUT_MS = 120_000;
27
+ /**
28
+ * Source budget per file in the code-review prompt. Local: ~36k chars is
29
+ * ~12k tokens; measured on Apple Silicon at ~19s per file for the 1.5B model
30
+ * (prompt eval ~670 tok/s), inside the 60s per-call timeout.
31
+ */
32
+ const LOCAL_SOURCE_CHARS = 36_000;
33
+ const CLOUD_SOURCE_CHARS = 60_000;
23
34
  export class DeepAnalysisGate extends Gate {
24
35
  config;
25
36
  provider = null;
37
+ outcome = emptyOutcome();
26
38
  constructor(config) {
27
39
  super('deep-analysis', 'Deep Code Quality Analysis');
28
40
  this.config = config;
@@ -30,321 +42,168 @@ export class DeepAnalysisGate extends Gate {
30
42
  get provenance() {
31
43
  return 'deep-analysis';
32
44
  }
33
- /** Check if a file is a likely entry point (higher analysis priority) */
34
- isEntryPoint(filePath) {
35
- const basename = path.basename(filePath).toLowerCase();
36
- const entryNames = [
37
- 'index.ts', 'index.js', 'index.tsx', 'index.jsx', 'index.mjs',
38
- 'main.ts', 'main.js', 'main.py', 'main.go', 'main.rs', 'main.java', 'main.kt',
39
- 'app.ts', 'app.js', 'app.py', 'app.go', 'app.rb',
40
- 'server.ts', 'server.js', 'server.py', 'server.go',
41
- 'mod.rs', 'lib.rs',
42
- ];
43
- return entryNames.includes(basename);
45
+ /** Outcome of the last run(). */
46
+ getOutcome() {
47
+ return this.outcome;
44
48
  }
45
49
  async run(context) {
46
- const { onProgress } = this.config;
47
- const failures = [];
48
50
  const startTime = Date.now();
51
+ const scoped = isScoped(context.patterns);
52
+ this.outcome = { ...emptyOutcome(), mode: scoped ? 'code' : 'facts' };
49
53
  try {
50
- // Step 0: Initialize inference provider (with timeout)
51
- onProgress?.('\n Setting up Rigour Brain...\n');
52
- this.provider = createProvider(this.config.options);
53
- // Pre-check availability to fail fast instead of hanging on install
54
- const isLocalProvider = !this.config.options.apiKey || this.config.options.provider === 'local';
55
- if (isLocalProvider) {
56
- const available = await this.provider.isAvailable();
57
- if (!available) {
58
- onProgress?.(' ⚠ Local inference binary not found. Attempting auto-install...');
59
- onProgress?.(' (This may take a moment on first run)');
60
- }
61
- }
62
- await Promise.race([
63
- this.provider.setup(onProgress),
64
- new Promise((_, reject) => setTimeout(() => reject(new Error('Deep analysis setup timed out.\n' +
65
- ' If local: run `rigour doctor` to check sidecar binary status.\n' +
66
- ' If cloud: check your API key with `rigour settings show`.')), SETUP_TIMEOUT_MS)),
67
- ]);
68
- const isLocal = !this.config.options.apiKey || this.config.options.provider === 'local';
69
- if (isLocal) {
70
- onProgress?.('\n 🔒 Local sidecar/model execution. Code remains on this machine.\n');
71
- }
72
- else {
73
- onProgress?.(`\n ☁️ Using ${this.config.options.provider} API. Code context may be sent to the provider.\n`);
74
- }
75
- // Step 1: AST extracts facts
76
- onProgress?.(' Extracting code facts...');
77
- let allFacts = await extractFacts(context.cwd, context.ignore);
78
- if (allFacts.length === 0) {
79
- onProgress?.(' No analyzable files found. Check ignore patterns and file extensions.');
54
+ await this.setupProvider();
55
+ this.config.onProgress?.(' Extracting code facts...');
56
+ const facts = prioritize(await extractFacts(context.cwd, context.ignore, scoped ? context.patterns : undefined));
57
+ this.outcome.filesAnalyzed = facts.length;
58
+ if (facts.length === 0) {
59
+ this.config.onProgress?.(' No analyzable files found. Check ignore patterns and file extensions.');
80
60
  return [];
81
61
  }
82
- // Smart prioritization: entry points first, then by complexity
83
- allFacts.sort((a, b) => {
84
- const aEntry = this.isEntryPoint(a.path) ? 1 : 0;
85
- const bEntry = this.isEntryPoint(b.path) ? 1 : 0;
86
- if (aEntry !== bEntry)
87
- return bEntry - aEntry;
88
- return b.lineCount - a.lineCount;
89
- });
90
- // Optional soft limit — if user configures deep.maxFiles, respect it
91
- // Otherwise process ALL files in batches (no hard cap)
92
- if (this.config.options.maxFiles && allFacts.length > this.config.options.maxFiles) {
93
- onProgress?.(` Limiting to ${this.config.options.maxFiles} files (configured in rigour.yml).`);
94
- allFacts = allFacts.slice(0, this.config.options.maxFiles);
95
- }
96
- const agentCount = this.config.options.agents || 1;
97
- const isCloud = !!this.config.options.apiKey;
98
- onProgress?.(` Found ${allFacts.length} files to analyze${agentCount > 1 ? ` with ${agentCount} parallel agents` : ''}.`);
99
- // Step 1.5: Check local project memory for known patterns (instant, no LLM)
100
- // Wrapped in try/catch: sqlite3 may not be available in all environments
101
- const fileList = allFacts.map(f => f.path).filter(Boolean);
102
- let localFindings = [];
103
- try {
104
- localFindings = await checkLocalPatterns(context.cwd, fileList);
105
- if (localFindings.length > 0) {
106
- onProgress?.(` 🧠 Local memory: ${localFindings.length} known pattern(s) matched instantly.`);
107
- }
108
- }
109
- catch (error) {
110
- Logger.debug(`Local memory check skipped (${error.message?.substring(0, 80)})`);
111
- onProgress?.(' ℹ Local memory unavailable — continuing with LLM analysis only.');
112
- }
113
- // Step 2: LLM interprets facts (in chunks)
114
- const chunks = chunkFacts(allFacts);
115
- const allFindings = [...localFindings];
116
- let failedChunks = 0;
117
- if (agentCount > 1 && isCloud) {
118
- // ── Multi-agent mode: partition chunks across N agents, analyze in parallel ──
119
- // Each agent gets its own provider instance for true parallelism.
120
- // Local mode stays sequential (single sidecar process).
121
- onProgress?.(` Spawning ${agentCount} parallel agents...`);
122
- const agentBuckets = Array.from({ length: agentCount }, () => []);
123
- chunks.forEach((chunk, i) => agentBuckets[i % agentCount].push(chunk));
124
- // Create N independent provider instances
125
- const agentProviders = [];
126
- for (let a = 0; a < agentCount; a++) {
127
- if (agentBuckets[a].length === 0)
128
- continue;
129
- const p = createProvider(this.config.options);
130
- await p.setup(); // Already connected — cloud setup is instant after first
131
- agentProviders.push(p);
132
- }
133
- // Run all agents in parallel
134
- const agentResults = await Promise.allSettled(agentProviders.map(async (provider, agentIdx) => {
135
- const bucket = agentBuckets[agentIdx];
136
- const findings = [];
137
- let failed = 0;
138
- for (let ci = 0; ci < bucket.length; ci++) {
139
- const globalIdx = agentIdx + ci * agentCount + 1;
140
- onProgress?.(` Agent ${agentIdx + 1}: batch ${ci + 1}/${bucket.length} (global ${globalIdx}/${chunks.length})`);
141
- const factsStr = factsToPromptString(bucket[ci]);
142
- const prompt = buildAnalysisPrompt(factsStr, this.config.checks);
143
- try {
144
- const response = await provider.analyze(prompt, {
145
- maxTokens: this.config.maxTokens || 8192,
146
- temperature: this.config.temperature || 0.1,
147
- timeout: this.config.timeoutMs || 120000,
148
- jsonMode: true,
149
- });
150
- findings.push(...parseFindings(response));
151
- }
152
- catch (error) {
153
- failed++;
154
- Logger.warn(`Agent ${agentIdx + 1} chunk ${ci + 1} failed: ${error.message}`);
155
- }
156
- }
157
- return { findings, failed };
158
- }));
159
- // Merge results and dispose extra providers
160
- for (let i = 0; i < agentResults.length; i++) {
161
- const result = agentResults[i];
162
- if (result.status === 'fulfilled') {
163
- allFindings.push(...result.value.findings);
164
- failedChunks += result.value.failed;
165
- }
166
- else {
167
- failedChunks += agentBuckets[i].length;
168
- Logger.warn(`Agent ${i + 1} failed entirely: ${result.reason?.message || 'unknown'}`);
169
- }
170
- agentProviders[i]?.dispose();
171
- }
172
- onProgress?.(` All ${agentCount} agents completed.`);
173
- }
174
- else {
175
- // ── Single-agent mode: sequential chunk processing ──
176
- let chunkIndex = 0;
177
- for (const chunk of chunks) {
178
- chunkIndex++;
179
- onProgress?.(` Analyzing batch ${chunkIndex}/${chunks.length}...`);
180
- const factsStr = factsToPromptString(chunk);
181
- const prompt = buildAnalysisPrompt(factsStr, this.config.checks);
182
- try {
183
- const response = await this.provider.analyze(prompt, {
184
- maxTokens: this.config.maxTokens || (isCloud ? 4096 : 512),
185
- temperature: this.config.temperature || 0.1,
186
- timeout: this.config.timeoutMs || (isCloud ? 120000 : 60000),
187
- jsonMode: true,
188
- });
189
- const findings = parseFindings(response);
190
- allFindings.push(...findings);
191
- }
192
- catch (error) {
193
- failedChunks++;
194
- Logger.warn(`Chunk ${chunkIndex} inference failed: ${error.message}`);
195
- onProgress?.(` ⚠ Batch ${chunkIndex} failed: ${error.message}`);
196
- }
197
- }
198
- }
199
- // Cross-file analysis (if we have enough files and at least some chunks succeeded)
200
- if (allFacts.length >= 3 && failedChunks < chunks.length) {
201
- onProgress?.(' Running cross-file analysis...');
202
- try {
203
- const crossPrompt = buildCrossFilePrompt(allFacts);
204
- const crossResponse = await this.provider.analyze(crossPrompt, {
205
- maxTokens: this.config.maxTokens || (isCloud ? 4096 : 512),
206
- temperature: this.config.temperature || 0.1,
207
- timeout: this.config.timeoutMs || (isCloud ? 120000 : 60000),
208
- jsonMode: true,
209
- });
210
- const crossFindings = parseFindings(crossResponse);
211
- allFindings.push(...crossFindings);
212
- }
213
- catch (error) {
214
- Logger.warn(`Cross-file analysis failed: ${error.message}`);
215
- }
216
- }
217
- // Step 3: AST verifies LLM
218
- onProgress?.(' Verifying findings...');
219
- const verified = verifyFindings(allFindings, allFacts);
220
- const durationMs = Date.now() - startTime;
221
- onProgress?.(` ✓ ${verified.length} verified findings (${allFindings.length - verified.length} dropped) in ${(durationMs / 1000).toFixed(1)}s`);
222
- if (failedChunks > 0) {
223
- onProgress?.(` ⚠ ${failedChunks}/${chunks.length} batches failed — results may be incomplete.`);
224
- }
225
- // Convert to Failure format
226
- for (const finding of verified) {
227
- const failure = this.createFailure(finding.description, [finding.file], finding.suggestion, `[${finding.category}] ${finding.description.substring(0, 80)}`, finding.line, undefined, finding.severity);
228
- // Tag with deep analysis metadata
229
- failure.confidence = finding.confidence;
230
- failure.source = 'llm';
231
- failure.category = finding.category;
232
- failure.verified = finding.verified;
233
- failures.push(failure);
234
- }
62
+ const verified = scoped
63
+ ? await this.reviewCode(context.cwd, facts)
64
+ : await this.analyzeFacts(context.cwd, limitFiles(facts, this.config));
65
+ this.config.onProgress?.(` ✓ ${verified.length} verified findings in ${((Date.now() - startTime) / 1000).toFixed(1)}s`);
66
+ return verified.map(f => this.toFailure(f));
235
67
  }
236
68
  catch (error) {
237
- Logger.error(`Deep analysis failed: ${error.message}`);
238
- onProgress?.(` ⚠ Deep analysis error: ${error.message}`);
239
- // Don't fail the whole check — deep is advisory
69
+ this.outcome.status = 'error';
70
+ this.outcome.error = error?.message ?? String(error);
71
+ Logger.error(`Deep analysis failed: ${this.outcome.error}`);
72
+ this.config.onProgress?.(` ⚠ Deep analysis error: ${this.outcome.error}`);
73
+ return [];
240
74
  }
241
75
  finally {
242
76
  this.provider?.dispose();
243
77
  }
244
- return failures;
245
78
  }
246
- }
247
- /**
248
- * Parse LLM response into structured findings.
249
- * Handles various response formats (raw JSON, markdown-wrapped JSON, etc.)
250
- */
251
- function parseFindings(response) {
252
- if (!response || response.trim().length === 0) {
253
- Logger.warn('Empty LLM response received');
254
- return [];
79
+ async setupProvider() {
80
+ const { options, onProgress } = this.config;
81
+ onProgress?.('\n Setting up Rigour Brain...\n');
82
+ const provider = createProvider(options);
83
+ this.provider = provider;
84
+ if (isCloud(this.config.options)) {
85
+ await withTimeout(provider.setup(onProgress), CLOUD_SETUP_TIMEOUT_MS, 'Deep analysis setup timed out. Check your API key with `rigour settings show`.');
86
+ this.outcome.model = options.modelName || options.provider || 'cloud';
87
+ onProgress?.(`\n ☁️ Using ${options.provider} API. Code context may be sent to the provider.\n`);
88
+ return;
89
+ }
90
+ await provider.setup(onProgress);
91
+ const active = provider instanceof SidecarProvider ? provider.getActiveModel() : null;
92
+ this.outcome.model = active?.name;
93
+ this.outcome.modelFallback = active?.fallback;
94
+ onProgress?.('\n 🔒 Local sidecar/model execution. Code remains on this machine.\n');
255
95
  }
256
- try {
257
- // Try direct JSON parse first
258
- const parsed = JSON.parse(response);
259
- if (Array.isArray(parsed.findings))
260
- return validateFindings(parsed.findings);
261
- if (Array.isArray(parsed))
262
- return validateFindings(parsed);
263
- return [];
96
+ async reviewCode(cwd, facts) {
97
+ this.config.onProgress?.(` Reviewing ${facts.length} scoped file(s) with source...`);
98
+ const result = await runCodePass(this.provider, facts, {
99
+ cwd,
100
+ inference: inferenceOptions(this.config),
101
+ maxSourceChars: isCloud(this.config.options) ? CLOUD_SOURCE_CHARS : LOCAL_SOURCE_CHARS,
102
+ focusLines: this.config.options.focusLines,
103
+ onProgress: this.config.onProgress,
104
+ });
105
+ this.recordPass(result);
106
+ return verifyCodeFindings(result.findings, result.contexts);
264
107
  }
265
- catch {
266
- // Try extracting JSON from markdown code blocks
267
- const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/);
268
- if (jsonMatch) {
269
- try {
270
- const parsed = JSON.parse(jsonMatch[1]);
271
- if (Array.isArray(parsed.findings))
272
- return validateFindings(parsed.findings);
273
- if (Array.isArray(parsed))
274
- return validateFindings(parsed);
275
- }
276
- catch {
277
- // Fall through
278
- }
279
- }
280
- // Try finding JSON object in response
281
- const objectMatch = response.match(/\{[\s\S]*"findings"[\s\S]*\}/);
282
- if (objectMatch) {
283
- try {
284
- const parsed = JSON.parse(objectMatch[0]);
285
- if (Array.isArray(parsed.findings))
286
- return validateFindings(parsed.findings);
287
- }
288
- catch {
289
- // Give up
290
- }
108
+ async analyzeFacts(cwd, facts) {
109
+ this.config.onProgress?.(` Found ${facts.length} files to analyze.`);
110
+ const memory = await this.localMemoryFindings(cwd, facts);
111
+ const agentCount = this.config.options.agents || 1;
112
+ const result = await runFactsPass(this.provider, facts, {
113
+ inference: inferenceOptions(this.config),
114
+ checks: this.config.checks,
115
+ onProgress: this.config.onProgress,
116
+ agents: isCloud(this.config.options) && agentCount > 1
117
+ ? { count: agentCount, create: () => this.createCloudAgent() }
118
+ : undefined,
119
+ });
120
+ this.recordPass(result);
121
+ return verifyFindings([...memory, ...result.findings], facts);
122
+ }
123
+ recordPass(result) {
124
+ this.outcome.chunksTotal = result.chunksTotal;
125
+ this.outcome.chunksFailed = result.chunksFailed;
126
+ if (result.chunksTotal > 0 && result.chunksFailed === result.chunksTotal) {
127
+ this.outcome.status = 'error';
128
+ this.outcome.error = `All ${result.chunksTotal} inference call(s) failed. First error: ${result.firstError ?? 'unknown'}`;
291
129
  }
292
- // Last resort: try to recover truncated JSON arrays
293
- // LLMs sometimes exceed token limits, truncating the response mid-JSON
294
- const recovered = recoverTruncatedFindings(response);
295
- if (recovered.length > 0) {
296
- Logger.info(`Recovered ${recovered.length} findings from truncated response`);
297
- return recovered;
130
+ else if (result.chunksFailed > 0) {
131
+ this.outcome.status = 'partial';
132
+ this.config.onProgress?.(` ⚠ ${result.chunksFailed}/${result.chunksTotal} batches failed — results are incomplete.`);
298
133
  }
299
- Logger.warn(`Could not parse LLM response as findings JSON. First 200 chars: ${response.substring(0, 200)}`);
300
- return [];
301
134
  }
302
- }
303
- /**
304
- * Attempt to recover individual finding objects from a truncated JSON response.
305
- * Extracts complete JSON objects from partial arrays.
306
- */
307
- function recoverTruncatedFindings(response) {
308
- const findings = [];
309
- // Match individual complete objects within the response
310
- const objectRegex = /\{\s*"category"\s*:\s*"[^"]+"\s*,[\s\S]*?"description"\s*:\s*"[^"]*"[^}]*\}/g;
311
- let match;
312
- while ((match = objectRegex.exec(response)) !== null) {
135
+ async localMemoryFindings(cwd, facts) {
313
136
  try {
314
- const obj = JSON.parse(match[0]);
315
- if (obj.category && obj.file && obj.description) {
316
- findings.push(obj);
317
- }
137
+ const found = await checkLocalPatterns(cwd, facts.map(f => f.path).filter(Boolean));
138
+ if (found.length > 0)
139
+ this.config.onProgress?.(` 🧠 Local memory: ${found.length} known pattern(s) matched instantly.`);
140
+ return found;
318
141
  }
319
- catch {
320
- // Individual object was itself truncated — skip
142
+ catch (error) {
143
+ Logger.debug(`Local memory check skipped (${error.message?.substring(0, 80)})`);
144
+ return [];
321
145
  }
322
146
  }
323
- return validateFindings(findings);
147
+ async createCloudAgent() {
148
+ const provider = createProvider(this.config.options);
149
+ await provider.setup();
150
+ return provider;
151
+ }
152
+ toFailure(finding) {
153
+ const failure = this.createFailure(finding.description, [finding.file], finding.suggestion, `[${finding.category}] ${finding.description.substring(0, 80)}`, finding.line, undefined, finding.severity);
154
+ return { ...failure, confidence: finding.confidence, source: 'llm', category: finding.category, verified: finding.verified };
155
+ }
324
156
  }
325
- /**
326
- * Validate and sanitize findings from LLM response.
327
- * Drops malformed entries that lack required fields.
328
- */
329
- function validateFindings(raw) {
330
- return raw.filter(f => {
331
- if (!f || typeof f !== 'object')
332
- return false;
333
- if (!f.category || typeof f.category !== 'string')
334
- return false;
335
- if (!f.file || typeof f.file !== 'string')
336
- return false;
337
- if (!f.description || typeof f.description !== 'string')
338
- return false;
339
- // Normalize confidence
340
- if (typeof f.confidence !== 'number' || f.confidence < 0 || f.confidence > 1) {
341
- f.confidence = 0.5;
342
- }
343
- // Normalize severity
344
- const validSeverities = ['critical', 'high', 'medium', 'low', 'info'];
345
- if (!validSeverities.includes(f.severity)) {
346
- f.severity = 'medium';
347
- }
348
- return true;
157
+ /** Cloud when an API key and a non-local provider are set (same rule as createProvider). */
158
+ function isCloud(options) {
159
+ return !!options.apiKey && !!options.provider && options.provider !== 'local';
160
+ }
161
+ /** Per-call options; unset config falls back to per-provider defaults. */
162
+ function inferenceOptions(config) {
163
+ const cloud = isCloud(config.options);
164
+ return {
165
+ maxTokens: config.maxTokens ?? (cloud ? 4096 : 1024),
166
+ temperature: config.temperature ?? 0.1,
167
+ timeout: config.timeoutMs ?? (cloud ? 120_000 : 60_000),
168
+ jsonMode: true,
169
+ };
170
+ }
171
+ function limitFiles(facts, config) {
172
+ const max = config.options.maxFiles;
173
+ if (!max || facts.length <= max)
174
+ return facts;
175
+ config.onProgress?.(` Limiting to ${max} files (configured in rigour.yml).`);
176
+ return facts.slice(0, max);
177
+ }
178
+ function emptyOutcome() {
179
+ return { status: 'ok', mode: 'facts', filesAnalyzed: 0, chunksTotal: 0, chunksFailed: 0 };
180
+ }
181
+ /** Entry points first, then larger files. */
182
+ function prioritize(facts) {
183
+ return [...facts].sort((a, b) => {
184
+ const entry = Number(isEntryPoint(b.path)) - Number(isEntryPoint(a.path));
185
+ return entry !== 0 ? entry : b.lineCount - a.lineCount;
349
186
  });
350
187
  }
188
+ const ENTRY_POINTS = new Set([
189
+ 'index.ts', 'index.js', 'index.tsx', 'index.jsx', 'index.mjs',
190
+ 'main.ts', 'main.js', 'main.py', 'main.go', 'main.rs', 'main.java', 'main.kt',
191
+ 'app.ts', 'app.js', 'app.py', 'app.go', 'app.rb',
192
+ 'server.ts', 'server.js', 'server.py', 'server.go',
193
+ 'mod.rs', 'lib.rs',
194
+ ]);
195
+ function isEntryPoint(filePath) {
196
+ return ENTRY_POINTS.has(path.basename(filePath).toLowerCase());
197
+ }
198
+ async function withTimeout(promise, ms, message) {
199
+ let timer;
200
+ try {
201
+ return await Promise.race([
202
+ promise,
203
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }),
204
+ ]);
205
+ }
206
+ finally {
207
+ clearTimeout(timer);
208
+ }
209
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ const provider = vi.hoisted(() => ({
6
+ setup: vi.fn(async () => undefined),
7
+ analyze: vi.fn(async (_prompt) => '{"findings": []}'),
8
+ }));
9
+ vi.mock('../inference/index.js', () => ({
10
+ createProvider: () => ({
11
+ name: 'fake',
12
+ isAvailable: async () => true,
13
+ setup: provider.setup,
14
+ analyze: provider.analyze,
15
+ dispose: () => undefined,
16
+ }),
17
+ }));
18
+ vi.mock('../storage/local-memory.js', () => ({ checkLocalPatterns: async () => [] }));
19
+ const { DeepAnalysisGate } = await import('./deep-analysis.js');
20
+ const REPORT_TS = [
21
+ 'export async function loadReport(days: number) {',
22
+ ' const rows = await fetchAll(days);',
23
+ ' return rows.length;',
24
+ '}',
25
+ ].join('\n');
26
+ describe('DeepAnalysisGate', () => {
27
+ let cwd;
28
+ beforeEach(async () => {
29
+ cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-deep-gate-'));
30
+ await fs.outputFile(path.join(cwd, 'src/report.ts'), REPORT_TS);
31
+ await fs.outputFile(path.join(cwd, 'src/other.ts'), 'export const other = 1;\nexport const two = 2;\nexport const three = 3;\n');
32
+ provider.setup.mockReset().mockResolvedValue(undefined);
33
+ provider.analyze.mockReset().mockResolvedValue('{"findings": []}');
34
+ });
35
+ afterEach(async () => { await fs.remove(cwd); });
36
+ const gate = () => new DeepAnalysisGate({ options: { enabled: true, provider: 'local' } });
37
+ it('records an error outcome when setup fails', async () => {
38
+ provider.setup.mockRejectedValue(new Error('engine missing'));
39
+ const g = gate();
40
+ expect(await g.run({ cwd })).toEqual([]);
41
+ expect(g.getOutcome()).toMatchObject({ status: 'error', error: 'engine missing' });
42
+ });
43
+ it('records an error outcome when every inference call fails', async () => {
44
+ provider.analyze.mockRejectedValue(new Error('invalid argument: --json'));
45
+ const g = gate();
46
+ await g.run({ cwd, patterns: ['src/report.ts', 'src/other.ts'] });
47
+ expect(g.getOutcome()).toMatchObject({ status: 'error', chunksTotal: 2, chunksFailed: 2 });
48
+ expect(g.getOutcome().error).toContain('invalid argument: --json');
49
+ });
50
+ it('records a partial outcome when some calls fail', async () => {
51
+ provider.analyze.mockRejectedValueOnce(new Error('timed out')).mockResolvedValue('{"findings": []}');
52
+ const g = gate();
53
+ await g.run({ cwd, patterns: ['src/report.ts', 'src/other.ts'] });
54
+ expect(g.getOutcome()).toMatchObject({ status: 'partial', chunksTotal: 2, chunksFailed: 1 });
55
+ });
56
+ it('reviews only the scoped file, with its numbered source', async () => {
57
+ const g = gate();
58
+ await g.run({ cwd, patterns: ['src/report.ts'] });
59
+ expect(provider.analyze).toHaveBeenCalledTimes(1);
60
+ const prompt = provider.analyze.mock.calls[0][0];
61
+ expect(prompt).toContain('2| const rows = await fetchAll(days);');
62
+ expect(prompt).not.toContain('src/other.ts');
63
+ expect(g.getOutcome()).toMatchObject({ status: 'ok', mode: 'code', filesAnalyzed: 1 });
64
+ });
65
+ it('keeps grounded findings and drops ones about code that was not sent', async () => {
66
+ provider.analyze.mockResolvedValue(JSON.stringify({ findings: [
67
+ { category: 'scalability', severity: 'high', file: 'src/report.ts', line: 2, description: '`fetchAll` loads every row.', suggestion: 'Aggregate in SQL.', confidence: 0.8 },
68
+ { category: 'security', severity: 'high', file: 'src/report.ts', line: 3, description: '`sendSecret` leaks a token.', suggestion: 'x', confidence: 0.9 },
69
+ ] }));
70
+ const failures = await gate().run({ cwd, patterns: ['src/report.ts'] });
71
+ expect(failures).toHaveLength(1);
72
+ expect(failures[0]).toMatchObject({ id: 'deep-analysis', files: ['src/report.ts'], line: 2, provenance: 'deep-analysis', verified: true });
73
+ });
74
+ it('uses the facts prompt for an unscoped run', async () => {
75
+ const g = gate();
76
+ await g.run({ cwd });
77
+ expect(provider.analyze.mock.calls[0][0]).toContain('AST-EXTRACTED FACTS');
78
+ expect(g.getOutcome()).toMatchObject({ status: 'ok', mode: 'facts', filesAnalyzed: 2 });
79
+ });
80
+ it('is ok with no analyzable files in scope', async () => {
81
+ const g = gate();
82
+ expect(await g.run({ cwd, patterns: ['README.md'] })).toEqual([]);
83
+ expect(g.getOutcome()).toMatchObject({ status: 'ok', filesAnalyzed: 0 });
84
+ expect(provider.analyze).not.toHaveBeenCalled();
85
+ });
86
+ });
@@ -0,0 +1,15 @@
1
+ import type { GateContext } from './base.js';
2
+ import type { Config, DeepOptions, Failure, Report, Status } from '../types/index.js';
3
+ export interface DeepRunResult {
4
+ failures: Failure[];
5
+ summary: Status;
6
+ stats: NonNullable<Report['stats']['deep']>;
7
+ }
8
+ /**
9
+ * Run the deep gate and turn its outcome into summary, stats and failures.
10
+ * A deep run that could not analyze anything is ERROR with a visible
11
+ * failure, never a clean PASS.
12
+ */
13
+ export declare function runDeepAnalysis(config: Config, context: Pick<GateContext, 'cwd' | 'ignore' | 'patterns'>, deepOptions: DeepOptions & {
14
+ onProgress?: (msg: string) => void;
15
+ }): Promise<DeepRunResult>;