@rigour-labs/core 5.1.0 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/gates/ast.js CHANGED
@@ -39,22 +39,26 @@ export class ASTGate extends Gate {
39
39
  cwd: normalizedCwd,
40
40
  ignore: ignore,
41
41
  });
42
- for (const file of files) {
43
- const handler = this.handlers.find(h => h.supports(file));
44
- if (!handler)
45
- continue;
46
- const fullPath = path.join(context.cwd, file);
47
- try {
42
+ // Process files concurrently in batches for performance
43
+ const CONCURRENCY = 16;
44
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
45
+ const batch = files.slice(i, i + CONCURRENCY);
46
+ const results = await Promise.allSettled(batch.map(async (file) => {
47
+ const handler = this.handlers.find(h => h.supports(file));
48
+ if (!handler)
49
+ return [];
50
+ const fullPath = path.join(context.cwd, file);
48
51
  const content = await fs.readFile(fullPath, 'utf-8');
49
- const gateFailures = await handler.run({
52
+ return handler.run({
50
53
  cwd: context.cwd,
51
54
  file: file,
52
55
  content
53
56
  });
54
- failures.push(...gateFailures);
55
- }
56
- catch (error) {
57
- // Individual file read failures shouldn't crash the whole run
57
+ }));
58
+ for (const result of results) {
59
+ if (result.status === 'fulfilled' && result.value.length > 0) {
60
+ failures.push(...result.value);
61
+ }
58
62
  }
59
63
  }
60
64
  return failures;
@@ -43,23 +43,29 @@ export class ContextWindowArtifactsGate extends Gate {
43
43
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*', '**/*.min.*'],
44
44
  });
45
45
  Logger.info(`Context Window Artifacts: Scanning ${files.length} files`);
46
- for (const file of files) {
47
- if (this.shouldSkipFile(file))
48
- continue;
49
- try {
46
+ const CONCURRENCY = 16;
47
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
48
+ const batch = files.slice(i, i + CONCURRENCY);
49
+ const results = await Promise.allSettled(batch.map(async (file) => {
50
+ if (this.shouldSkipFile(file))
51
+ return null;
50
52
  const content = await fs.readFile(path.join(context.cwd, file), 'utf-8');
51
53
  const lines = content.split('\n');
52
54
  if (lines.length < this.config.min_file_lines)
53
- continue;
55
+ return null;
54
56
  const metrics = this.analyzeFile(content, file, path.join(context.cwd, file));
55
57
  if (metrics && metrics.signals.length >= this.config.signals_required &&
56
58
  metrics.degradationScore >= this.config.degradation_threshold) {
57
59
  const signalList = metrics.signals.map(s => ` • ${s}`).join('\n');
58
60
  const midpoint = Math.floor(metrics.totalLines / 2);
59
- failures.push(this.createFailure(`Context window artifact detected in ${file} (${metrics.totalLines} lines, degradation: ${(metrics.degradationScore * 100).toFixed(0)}%):\n${signalList}`, [file], `This file shows quality degradation from top to bottom, a pattern typical of AI context window exhaustion. Consider refactoring the bottom half or splitting the file. The quality drop begins around line ${midpoint}.`, 'Context Window Artifacts', midpoint, undefined, 'high'));
61
+ return this.createFailure(`Context window artifact detected in ${file} (${metrics.totalLines} lines, degradation: ${(metrics.degradationScore * 100).toFixed(0)}%):\n${signalList}`, [file], `This file shows quality degradation from top to bottom, a pattern typical of AI context window exhaustion. Consider refactoring the bottom half or splitting the file. The quality drop begins around line ${midpoint}.`, 'Context Window Artifacts', midpoint, undefined, 'high');
60
62
  }
63
+ return null;
64
+ }));
65
+ for (const result of results) {
66
+ if (result.status === 'fulfilled' && result.value)
67
+ failures.push(result.value);
61
68
  }
62
- catch (e) { }
63
69
  }
64
70
  return failures;
65
71
  }
@@ -50,9 +50,20 @@ export class DeepAnalysisGate extends Gate {
50
50
  // Step 0: Initialize inference provider (with timeout)
51
51
  onProgress?.('\n Setting up Rigour Brain...\n');
52
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
+ }
53
62
  await Promise.race([
54
63
  this.provider.setup(onProgress),
55
- new Promise((_, reject) => setTimeout(() => reject(new Error('Setup timed out. Check network or model availability.')), SETUP_TIMEOUT_MS)),
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)),
56
67
  ]);
57
68
  const isLocal = !this.config.options.apiKey || this.config.options.provider === 'local';
58
69
  if (isLocal) {
@@ -21,7 +21,7 @@ export class EnvironmentGate extends Gate {
21
21
  // Ensure range is a string
22
22
  const semverRange = String(range);
23
23
  try {
24
- const { stdout } = await execa(tool, ['--version'], { shell: true });
24
+ const { stdout } = await execa(tool, ['--version']);
25
25
  const versionMatch = stdout.match(/(\d+\.\d+\.\d+)/);
26
26
  if (versionMatch) {
27
27
  const version = versionMatch[1];
@@ -48,17 +48,20 @@ export class InconsistentErrorHandlingGate extends Gate {
48
48
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*'],
49
49
  });
50
50
  Logger.info(`Inconsistent Error Handling: Scanning ${files.length} files`);
51
- for (const file of files) {
52
- if (this.shouldSkipFile(file))
53
- continue;
54
- try {
51
+ const CONCURRENCY = 16;
52
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
53
+ const batch = files.slice(i, i + CONCURRENCY);
54
+ const results = await Promise.allSettled(batch.map(async (file) => {
55
+ if (this.shouldSkipFile(file))
56
+ return [];
55
57
  const content = await fs.readFile(path.join(context.cwd, file), 'utf-8');
56
58
  const adapter = languageAdapters.getAdapter(file);
57
59
  if (!adapter)
58
- continue;
60
+ return [];
61
+ const localHandlers = [];
59
62
  const errorHandlerFacts = adapter.extractErrorHandlers(content);
60
63
  for (const fact of errorHandlerFacts) {
61
- handlers.push({
64
+ localHandlers.push({
62
65
  file,
63
66
  line: fact.startLine,
64
67
  errorType: fact.type,
@@ -66,8 +69,12 @@ export class InconsistentErrorHandlingGate extends Gate {
66
69
  rawPattern: fact.body.split('\n')[0]?.trim() || '',
67
70
  });
68
71
  }
72
+ return localHandlers;
73
+ }));
74
+ for (const result of results) {
75
+ if (result.status === 'fulfilled')
76
+ handlers.push(...result.value);
69
77
  }
70
- catch (e) { }
71
78
  }
72
79
  // Group by error type
73
80
  const byType = new Map();
@@ -44,21 +44,19 @@ export class PromiseSafetyGate extends Gate {
44
44
  '**/bin/Debug/**', '**/bin/Release/**', '**/obj/**', '**/venv/**', '**/.venv/**'],
45
45
  });
46
46
  Logger.info(`Async Safety: Scanning ${files.length} files across all languages`);
47
- for (const file of files) {
48
- if (this.config.ignore_patterns.some(p => new RegExp(p).test(file)))
49
- continue;
50
- if (this.shouldSkipFile(file))
51
- continue;
52
- const lang = detectLang(file);
53
- if (lang === 'unknown')
54
- continue;
55
- try {
47
+ const CONCURRENCY = 16;
48
+ const filteredFiles = files.filter(file => !this.config.ignore_patterns.some(p => new RegExp(p).test(file)) &&
49
+ !this.shouldSkipFile(file) &&
50
+ detectLang(file) !== 'unknown');
51
+ for (let i = 0; i < filteredFiles.length; i += CONCURRENCY) {
52
+ const batch = filteredFiles.slice(i, i + CONCURRENCY);
53
+ await Promise.allSettled(batch.map(async (file) => {
54
+ const lang = detectLang(file);
56
55
  const fullPath = path.join(context.cwd, file);
57
56
  const content = await fs.readFile(fullPath, 'utf-8');
58
57
  const lines = content.split('\n');
59
58
  this.scanFile(lang, lines, content, file, violations);
60
- }
61
- catch { /* skip */ }
59
+ }));
62
60
  }
