@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 +15 -11
- package/dist/gates/context-window-artifacts.js +13 -7
- package/dist/gates/deep-analysis.js +12 -1
- package/dist/gates/environment.js +1 -1
- package/dist/gates/inconsistent-error-handling.js +14 -7
- package/dist/gates/promise-safety.js +9 -11
- package/dist/gates/runner.js +20 -6
- package/dist/gates/runner.test.js +1 -1
- package/dist/gates/side-effect-analysis/index.js +9 -10
- package/dist/gates/test-quality.js +25 -14
- package/dist/hooks/dlp-templates.d.ts +1 -1
- package/dist/hooks/dlp-templates.js +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/inference/index.js +1 -1
- package/dist/services/incremental-cache.d.ts +42 -0
- package/dist/services/incremental-cache.js +157 -0
- package/dist/services/score-history.js +11 -3
- package/dist/services/temporal-drift.js +5 -1
- package/dist/storage/db.js +20 -6
- package/package.json +6 -6
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
-
|
|
52
|
+
return handler.run({
|
|
50
53
|
cwd: context.cwd,
|
|
51
54
|
file: file,
|
|
52
55
|
content
|
|
53
56
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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']
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
60
|
+
return [];
|
|
61
|
+
const localHandlers = [];
|
|
59
62
|
const errorHandlerFacts = adapter.extractErrorHandlers(content);
|
|
60
63
|
for (const fact of errorHandlerFacts) {
|
|
61
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|
package/dist/gates/runner.js
CHANGED
|
@@ -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
|
-
|
|
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' : '
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
79
|
-
const
|
|
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,
|
|
84
|
+
this.checkJSTestQuality(content, file, localIssues);
|
|
88
85
|
break;
|
|
89
86
|
case 'python':
|
|
90
|
-
this.checkPythonTestQuality(content, file,
|
|
87
|
+
this.checkPythonTestQuality(content, file, localIssues);
|
|
91
88
|
break;
|
|
92
89
|
case 'go':
|
|
93
|
-
checkGoTestQuality(content, file,
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
package/dist/inference/index.js
CHANGED
|
@@ -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 =
|
|
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 (
|
|
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
|
-
|
|
97
|
+
}
|
|
98
|
+
else if (delta < -3) {
|
|
93
99
|
direction = 'degrading';
|
|
94
|
-
|
|
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
|
-
|
|
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);
|
package/dist/storage/db.js
CHANGED
|
@@ -19,13 +19,27 @@ function loadSqlite3() {
|
|
|
19
19
|
if (_resolved)
|
|
20
20
|
return sqlite3Module;
|
|
21
21
|
_resolved = true;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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.
|
|
63
|
-
"@rigour-labs/brain-
|
|
64
|
-
"@rigour-labs/brain-
|
|
65
|
-
"@rigour-labs/brain-
|
|
66
|
-
"@rigour-labs/brain-
|
|
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",
|