63
61
  return this.buildFailures(violations);
64
62
  }
@@ -139,8 +139,13 @@ export class GateRunner {
139
139
  // Create shared file cache for all gates (solves memory bloat on large repos)
140
140
  const fileCache = new FileSystemCache();
141
141
  // 1. Run internal gates
142
+ const onProgress = deepOptions?.onProgress;
143
+ const totalGates = this.gates.length;
144
+ let gateIndex = 0;
142
145
  for (const gate of this.gates) {
146
+ gateIndex++;
143
147
  try {
148
+ onProgress?.(` [${gateIndex}/${totalGates}] Running ${gate.id}...`);
144
149
  const gateFailures = await gate.run({ cwd, record, ignore, patterns, fileCache });
145
150
  if (gateFailures.length > 0) {
146
151
  failures.push(...gateFailures);
@@ -173,7 +178,10 @@ export class GateRunner {
173
178
  }
174
179
  try {
175
180
  Logger.info(`Running command gate: ${key} (${cmd})`);
176
- await execa(cmd, { shell: true, cwd });
181
+ const parts = cmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [cmd];
182
+ const bin = parts[0];
183
+ const args = parts.slice(1).map(a => a.replace(/^["']|["']$/g, ''));
184
+ await execa(bin, args, { cwd });
177
185
  summary[key] = 'PASS';
178
186
  }
179
187
  catch (error) {
@@ -219,7 +227,7 @@ export class GateRunner {
219
227
  enabled: true,
220
228
  tier: deepTier,
221
229
  model: isLocalDeepExecution
222
- ? (deepOptions.pro ? 'Qwen2.5-Coder-1.5B' : 'Qwen3.5-0.8B')
230
+ ? (deepOptions.pro ? 'Qwen2.5-Coder-1.5B' : 'Qwen2.5-Coder-0.5B')
223
231
  : (deepOptions.modelName || deepOptions.provider || 'cloud'),
224
232
  total_ms: Date.now() - deepSetupStart,
225
233
  findings_count: deepFailures.length,
@@ -247,8 +255,10 @@ export class GateRunner {
247
255
  // Replace failures array with deduplicated version
248
256
  failures.length = 0;
249
257
  failures.push(...deduped);
250
- // Step 2: Calculate per-gate deductions with cap
251
- const PER_GATE_CAP = 30; // No single gate can deduct more than 30 points
258
+ // Step 2: Calculate per-gate deductions with dynamic cap
259
+ // Cap scales with number of failing gates so score doesn't floor at 0 too easily
260
+ const uniqueFailingGates = new Set(failures.map(f => f.id || 'unknown')).size;
261
+ const PER_GATE_CAP = uniqueFailingGates > 0 ? Math.max(5, Math.floor(80 / uniqueFailingGates)) : 30;
252
262
  const severityBreakdown = {};
253
263
  const gateDeductions = new Map();
254
264
  for (const f of failures) {
@@ -258,12 +268,16 @@ export class GateRunner {
258
268
  const gateId = f.id || 'unknown';
259
269
  gateDeductions.set(gateId, (gateDeductions.get(gateId) || 0) + weight);
260
270
  }
261
- // Step 3: Apply cap per gate and sum
271
+ // Step 3: Apply cap per gate and sum (max deduction capped at 90 so score never hits 0)
262
272
  let totalDeduction = 0;
263
273
  for (const [_gateId, deduction] of gateDeductions) {
264
274
  totalDeduction += Math.min(deduction, PER_GATE_CAP);
265
275
  }
266
- const score = Math.max(0, 100 - totalDeduction);
276
+ // Cap total deduction at 90 so score has a meaningful floor
277
+ // (0 is reserved for catastrophic failures only)
278
+ const hasCritical = failures.some(f => f.severity === 'critical');
279
+ const maxDeduction = hasCritical ? 100 : 90;
280
+ const score = Math.max(0, 100 - Math.min(totalDeduction, maxDeduction));
267
281
  // Two-score system: separate AI health from structural quality
268
282
  // IMPORTANT: Only ai-drift affects ai_health_score, only traditional affects structural_score.
269
283
  // Security and governance affect the overall score but NOT the sub-scores,
@@ -35,7 +35,7 @@ describe('GateRunner deep stats execution mode', () => {
35
35
  pro: false,
36
36
  });
37
37
  expect(report.stats.deep?.tier).toBe('lite');
38
- expect(report.stats.deep?.model).toBe('Qwen3.5-0.8B');
38
+ expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-0.5B');
39
39
  });
40
40
  it('reports local deep tier when provider=local and pro=true', async () => {
41
41
  vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
@@ -95,20 +95,19 @@ export class SideEffectAnalysisGate extends Gate {
95
95
  ],
96
96
  });
97
97
  Logger.info(`Side-Effect Analysis: Scanning ${files.length} files`);
98
- for (const file of files) {
99
- if (this.cfg.ignore_patterns.some(p => new RegExp(p).test(file)))
100
- continue;
101
- const ext = path.extname(file).toLowerCase();
102
- const lang = LANG_MAP[ext];
103
- if (!lang)
104
- continue;
105
- try {
98
+ const CONCURRENCY = 16;
99
+ const filteredFiles = files.filter(file => !this.cfg.ignore_patterns.some(p => new RegExp(p).test(file)) &&
100
+ LANG_MAP[path.extname(file).toLowerCase()]);
101
+ for (let i = 0; i < filteredFiles.length; i += CONCURRENCY) {
102
+ const batch = filteredFiles.slice(i, i + CONCURRENCY);
103
+ await Promise.allSettled(batch.map(async (file) => {
104
+ const ext = path.extname(file).toLowerCase();
105
+ const lang = LANG_MAP[ext];
106
106
  const fullPath = path.join(context.cwd, file);
107
107
  const content = await fs.readFile(fullPath, 'utf-8');
108
108
  const lines = content.split('\n');
109
109
  this.scanFile(lang, lines, content, file, violations);
110
- }
111
- catch { /* skip unreadable files */ }
110
+ }));
112
111
  }
113
112
  return violations.map(v => violationToFailure(v, (msg, files, hint, title, sl, el, sev) => this.createFailure(msg, files, hint, title, sl, el, sev)));
114
113
  }
@@ -68,36 +68,47 @@ export class TestQualityGate extends Gate {
68
68
  '**/target/**', '**/.gradle/**', '**/out/**'],
69
69
  });
70
70
  Logger.info(`Test Quality: Scanning ${files.length} test files`);
71
- for (const file of files) {
72
- try {
71
+ const CONCURRENCY = 16;
72
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
73
+ const batch = files.slice(i, i + CONCURRENCY);
74
+ const results = await Promise.allSettled(batch.map(async (file) => {
73
75
  const fullPath = path.join(context.cwd, file);
74
76
  const content = await fs.readFile(fullPath, 'utf-8');
75
77
  const ext = path.extname(file);
76
78
  const adapter = languageAdapters.getAdapter(file);
77
79
  if (!adapter)
78
- continue;
79
- const langConfig = {
80
- check_empty_tests: this.config.check_empty_tests,
81
- check_tautological: this.config.check_tautological,
82
- check_mock_heavy: this.config.check_mock_heavy,
83
- max_mocks_per_test: this.config.max_mocks_per_test,
84
- };
80
+ return [];
81
+ const localIssues = [];
85
82
  switch (adapter.id) {
86
83
  case 'js':
87
- this.checkJSTestQuality(content, file, issues);
84
+ this.checkJSTestQuality(content, file, localIssues);
88
85
  break;
89
86
  case 'python':
90
- this.checkPythonTestQuality(content, file, issues);
87
+ this.checkPythonTestQuality(content, file, localIssues);
91
88
  break;
92
89
  case 'go':
93
- checkGoTestQuality(content, file, issues, langConfig);
90
+ checkGoTestQuality(content, file, localIssues, {
91
+ check_empty_tests: this.config.check_empty_tests,
92
+ check_tautological: this.config.check_tautological,
93
+ check_mock_heavy: this.config.check_mock_heavy,
94
+ max_mocks_per_test: this.config.max_mocks_per_test,
95
+ });
94
96
  break;
95
97
  case 'java':
96
- checkJavaKotlinTestQuality(content, file, ext, issues, langConfig);
98
+ checkJavaKotlinTestQuality(content, file, ext, localIssues, {
99
+ check_empty_tests: this.config.check_empty_tests,
100
+ check_tautological: this.config.check_tautological,
101
+ check_mock_heavy: this.config.check_mock_heavy,
102
+ max_mocks_per_test: this.config.max_mocks_per_test,
103
+ });
97
104
  break;
98
105
  }
106
+ return localIssues;
107
+ }));
108
+ for (const result of results) {
109
+ if (result.status === 'fulfilled')
110
+ issues.push(...result.value);
99
111
  }
100
- catch { /* skip */ }
101
112
  }
102
113
  // Group by file
103
114
  const byFile = new Map();
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Hook events:
9
9
  * - Claude Code: PreToolUse matcher (all tools)
10
- * - Cursor: beforeFileEdit event
10
+ * - Cursor: beforeSubmitPrompt event (scans user input before agent sees it)
11
11
  * - Cline: PreToolUse executable script
12
12
  * - Windsurf: pre_write_code event
13
13
  *
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Hook events:
9
9
  * - Claude Code: PreToolUse matcher (all tools)
10
- * - Cursor: beforeFileEdit event
10
+ * - Cursor: beforeSubmitPrompt event (scans user input before agent sees it)
11
11
  * - Cline: PreToolUse executable script
12
12
  * - Windsurf: pre_write_code event
13
13
  *
@@ -61,7 +61,7 @@ function generateCursorDLPHooks(checkerCommand) {
61
61
  const hooks = {
62
62
  version: 1,
63
63
  hooks: {
64
- beforeFileEdit: [
64
+ beforeSubmitPrompt: [
65
65
  {
66
66
  command: `${checkerCommand} --mode dlp --stdin`,
67
67
  }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { RetryLoopBreakerGate } from './gates/retry-loop-breaker.js';
9
9
  export { SideEffectAnalysisGate } from './gates/side-effect-analysis/index.js';
10
10
  export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js';
11
11
  export * from './utils/logger.js';
12
+ export { FileScanner } from './utils/scanner.js';
12
13
  export * from './services/score-history.js';
13
14
  export * from './hooks/index.js';
14
15
  export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey } from './settings.js';
@@ -27,3 +28,5 @@ export { generateTemporalDriftReport, formatDriftSummary } from './services/temp
27
28
  export type { TemporalDriftReport, ProvenanceStream, MonthlyBucket, WeeklyBucket, DriftDirection } from './services/temporal-drift.js';
28
29
  export { getProvenanceTrends, getQualityTrend } from './services/adaptive-thresholds.js';
29
30
  export type { ProvenanceTrends, ProvenanceRunData } from './services/adaptive-thresholds.js';
31
+ export { IncrementalCache } from './services/incremental-cache.js';
32
+ export type { IncrementalResult } from './services/incremental-cache.js';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { RetryLoopBreakerGate } from './gates/retry-loop-breaker.js';
9
9
  export { SideEffectAnalysisGate } from './gates/side-effect-analysis/index.js';
10
10
  export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js';
11
11
  export * from './utils/logger.js';
12
+ export { FileScanner } from './utils/scanner.js';
12
13
  export * from './services/score-history.js';
13
14
  export * from './hooks/index.js';
14
15
  // Settings Module (Global user config at ~/.rigour/settings.json)
@@ -27,6 +28,8 @@ export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './stor
27
28
  export { generateTemporalDriftReport, formatDriftSummary } from './services/temporal-drift.js';
28
29
  // Adaptive Thresholds (v5 — Z-score + per-provenance trends)
29
30
  export { getProvenanceTrends, getQualityTrend } from './services/adaptive-thresholds.js';
31
+ // Incremental Cache (cross-run file change detection)
32
+ export { IncrementalCache } from './services/incremental-cache.js';
30
33
  // Pattern Index is intentionally NOT exported here to prevent
31
34
  // native dependency issues (sharp/transformers) from leaking into
32
35
  // non-AI parts of the system.
@@ -19,7 +19,7 @@ export function createProvider(options) {
19
19
  }
20
20
  // Default: local sidecar
21
21
  // deep = Qwen2.5-Coder-1.5B (full power, company-hosted)
22
- // lite = Qwen3.5-0.8B (lightweight, default CLI sidecar)
22
+ // lite = Qwen2.5-Coder-0.5B (lightweight, default CLI sidecar)
23
23
  const tier = options.pro ? 'deep' : 'lite';
24
24
  return new SidecarProvider(tier);
25
25
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * IncrementalCache — Cross-run file change detection.
3
+ *
4
+ * Stores file metadata (mtime + size) in .rigour/scan-cache.json.
5
+ * On subsequent runs, compares against current state to detect changes.
6
+ * If zero files changed, the cached report can be reused instantly.
7
+ *
8
+ * This turns repeated scans from O(files × gates) → O(files) stat calls.
9
+ * Demo moment: "Second scan finishes in 50ms because nothing changed."
10
+ */
11
+ import type { Report } from '../types/index.js';
12
+ export interface IncrementalResult {
13
+ /** true = all files unchanged, report is valid */
14
+ hit: boolean;
15
+ /** Cached report (only if hit=true) */
16
+ report?: Report;
17
+ /** Files that changed since last scan (only if hit=false) */
18
+ changedFiles?: string[];
19
+ /** Total files checked */
20
+ totalFiles: number;
21
+ /** Time spent on cache check (ms) */
22
+ checkMs: number;
23
+ }
24
+ export declare class IncrementalCache {
25
+ private cwd;
26
+ private cachePath;
27
+ constructor(cwd: string);
28
+ /**
29
+ * Check if files have changed since last scan.
30
+ * Returns { hit: true, report } if nothing changed.
31
+ * Returns { hit: false, changedFiles } otherwise.
32
+ */
33
+ check(currentFiles: string[], configStr: string): Promise<IncrementalResult>;
34
+ /**
35
+ * Save current scan results for next incremental check.
36
+ */
37
+ save(files: string[], configStr: string, report: Report): Promise<void>;
38
+ /**
39
+ * Invalidate the cache (e.g., for --no-cache).
40
+ */
41
+ invalidate(): Promise<void>;
42
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * IncrementalCache — Cross-run file change detection.
3
+ *
4
+ * Stores file metadata (mtime + size) in .rigour/scan-cache.json.
5
+ * On subsequent runs, compares against current state to detect changes.
6
+ * If zero files changed, the cached report can be reused instantly.
7
+ *
8
+ * This turns repeated scans from O(files × gates) → O(files) stat calls.
9
+ * Demo moment: "Second scan finishes in 50ms because nothing changed."
10
+ */
11
+ import fs from 'fs-extra';
12
+ import path from 'path';
13
+ /**
14
+ * Simple string hash for config diffing (not cryptographic).
15
+ */
16
+ function quickHash(str) {
17
+ let hash = 0;
18
+ for (let i = 0; i < str.length; i++) {
19
+ const char = str.charCodeAt(i);
20
+ hash = ((hash << 5) - hash) + char;
21
+ hash |= 0; // Convert to 32-bit integer
22
+ }
23
+ return hash.toString(36);
24
+ }
25
+ export class IncrementalCache {
26
+ cwd;
27
+ cachePath;
28
+ constructor(cwd) {
29
+ this.cwd = cwd;
30
+ this.cachePath = path.join(cwd, '.rigour', 'scan-cache.json');
31
+ }
32
+ /**
33
+ * Check if files have changed since last scan.
34
+ * Returns { hit: true, report } if nothing changed.
35
+ * Returns { hit: false, changedFiles } otherwise.
36
+ */
37
+ async check(currentFiles, configStr) {
38
+ const start = Date.now();
39
+ // Load previous cache
40
+ let cache = null;
41
+ try {
42
+ if (await fs.pathExists(this.cachePath)) {
43
+ cache = await fs.readJson(this.cachePath);
44
+ }
45
+ }
46
+ catch {
47
+ // Corrupted cache — treat as miss
48
+ cache = null;
49
+ }
50
+ // No cache or version mismatch → full scan
51
+ if (!cache || cache.version !== 2) {
52
+ return { hit: false, totalFiles: currentFiles.length, checkMs: Date.now() - start };
53
+ }
54
+ // Config changed → full scan (gates may have changed)
55
+ const currentConfigHash = quickHash(configStr);
56
+ if (cache.configHash !== currentConfigHash) {
57
+ return { hit: false, totalFiles: currentFiles.length, checkMs: Date.now() - start };
58
+ }
59
+ // Check for file changes: added, removed, or modified
60
+ const previousFiles = new Set(Object.keys(cache.files));
61
+ const currentSet = new Set(currentFiles);
62
+ const changedFiles = [];
63
+ // Stat all current files in parallel (batched for OS fd limits)
64
+ const BATCH = 64;
65
+ const statMap = new Map();
66
+ for (let i = 0; i < currentFiles.length; i += BATCH) {
67
+ const batch = currentFiles.slice(i, i + BATCH);
68
+ const results = await Promise.allSettled(batch.map(async (file) => {
69
+ const fullPath = path.join(this.cwd, file);
70
+ const stat = await fs.stat(fullPath);
71
+ return { file, mtimeMs: stat.mtimeMs, size: stat.size };
72
+ }));
73
+ for (const result of results) {
74
+ if (result.status === 'fulfilled') {
75
+ statMap.set(result.value.file, {
76
+ mtimeMs: result.value.mtimeMs,
77
+ size: result.value.size,
78
+ });
79
+ }
80
+ }
81
+ }
82
+ // Detect changes
83
+ for (const file of currentFiles) {
84
+ const current = statMap.get(file);
85
+ if (!current)
86
+ continue; // couldn't stat — consider changed
87
+ const prev = cache.files[file];
88
+ if (!prev) {
89
+ // New file
90
+ changedFiles.push(file);
91
+ }
92
+ else if (current.mtimeMs !== prev.mtimeMs || current.size !== prev.size) {
93
+ // Modified file
94
+ changedFiles.push(file);
95
+ }
96
+ }
97
+ // Check for removed files
98
+ for (const prevFile of previousFiles) {
99
+ if (!currentSet.has(prevFile)) {
100
+ changedFiles.push(prevFile); // Removed file counts as "changed"
101
+ }
102
+ }
103
+ const checkMs = Date.now() - start;
104
+ if (changedFiles.length === 0) {
105
+ // Cache hit! Reuse the report but update the timestamp in report
106
+ const cachedReport = cache.report;
107
+ cachedReport.stats.duration_ms = checkMs;
108
+ cachedReport.stats.cached = true;
109
+ return { hit: true, report: cachedReport, totalFiles: currentFiles.length, checkMs };
110
+ }
111
+ return { hit: false, changedFiles, totalFiles: currentFiles.length, checkMs };
112
+ }
113
+ /**
114
+ * Save current scan results for next incremental check.
115
+ */
116
+ async save(files, configStr, report) {
117
+ // Stat all files for the cache
118
+ const fileEntries = {};
119
+ const BATCH = 64;
120
+ for (let i = 0; i < files.length; i += BATCH) {
121
+ const batch = files.slice(i, i + BATCH);
122
+ const results = await Promise.allSettled(batch.map(async (file) => {
123
+ const fullPath = path.join(this.cwd, file);
124
+ const stat = await fs.stat(fullPath);
125
+ return { file, mtimeMs: stat.mtimeMs, size: stat.size };
126
+ }));
127
+ for (const result of results) {
128
+ if (result.status === 'fulfilled') {
129
+ fileEntries[result.value.file] = {
130
+ mtimeMs: result.value.mtimeMs,
131
+ size: result.value.size,
132
+ };
133
+ }
134
+ }
135
+ }
136
+ const cache = {
137
+ version: 2,
138
+ timestamp: new Date().toISOString(),
139
+ configHash: quickHash(configStr),
140
+ files: fileEntries,
141
+ report,
142
+ };
143
+ await fs.ensureDir(path.dirname(this.cachePath));
144
+ await fs.writeJson(this.cachePath, cache);
145
+ }
146
+ /**
147
+ * Invalidate the cache (e.g., for --no-cache).
148
+ */
149
+ async invalidate() {
150
+ try {
151
+ await fs.remove(this.cachePath);
152
+ }
153
+ catch {
154
+ // Ignore
155
+ }
156
+ }
157
+ }
@@ -86,13 +86,21 @@ export function getScoreTrend(cwd) {
86
86
  }
87
87
  const previousAvg = previousScores.reduce((a, b) => a + b, 0) / previousScores.length;
88
88
  const delta = recentAvg - previousAvg;
89
+ // If all recent scores are the same (e.g. all 0 or all 100), trend is stable
90
+ const allSame = recentScores.every(s => s === recentScores[0]);
89
91
  let direction;
90
- if (delta > 3)
92
+ if (allSame && previousScores.length > 0 && previousScores.every(s => s === recentScores[0])) {
93
+ direction = 'stable';
94
+ }
95
+ else if (delta > 3) {
91
96
  direction = 'improving';
92
- else if (delta < -3)
97
+ }
98
+ else if (delta < -3) {
93
99
  direction = 'degrading';
94
- else
100
+ }
101
+ else {
95
102
  direction = 'stable';
103
+ }
96
104
  return {
97
105
  direction,
98
106
  delta: Math.round(delta * 10) / 10,
@@ -79,6 +79,7 @@ function toWeekKey(timestamp) {
79
79
  return monday.toISOString().split('T')[0];
80
80
  }
81
81
  // ─── Main Engine ────────────────────────────────────────────────────
82
+ let _sqliteWarningShown = false;
82
83
  /**
83
84
  * Generate a complete temporal drift report for a project.
84
85
  *
@@ -91,7 +92,10 @@ function toWeekKey(timestamp) {
91
92
  export async function generateTemporalDriftReport(cwd, maxScans = 200) {
92
93
  const db = await openDatabase();
93
94
  if (!db) {
94
- Logger.warn('Temporal drift: SQLite not available');
95
+ if (!_sqliteWarningShown) {
96
+ _sqliteWarningShown = true;
97
+ Logger.warn('Temporal drift: SQLite not available — install sqlite3 to enable scan history');
98
+ }
95
99
  return null;
96
100
  }
97
101
  const repo = path.basename(cwd);
@@ -19,13 +19,27 @@ function loadSqlite3() {
19
19
  if (_resolved)
20
20
  return sqlite3Module;
21
21
  _resolved = true;
22
- try {
23
- const require = createRequire(import.meta.url);
24
- sqlite3Module = require('sqlite3');
25
- }
26
- catch {
27
- sqlite3Module = null;
22
+ // Try multiple resolution paths:
23
+ // 1. Relative to this package (works when sqlite3 is installed in monorepo)
24
+ // 2. Relative to cwd (works when user installs sqlite3 in their project)
25
+ // 3. Relative to global node_modules (works for global installs)
26
+ const searchPaths = [
27
+ import.meta.url,
28
+ `file://${process.cwd()}/package.json`,
29
+ `file://${path.join(os.homedir(), '.rigour', 'package.json')}`,
30
+ ];
31
+ for (const base of searchPaths) {
32
+ try {
33
+ const req = createRequire(base);
34
+ sqlite3Module = req('sqlite3');
35
+ if (sqlite3Module)
36
+ return sqlite3Module;
37
+ }
38
+ catch {
39
+ // Try next path
40
+ }
28
41
  }
42
+ sqlite3Module = null;
29
43
  return sqlite3Module;
30
44
  }
31
45
  const RIGOUR_DIR = path.join(os.homedir(), '.rigour');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "5.1.0",
3
+ "version": "5.1.2",
4
4
  "description": "Deterministic quality gate engine for AI-generated code. AST analysis, drift detection, and Fix Packet generation across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rigour.run",
@@ -59,11 +59,11 @@
59
59
  "@xenova/transformers": "^2.17.2",
60
60
  "sqlite3": "^5.1.7",
61
61
  "openai": "^4.104.0",
62
- "@rigour-labs/brain-darwin-arm64": "5.1.0",
63
- "@rigour-labs/brain-darwin-x64": "5.1.0",
64
- "@rigour-labs/brain-win-x64": "5.1.0",
65
- "@rigour-labs/brain-linux-arm64": "5.1.0",
66
- "@rigour-labs/brain-linux-x64": "5.1.0"
62
+ "@rigour-labs/brain-darwin-arm64": "5.1.2",
63
+ "@rigour-labs/brain-linux-x64": "5.1.2",
64
+ "@rigour-labs/brain-linux-arm64": "5.1.2",
65
+ "@rigour-labs/brain-win-x64": "5.1.2",
66
+ "@rigour-labs/brain-darwin-x64": "5.1.2"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/fs-extra": "^11.0.4",