@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.
- package/dist/deep/code-context.d.ts +28 -0
- package/dist/deep/code-context.js +93 -0
- package/dist/deep/code-context.test.d.ts +1 -0
- package/dist/deep/code-context.test.js +48 -0
- package/dist/deep/code-pass.d.ts +15 -0
- package/dist/deep/code-pass.js +44 -0
- package/dist/deep/code-review-prompt.d.ts +17 -0
- package/dist/deep/code-review-prompt.js +30 -0
- package/dist/deep/code-review-prompt.test.d.ts +1 -0
- package/dist/deep/code-review-prompt.test.js +20 -0
- package/dist/deep/code-verifier.d.ts +19 -0
- package/dist/deep/code-verifier.js +48 -0
- package/dist/deep/code-verifier.test.d.ts +1 -0
- package/dist/deep/code-verifier.test.js +46 -0
- package/dist/deep/fact-extractor.d.ts +7 -1
- package/dist/deep/fact-extractor.js +16 -8
- package/dist/deep/fact-extractor.test.js +34 -1
- package/dist/deep/facts-pass.d.ts +24 -0
- package/dist/deep/facts-pass.js +69 -0
- package/dist/deep/index.d.ts +6 -1
- package/dist/deep/index.js +5 -1
- package/dist/deep/parse-findings.d.ts +12 -0
- package/dist/deep/parse-findings.js +73 -0
- package/dist/deep/parse-findings.test.d.ts +1 -0
- package/dist/deep/parse-findings.test.js +25 -0
- package/dist/deep/prompts.d.ts +1 -1
- package/dist/deep/prompts.js +7 -8
- package/dist/deep/prompts.test.js +15 -0
- package/dist/gates/context.js +7 -0
- package/dist/gates/deep-analysis.d.ts +31 -8
- package/dist/gates/deep-analysis.js +165 -306
- package/dist/gates/deep-analysis.test.d.ts +1 -0
- package/dist/gates/deep-analysis.test.js +86 -0
- package/dist/gates/deep-runner.d.ts +15 -0
- package/dist/gates/deep-runner.js +65 -0
- package/dist/gates/dependency.js +4 -0
- package/dist/gates/hallucinated-imports/index.js +13 -9
- package/dist/gates/hallucinated-imports.test.js +10 -0
- package/dist/gates/js-style-context.d.ts +3 -0
- package/dist/gates/js-style-context.js +65 -0
- package/dist/gates/js-style-context.test.d.ts +1 -0
- package/dist/gates/js-style-context.test.js +35 -0
- package/dist/gates/logic-drift-git-base.d.ts +7 -0
- package/dist/gates/logic-drift-git-base.js +41 -0
- package/dist/gates/logic-drift.d.ts +3 -3
- package/dist/gates/logic-drift.js +39 -7
- package/dist/gates/logic-drift.test.d.ts +1 -0
- package/dist/gates/logic-drift.test.js +45 -0
- package/dist/gates/runner.js +5 -40
- package/dist/gates/runner.test.js +37 -25
- package/dist/gates/scoped-gates.test.d.ts +1 -0
- package/dist/gates/scoped-gates.test.js +52 -0
- package/dist/gates/security-command-execution.d.ts +5 -0
- package/dist/gates/security-command-execution.js +123 -0
- package/dist/gates/security-patterns-data.js +1 -17
- package/dist/gates/security-patterns.d.ts +0 -5
- package/dist/gates/security-patterns.js +11 -19
- package/dist/gates/security-patterns.test.js +37 -2
- package/dist/gates/style-drift.d.ts +4 -4
- package/dist/gates/style-drift.js +15 -16
- package/dist/gates/style-drift.test.d.ts +1 -0
- package/dist/gates/style-drift.test.js +26 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/inference/http-download.d.ts +22 -0
- package/dist/inference/http-download.js +132 -0
- package/dist/inference/http-download.test.d.ts +1 -0
- package/dist/inference/http-download.test.js +109 -0
- package/dist/inference/index.d.ts +5 -1
- package/dist/inference/index.js +2 -1
- package/dist/inference/llama-engine.d.ts +27 -0
- package/dist/inference/llama-engine.js +152 -0
- package/dist/inference/llama-engine.test.d.ts +1 -0
- package/dist/inference/llama-engine.test.js +51 -0
- package/dist/inference/llama-process.d.ts +72 -0
- package/dist/inference/llama-process.js +126 -0
- package/dist/inference/llama-process.test.d.ts +1 -0
- package/dist/inference/llama-process.test.js +61 -0
- package/dist/inference/model-download.test.d.ts +1 -0
- package/dist/inference/model-download.test.js +73 -0
- package/dist/inference/model-manager.d.ts +14 -1
- package/dist/inference/model-manager.js +65 -80
- package/dist/inference/sidecar-provider.d.ts +19 -10
- package/dist/inference/sidecar-provider.js +115 -343
- package/dist/templates/universal-config.js +0 -2
- package/dist/types/index.d.ts +58 -14
- package/dist/types/index.js +9 -2
- package/dist/utils/deep-status.d.ts +7 -0
- package/dist/utils/deep-status.js +11 -0
- package/dist/utils/diff.d.ts +3 -0
- package/dist/utils/diff.js +52 -0
- package/dist/utils/diff.test.d.ts +1 -0
- package/dist/utils/diff.test.js +38 -0
- package/dist/utils/scanner.js +13 -2
- package/dist/utils/scanner.test.js +22 -8
- package/dist/utils/scope.d.ts +15 -0
- package/dist/utils/scope.js +51 -0
- package/dist/utils/scope.test.d.ts +1 -0
- package/dist/utils/scope.test.js +38 -0
- package/package.json +6 -6
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep analysis as the gate runner reports it: summary status, report stats
|
|
3
|
+
* and failures, derived from the deep gate's outcome.
|
|
4
|
+
*/
|
|
5
|
+
import { DeepAnalysisGate } from './deep-analysis.js';
|
|
6
|
+
import { Logger } from '../utils/logger.js';
|
|
7
|
+
/**
|
|
8
|
+
* Run the deep gate and turn its outcome into summary, stats and failures.
|
|
9
|
+
* A deep run that could not analyze anything is ERROR with a visible
|
|
10
|
+
* failure, never a clean PASS.
|
|
11
|
+
*/
|
|
12
|
+
export async function runDeepAnalysis(config, context, deepOptions) {
|
|
13
|
+
const start = Date.now();
|
|
14
|
+
const deepGate = new DeepAnalysisGate({
|
|
15
|
+
options: deepOptions,
|
|
16
|
+
checks: config.gates.deep?.checks,
|
|
17
|
+
threads: config.gates.deep?.threads,
|
|
18
|
+
maxTokens: config.gates.deep?.max_tokens,
|
|
19
|
+
temperature: config.gates.deep?.temperature,
|
|
20
|
+
timeoutMs: config.gates.deep?.timeout_ms,
|
|
21
|
+
onProgress: deepOptions.onProgress,
|
|
22
|
+
});
|
|
23
|
+
let findings = [];
|
|
24
|
+
let thrown;
|
|
25
|
+
try {
|
|
26
|
+
findings = await deepGate.run(context);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
thrown = error?.message ?? String(error);
|
|
30
|
+
Logger.error(`Deep analysis failed: ${thrown}`);
|
|
31
|
+
}
|
|
32
|
+
// A gate that threw produced nothing: never report its last outcome as ok.
|
|
33
|
+
const outcome = thrown ? { ...deepGate.getOutcome(), status: 'error', error: thrown } : deepGate.getOutcome();
|
|
34
|
+
const isLocal = !deepOptions.apiKey || (deepOptions.provider || '').toLowerCase() === 'local';
|
|
35
|
+
const stats = {
|
|
36
|
+
enabled: true,
|
|
37
|
+
status: outcome.status,
|
|
38
|
+
mode: outcome.mode,
|
|
39
|
+
tier: isLocal ? (deepOptions.pro ? 'deep' : 'lite') : 'cloud',
|
|
40
|
+
model: outcome.model,
|
|
41
|
+
model_fallback: outcome.modelFallback,
|
|
42
|
+
total_ms: Date.now() - start,
|
|
43
|
+
files_analyzed: outcome.filesAnalyzed,
|
|
44
|
+
chunks_total: outcome.chunksTotal,
|
|
45
|
+
chunks_failed: outcome.chunksFailed,
|
|
46
|
+
error: outcome.error,
|
|
47
|
+
findings_count: findings.length,
|
|
48
|
+
findings_verified: findings.filter(f => f.verified).length,
|
|
49
|
+
};
|
|
50
|
+
if (outcome.status === 'error') {
|
|
51
|
+
return {
|
|
52
|
+
failures: [{
|
|
53
|
+
id: 'deep-analysis',
|
|
54
|
+
title: 'Deep analysis did not run',
|
|
55
|
+
details: outcome.error || 'Deep analysis failed before producing results.',
|
|
56
|
+
severity: 'medium',
|
|
57
|
+
provenance: 'traditional',
|
|
58
|
+
hint: 'Run `rigour doctor`. For local mode, `rigour deep pull` installs the engine and model.',
|
|
59
|
+
}],
|
|
60
|
+
summary: 'ERROR',
|
|
61
|
+
stats,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { failures: findings, summary: findings.length > 0 ? 'FAIL' : 'PASS', stats };
|
|
65
|
+
}
|
package/dist/gates/dependency.js
CHANGED
|
@@ -18,6 +18,7 @@ import path from 'path';
|
|
|
18
18
|
import { Gate } from './base.js';
|
|
19
19
|
import { FileScanner } from '../utils/scanner.js';
|
|
20
20
|
import { Logger } from '../utils/logger.js';
|
|
21
|
+
import { scopeExcludes } from '../utils/scope.js';
|
|
21
22
|
/**
|
|
22
23
|
* Known heavy packages with lighter alternatives.
|
|
23
24
|
* Format: package → "alternative (size comparison)"
|
|
@@ -84,6 +85,9 @@ export class DependencyGate extends Gate {
|
|
|
84
85
|
const depConfig = this.config.gates.dependencies || {};
|
|
85
86
|
const forbidden = depConfig.forbid || [];
|
|
86
87
|
const { cwd } = context;
|
|
88
|
+
// Dependency health belongs to the manifest: a scoped run checks it only with package.json in scope.
|
|
89
|
+
if (await scopeExcludes(context, 'package.json'))
|
|
90
|
+
return [];
|
|
87
91
|
// 1. Scan Node.js (package.json) — forbidden + new checks
|
|
88
92
|
const pkgPath = path.join(cwd, 'package.json');
|
|
89
93
|
if (await fs.pathExists(pkgPath)) {
|
|
@@ -48,21 +48,25 @@ export class HallucinatedImportsGate extends Gate {
|
|
|
48
48
|
const hallucinated = [];
|
|
49
49
|
const defaultPatterns = ['**/*.{ts,js,tsx,jsx,py,go,rb,cs,rs,java,kt}'];
|
|
50
50
|
const scanPatterns = context.patterns || defaultPatterns;
|
|
51
|
+
const ignore = [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
|
|
52
|
+
'**/examples/**', '**/studio-dist/**', '**/.next/**', '**/coverage/**',
|
|
53
|
+
'**/*.test.*', '**/*.spec.*', '**/__tests__/**',
|
|
54
|
+
'**/.venv/**', '**/venv/**', '**/vendor/**', '**/bin/Debug/**', '**/bin/Release/**', '**/obj/**',
|
|
55
|
+
'**/target/debug/**', '**/target/release/**', '**/out/**', '**/.gradle/**', '**/gradle/**'];
|
|
51
56
|
const files = await FileScanner.findFiles({
|
|
52
57
|
cwd: context.cwd,
|
|
53
58
|
patterns: scanPatterns,
|
|
54
|
-
ignore
|
|
55
|
-
'**/examples/**',
|
|
56
|
-
'**/studio-dist/**', '**/.next/**', '**/coverage/**',
|
|
57
|
-
'**/*.test.*', '**/*.spec.*', '**/__tests__/**',
|
|
58
|
-
'**/.venv/**', '**/venv/**', '**/vendor/**', '**/bin/Debug/**', '**/bin/Release/**', '**/obj/**',
|
|
59
|
-
'**/target/debug/**', '**/target/release/**',
|
|
60
|
-
'**/out/**', '**/.gradle/**', '**/gradle/**'],
|
|
59
|
+
ignore,
|
|
61
60
|
});
|
|
62
61
|
const analyzableFiles = files.filter(file => !this.shouldSkipFile(file));
|
|
63
62
|
Logger.info(`Hallucinated Imports: Scanning ${analyzableFiles.length} files`);
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// A scoped review scans changed files, but relative imports may point
|
|
64
|
+
// to unchanged files. Resolve against the project, not the edit set.
|
|
65
|
+
const resolutionFiles = context.patterns
|
|
66
|
+
? await FileScanner.findFiles({ cwd: context.cwd, patterns: defaultPatterns, ignore })
|
|
67
|
+
: files;
|
|
68
|
+
const projectFiles = new Set(resolutionFiles.map(f => f.replace(/\\/g, '/')));
|
|
69
|
+
const allProjectFiles = projectFiles;
|
|
66
70
|
const packageJson = await loadPackageJson(context.cwd);
|
|
67
71
|
const rootDeps = new Set([
|
|
68
72
|
...Object.keys(packageJson?.dependencies || {}),
|
|
@@ -264,6 +264,16 @@ describe('HallucinatedImportsGate — JS/TS Node builtins', () => {
|
|
|
264
264
|
mockReaddirSync.mockReturnValue([]);
|
|
265
265
|
gate = new HallucinatedImportsGate({ enabled: true });
|
|
266
266
|
});
|
|
267
|
+
it('resolves changed-file imports against unchanged project files', async () => {
|
|
268
|
+
FileScanner.findFiles
|
|
269
|
+
.mockResolvedValueOnce(['src/feature.ts'])
|
|
270
|
+
.mockResolvedValueOnce(['src/feature.ts', 'src/types.ts']);
|
|
271
|
+
mockReadFile.mockResolvedValue("import type { Value } from './types.js';\n");
|
|
272
|
+
mockPathExists.mockResolvedValue(false);
|
|
273
|
+
const failures = await gate.run({ ...context, patterns: ['src/feature.ts'] });
|
|
274
|
+
expect(failures).toEqual([]);
|
|
275
|
+
expect(FileScanner.findFiles).toHaveBeenCalledTimes(2);
|
|
276
|
+
});
|
|
267
277
|
it('should NOT flag Node.js built-in modules', async () => {
|
|
268
278
|
const jsContent = `
|
|
269
279
|
import fs from 'fs';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { classifyCasing } from './language-adapters/types.js';
|
|
3
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
|
|
4
|
+
function isNextRoute(file) {
|
|
5
|
+
return /(?:^|\/)app(?:\/[^/]+)*\/route\.[cm]?[jt]sx?$/.test(file.replace(/\\/g, '/'));
|
|
6
|
+
}
|
|
7
|
+
function exported(node) {
|
|
8
|
+
return ts.canHaveModifiers(node)
|
|
9
|
+
&& Boolean(ts.getModifiers(node)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword));
|
|
10
|
+
}
|
|
11
|
+
function containsJsx(node) {
|
|
12
|
+
let found = false;
|
|
13
|
+
const visit = (child) => {
|
|
14
|
+
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child)) {
|
|
15
|
+
found = true;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (!found)
|
|
19
|
+
ts.forEachChild(child, visit);
|
|
20
|
+
};
|
|
21
|
+
ts.forEachChild(node, visit);
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
24
|
+
function isComponent(name, node, file) {
|
|
25
|
+
return /\.[jt]sx$/.test(file) && /^[A-Z]/.test(name) && containsJsx(node);
|
|
26
|
+
}
|
|
27
|
+
function isModuleConstant(declaration) {
|
|
28
|
+
const list = declaration.parent;
|
|
29
|
+
return ts.isVariableDeclarationList(list)
|
|
30
|
+
&& (list.flags & ts.NodeFlags.Const) !== 0
|
|
31
|
+
&& ts.isVariableStatement(list.parent)
|
|
32
|
+
&& ts.isSourceFile(list.parent.parent);
|
|
33
|
+
}
|
|
34
|
+
function isFunctionValue(initializer) {
|
|
35
|
+
return Boolean(initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)));
|
|
36
|
+
}
|
|
37
|
+
/** Extract comparable JS/TS names, excluding framework-defined identifiers. */
|
|
38
|
+
export function extractComparableJsNames(content, file) {
|
|
39
|
+
const kind = /\.[jt]sx$/.test(file) ? ts.ScriptKind.TSX : /\.[cm]?ts$/.test(file) ? ts.ScriptKind.TS : ts.ScriptKind.JS;
|
|
40
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
|
|
41
|
+
const patterns = [];
|
|
42
|
+
const route = isNextRoute(file);
|
|
43
|
+
const visit = (node) => {
|
|
44
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
45
|
+
const name = node.name.text;
|
|
46
|
+
if (!(route && exported(node) && HTTP_METHODS.has(name)) && !isComponent(name, node, file)) {
|
|
47
|
+
patterns.push({ name, kind: 'function', convention: classifyCasing(name) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
51
|
+
const name = node.name.text;
|
|
52
|
+
const fn = isFunctionValue(node.initializer);
|
|
53
|
+
const statement = node.parent.parent;
|
|
54
|
+
const routeExport = route && HTTP_METHODS.has(name) && ts.isVariableStatement(statement) && exported(statement);
|
|
55
|
+
const component = fn && isComponent(name, node.initializer, file);
|
|
56
|
+
const conventionalConstant = !fn && isModuleConstant(node) && classifyCasing(name) === 'SCREAMING_SNAKE';
|
|
57
|
+
if (!routeExport && !component && !conventionalConstant) {
|
|
58
|
+
patterns.push({ name, kind: fn ? 'function' : 'variable', convention: classifyCasing(name) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
ts.forEachChild(node, visit);
|
|
62
|
+
};
|
|
63
|
+
visit(source);
|
|
64
|
+
return patterns;
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractComparableJsNames } from './js-style-context.js';
|
|
3
|
+
describe('context-aware JS naming', () => {
|
|
4
|
+
it('excludes exported Next route methods only inside app route files', () => {
|
|
5
|
+
const code = `
|
|
6
|
+
export async function GET() { return Response.json({}); }
|
|
7
|
+
export const POST = async () => Response.json({});
|
|
8
|
+
function GET_HELPER() { return 1; }
|
|
9
|
+
`;
|
|
10
|
+
expect(extractComparableJsNames(code, 'src/app/api/items/route.ts').map(item => item.name))
|
|
11
|
+
.toEqual(['GET_HELPER']);
|
|
12
|
+
expect(extractComparableJsNames(code, 'src/lib/route.ts').map(item => item.name))
|
|
13
|
+
.toEqual(['GET', 'POST', 'GET_HELPER']);
|
|
14
|
+
});
|
|
15
|
+
it('excludes JSX-returning components but keeps ordinary PascalCase functions', () => {
|
|
16
|
+
const code = `
|
|
17
|
+
export function StepReview() { return <section />; }
|
|
18
|
+
const WizardField = () => <input />;
|
|
19
|
+
function ParseValue() { return 1; }
|
|
20
|
+
`;
|
|
21
|
+
expect(extractComparableJsNames(code, 'src/components/WizardField.tsx').map(item => item.name))
|
|
22
|
+
.toEqual(['ParseValue']);
|
|
23
|
+
});
|
|
24
|
+
it('excludes module constants but keeps unconventional local variables', () => {
|
|
25
|
+
const code = `
|
|
26
|
+
const EMAIL_TEMPLATE = 'hello';
|
|
27
|
+
function sendMessage() {
|
|
28
|
+
const LOCAL_VALUE = 1;
|
|
29
|
+
return LOCAL_VALUE;
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
expect(extractComparableJsNames(code, 'src/lib/template.ts').map(item => item.name))
|
|
33
|
+
.toEqual(['sendMessage', 'LOCAL_VALUE']);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface GitLogicBase {
|
|
2
|
+
changedFiles: Set<string>;
|
|
3
|
+
readAtBase: (file: string) => string | null;
|
|
4
|
+
}
|
|
5
|
+
export declare function isGitWorktree(cwd: string): boolean;
|
|
6
|
+
/** Use a fixed commit for the entire scan; never move the baseline on read. */
|
|
7
|
+
export declare function resolveGitLogicBase(cwd: string): GitLogicBase | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
export function isGitWorktree(cwd) {
|
|
3
|
+
return git(cwd, ['rev-parse', '--is-inside-work-tree'])?.trim() === 'true';
|
|
4
|
+
}
|
|
5
|
+
function git(cwd, args) {
|
|
6
|
+
try {
|
|
7
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Use a fixed commit for the entire scan; never move the baseline on read. */
|
|
14
|
+
export function resolveGitLogicBase(cwd) {
|
|
15
|
+
// Git reports the path relative to the worktree root without relying on
|
|
16
|
+
// platform-specific path casing or Windows short-name expansion.
|
|
17
|
+
const prefix = git(cwd, ['rev-parse', '--show-prefix']);
|
|
18
|
+
if (prefix === null || prefix.trim())
|
|
19
|
+
return null;
|
|
20
|
+
const currentBranch = git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD'])?.trim();
|
|
21
|
+
const candidates = [
|
|
22
|
+
process.env.GITHUB_BASE_REF ? `refs/remotes/origin/${process.env.GITHUB_BASE_REF}` : '',
|
|
23
|
+
'refs/remotes/origin/main', 'refs/heads/main',
|
|
24
|
+
'refs/remotes/origin/master', 'refs/heads/master',
|
|
25
|
+
].filter(Boolean);
|
|
26
|
+
const mainRef = candidates.find(ref => git(cwd, ['rev-parse', '--verify', '--quiet', ref])?.trim());
|
|
27
|
+
if (!mainRef)
|
|
28
|
+
return null;
|
|
29
|
+
const base = currentBranch === 'main' || currentBranch === 'master'
|
|
30
|
+
? git(cwd, ['rev-parse', 'HEAD'])?.trim()
|
|
31
|
+
: git(cwd, ['merge-base', 'HEAD', mainRef])?.trim();
|
|
32
|
+
if (!base)
|
|
33
|
+
return null;
|
|
34
|
+
const diff = git(cwd, ['diff', '--name-only', '-z', '--diff-filter=ACMR', base, '--']);
|
|
35
|
+
if (diff === null)
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
changedFiles: new Set(diff.split('\0').filter(Boolean)),
|
|
39
|
+
readAtBase: file => git(cwd, ['show', `${base}:${file}`]),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* - Tests might still pass (if they don't cover edge cases)
|
|
13
13
|
* - The change looks intentional ("AI refactored the function")
|
|
14
14
|
*
|
|
15
|
-
* Strategy:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Strategy: Compare changed functions against the Git main merge base.
|
|
16
|
+
* Repeated scans of the same change must report the same review signals.
|
|
17
|
+
* Non-Git projects retain the legacy local baseline behavior.
|
|
18
18
|
*/
|
|
19
19
|
import { Gate, GateContext } from './base.js';
|
|
20
20
|
import { Failure, Provenance } from '../types/index.js';
|
|
@@ -12,15 +12,17 @@
|
|
|
12
12
|
* - Tests might still pass (if they don't cover edge cases)
|
|
13
13
|
* - The change looks intentional ("AI refactored the function")
|
|
14
14
|
*
|
|
15
|
-
* Strategy:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Strategy: Compare changed functions against the Git main merge base.
|
|
16
|
+
* Repeated scans of the same change must report the same review signals.
|
|
17
|
+
* Non-Git projects retain the legacy local baseline behavior.
|
|
18
18
|
*/
|
|
19
19
|
import { Gate } from './base.js';
|
|
20
20
|
import { FileScanner } from '../utils/scanner.js';
|
|
21
21
|
import { Logger } from '../utils/logger.js';
|
|
22
22
|
import { languageAdapters } from './language-adapters/index.js';
|
|
23
23
|
import { extractCallSequence, isDangerousMutation } from './logic-drift-extractors.js';
|
|
24
|
+
import { isGitWorktree, resolveGitLogicBase } from './logic-drift-git-base.js';
|
|
25
|
+
import { isScoped } from '../utils/scope.js';
|
|
24
26
|
import fs from 'fs-extra';
|
|
25
27
|
import path from 'path';
|
|
26
28
|
import crypto from 'crypto';
|
|
@@ -50,16 +52,35 @@ export class LogicDriftGate extends Gate {
|
|
|
50
52
|
});
|
|
51
53
|
if (files.length === 0)
|
|
52
54
|
return [];
|
|
55
|
+
// In Git worktrees, compare only files changed from an immutable base.
|
|
56
|
+
const gitBase = resolveGitLogicBase(context.cwd);
|
|
57
|
+
if (!gitBase && isGitWorktree(context.cwd)) {
|
|
58
|
+
Logger.info('Logic Drift: no main reference available; comparison unavailable');
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const scanFiles = gitBase ? files.filter(file => gitBase.changedFiles.has(file)) : files;
|
|
62
|
+
if (gitBase && scanFiles.length === 0)
|
|
63
|
+
return [];
|
|
53
64
|
// Extract current function baselines
|
|
54
65
|
const currentFunctions = [];
|
|
55
|
-
const contents = await FileScanner.readFiles(context.cwd,
|
|
66
|
+
const contents = await FileScanner.readFiles(context.cwd, scanFiles, context.fileCache);
|
|
56
67
|
for (const [file, content] of contents) {
|
|
57
68
|
const extracted = this.extractFunctionBaselines(content, file);
|
|
58
69
|
currentFunctions.push(...extracted);
|
|
59
70
|
}
|
|
60
|
-
//
|
|
71
|
+
// Git is authoritative when present; old local snapshots are fallback
|
|
72
|
+
// only for projects without an available Git main reference.
|
|
61
73
|
let previousBaseline = null;
|
|
62
|
-
if (
|
|
74
|
+
if (gitBase) {
|
|
75
|
+
const functions = [];
|
|
76
|
+
for (const file of scanFiles) {
|
|
77
|
+
const content = gitBase.readAtBase(file);
|
|
78
|
+
if (content !== null)
|
|
79
|
+
functions.push(...this.extractFunctionBaselines(content, file));
|
|
80
|
+
}
|
|
81
|
+
previousBaseline = { functions, createdAt: '', lastUpdated: '', scanCount: 0 };
|
|
82
|
+
}
|
|
83
|
+
else if (await fs.pathExists(baselinePath)) {
|
|
63
84
|
try {
|
|
64
85
|
const raw = await fs.readJson(baselinePath);
|
|
65
86
|
// Validate baseline has required structure
|
|
@@ -74,6 +95,11 @@ export class LogicDriftGate extends Gate {
|
|
|
74
95
|
Logger.debug('Failed to load logic baseline');
|
|
75
96
|
}
|
|
76
97
|
}
|
|
98
|
+
// A scoped run sees only some files: compare against the baseline but
|
|
99
|
+
// never create or overwrite it, or the rest of the repo drops out of it.
|
|
100
|
+
const scoped = isScoped(context.patterns);
|
|
101
|
+
if (!previousBaseline && scoped)
|
|
102
|
+
return [];
|
|
77
103
|
if (!previousBaseline) {
|
|
78
104
|
// First scan: save baseline, no comparisons yet
|
|
79
105
|
const baseline = {
|
|
@@ -131,7 +157,8 @@ export class LogicDriftGate extends Gate {
|
|
|
131
157
|
lastUpdated: new Date().toISOString(),
|
|
132
158
|
scanCount: previousBaseline.scanCount + 1,
|
|
133
159
|
};
|
|
134
|
-
|
|
160
|
+
if (!gitBase && !scoped)
|
|
161
|
+
await fs.writeJson(baselinePath, updatedBaseline, { spaces: 2 });
|
|
135
162
|
if (failures.length > 0) {
|
|
136
163
|
Logger.info(`Logic Drift: Found ${failures.length} logic mutations`);
|
|
137
164
|
}
|
|
@@ -146,7 +173,12 @@ export class LogicDriftGate extends Gate {
|
|
|
146
173
|
}
|
|
147
174
|
const skipKeywords = new Set(['if', 'for', 'while', 'switch', 'catch', 'constructor', 'else', 'elif', 'elsif', 'rescue']);
|
|
148
175
|
const functions = adapter.extractFunctions(content, file);
|
|
176
|
+
const seen = new Set();
|
|
149
177
|
for (const fn of functions) {
|
|
178
|
+
const identity = `${fn.name}:${fn.startLine}`;
|
|
179
|
+
if (seen.has(identity))
|
|
180
|
+
continue;
|
|
181
|
+
seen.add(identity);
|
|
150
182
|
// Skip control flow keywords
|
|
151
183
|
if (skipKeywords.has(fn.name))
|
|
152
184
|
continue;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { LogicDriftGate } from './logic-drift.js';
|
|
7
|
+
describe('LogicDriftGate Git baseline', () => {
|
|
8
|
+
let cwd;
|
|
9
|
+
const git = (...args) => execFileSync('git', args, { cwd, stdio: 'pipe' });
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'logic-drift-'));
|
|
12
|
+
git('init', '-q', '-b', 'main');
|
|
13
|
+
git('config', 'user.name', 'Rigour Test');
|
|
14
|
+
git('config', 'user.email', 'test@example.invalid');
|
|
15
|
+
fs.mkdirSync(path.join(cwd, 'src'));
|
|
16
|
+
fs.writeFileSync(path.join(cwd, 'src/rules.ts'), 'export function eligible(score: number) { return score >= 10; }\n');
|
|
17
|
+
git('add', '.');
|
|
18
|
+
git('commit', '-qm', 'base');
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
|
|
21
|
+
it('reports a branch mutation consistently and clears it after merge', async () => {
|
|
22
|
+
const gate = new LogicDriftGate();
|
|
23
|
+
expect(await gate.run({ cwd })).toEqual([]);
|
|
24
|
+
git('switch', '-q', '-c', 'feature');
|
|
25
|
+
fs.writeFileSync(path.join(cwd, 'src/rules.ts'), 'export function eligible(score: number) { return score > 10; }\n');
|
|
26
|
+
const first = await gate.run({ cwd });
|
|
27
|
+
const second = await gate.run({ cwd });
|
|
28
|
+
expect(first).toEqual(second);
|
|
29
|
+
expect(first).toHaveLength(1);
|
|
30
|
+
expect(first.some(finding => finding.details.includes("'>=' to '>'"))).toBe(true);
|
|
31
|
+
expect(fs.existsSync(path.join(cwd, '.rigour/logic-baseline.json'))).toBe(false);
|
|
32
|
+
git('add', '.');
|
|
33
|
+
git('commit', '-qm', 'change boundary');
|
|
34
|
+
git('switch', '-q', 'main');
|
|
35
|
+
git('merge', '-q', '--ff-only', 'feature');
|
|
36
|
+
expect(await gate.run({ cwd })).toEqual([]);
|
|
37
|
+
}, 30_000);
|
|
38
|
+
it('does not use a moving local snapshot when Git has no main reference', async () => {
|
|
39
|
+
git('switch', '-q', '-c', 'feature');
|
|
40
|
+
git('branch', '-D', 'main');
|
|
41
|
+
const gate = new LogicDriftGate();
|
|
42
|
+
expect(await gate.run({ cwd })).toEqual([]);
|
|
43
|
+
expect(fs.existsSync(path.join(cwd, '.rigour/logic-baseline.json'))).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/dist/gates/runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SEVERITY_WEIGHTS } from '../types/index.js';
|
|
2
|
-
import {
|
|
2
|
+
import { runDeepAnalysis } from './deep-runner.js';
|
|
3
3
|
import { persistAndReinforce } from '../storage/local-memory.js';
|
|
4
4
|
import { recordGateRun } from '../services/adaptive-thresholds.js';
|
|
5
5
|
import { FileGate } from './file.js';
|
|
@@ -200,45 +200,10 @@ export class GateRunner {
|
|
|
200
200
|
// 3. Run Deep Analysis (if enabled)
|
|
201
201
|
let deepStats = undefined;
|
|
202
202
|
if (deepOptions?.enabled) {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
threads: this.config.gates.deep?.threads,
|
|
208
|
-
maxTokens: this.config.gates.deep?.max_tokens,
|
|
209
|
-
temperature: this.config.gates.deep?.temperature,
|
|
210
|
-
timeoutMs: this.config.gates.deep?.timeout_ms,
|
|
211
|
-
onProgress: deepOptions.onProgress,
|
|
212
|
-
});
|
|
213
|
-
try {
|
|
214
|
-
const deepFailures = await deepGate.run({ cwd, ignore, patterns });
|
|
215
|
-
if (deepFailures.length > 0) {
|
|
216
|
-
failures.push(...deepFailures);
|
|
217
|
-
summary['deep-analysis'] = 'FAIL';
|
|
218
|
-
}
|
|
219
|
-
else {
|
|
220
|
-
summary['deep-analysis'] = 'PASS';
|
|
221
|
-
}
|
|
222
|
-
const isLocalDeepExecution = !deepOptions.apiKey || (deepOptions.provider || '').toLowerCase() === 'local';
|
|
223
|
-
const deepTier = isLocalDeepExecution
|
|
224
|
-
? (deepOptions.pro ? 'deep' : 'lite')
|
|
225
|
-
: 'cloud';
|
|
226
|
-
deepStats = {
|
|
227
|
-
enabled: true,
|
|
228
|
-
tier: deepTier,
|
|
229
|
-
model: isLocalDeepExecution
|
|
230
|
-
? (deepOptions.pro ? 'Qwen2.5-Coder-1.5B' : 'Qwen2.5-Coder-0.5B')
|
|
231
|
-
: (deepOptions.modelName || deepOptions.provider || 'cloud'),
|
|
232
|
-
total_ms: Date.now() - deepSetupStart,
|
|
233
|
-
findings_count: deepFailures.length,
|
|
234
|
-
findings_verified: deepFailures.filter((f) => f.verified).length,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
catch (error) {
|
|
238
|
-
Logger.error(`Deep analysis failed: ${error.message}`);
|
|
239
|
-
summary['deep-analysis'] = 'ERROR';
|
|
240
|
-
deepStats = { enabled: true };
|
|
241
|
-
}
|
|
203
|
+
const deep = await runDeepAnalysis(this.config, { cwd, ignore, patterns }, deepOptions);
|
|
204
|
+
failures.push(...deep.failures);
|
|
205
|
+
summary['deep-analysis'] = deep.summary;
|
|
206
|
+
deepStats = deep.stats;
|
|
242
207
|
}
|
|
243
208
|
const status = failures.length > 0 ? 'FAIL' : 'PASS';
|
|
244
209
|
// Severity-weighted scoring with per-gate cap and deduplication
|
|
@@ -4,6 +4,12 @@ import path from 'path';
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { GateRunner } from './runner.js';
|
|
6
6
|
import { DeepAnalysisGate } from './deep-analysis.js';
|
|
7
|
+
function mockDeep(outcome, failures = []) {
|
|
8
|
+
vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue(failures);
|
|
9
|
+
vi.spyOn(DeepAnalysisGate.prototype, 'getOutcome').mockReturnValue({
|
|
10
|
+
status: 'ok', mode: 'facts', filesAnalyzed: 1, chunksTotal: 1, chunksFailed: 0, ...outcome,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
7
13
|
describe('GateRunner deep stats execution mode', () => {
|
|
8
14
|
let testDir;
|
|
9
15
|
beforeEach(async () => {
|
|
@@ -25,41 +31,47 @@ describe('GateRunner deep stats execution mode', () => {
|
|
|
25
31
|
},
|
|
26
32
|
});
|
|
27
33
|
}
|
|
28
|
-
it('reports local lite tier
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
enabled: true,
|
|
33
|
-
apiKey: 'sk-test',
|
|
34
|
-
provider: 'local',
|
|
35
|
-
pro: false,
|
|
34
|
+
it('reports local lite tier and the model the provider actually loaded', async () => {
|
|
35
|
+
mockDeep({ model: 'Qwen2.5-Coder-0.5B-Instruct (stock)', modelFallback: true });
|
|
36
|
+
const report = await createRunner().run(testDir, undefined, {
|
|
37
|
+
enabled: true, apiKey: 'sk-test', provider: 'local', pro: false,
|
|
36
38
|
});
|
|
37
39
|
expect(report.stats.deep?.tier).toBe('lite');
|
|
38
|
-
expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-0.5B');
|
|
40
|
+
expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-0.5B-Instruct (stock)');
|
|
41
|
+
expect(report.stats.deep?.model_fallback).toBe(true);
|
|
42
|
+
expect(report.stats.deep?.status).toBe('ok');
|
|
43
|
+
expect(report.summary['deep-analysis']).toBe('PASS');
|
|
39
44
|
});
|
|
40
45
|
it('reports local deep tier when provider=local and pro=true', async () => {
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
enabled: true,
|
|
45
|
-
apiKey: 'sk-test',
|
|
46
|
-
provider: 'local',
|
|
47
|
-
pro: true,
|
|
46
|
+
mockDeep({ model: 'Rigour-Deep-v5.0.0 (Qwen2.5-Coder-1.5B fine-tuned)', modelFallback: false });
|
|
47
|
+
const report = await createRunner().run(testDir, undefined, {
|
|
48
|
+
enabled: true, apiKey: 'sk-test', provider: 'local', pro: true,
|
|
48
49
|
});
|
|
49
50
|
expect(report.stats.deep?.tier).toBe('deep');
|
|
50
|
-
expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-1.5B');
|
|
51
|
+
expect(report.stats.deep?.model).toBe('Rigour-Deep-v5.0.0 (Qwen2.5-Coder-1.5B fine-tuned)');
|
|
51
52
|
});
|
|
52
53
|
it('reports cloud tier/model for cloud providers', async () => {
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
enabled: true,
|
|
57
|
-
apiKey: 'sk-test',
|
|
58
|
-
provider: 'openai',
|
|
59
|
-
modelName: 'gpt-4.1-mini',
|
|
60
|
-
pro: false,
|
|
54
|
+
mockDeep({ model: 'gpt-4.1-mini' });
|
|
55
|
+
const report = await createRunner().run(testDir, undefined, {
|
|
56
|
+
enabled: true, apiKey: 'sk-test', provider: 'openai', modelName: 'gpt-4.1-mini', pro: false,
|
|
61
57
|
});
|
|
62
58
|
expect(report.stats.deep?.tier).toBe('cloud');
|
|
63
59
|
expect(report.stats.deep?.model).toBe('gpt-4.1-mini');
|
|
64
60
|
});
|
|
61
|
+
it('reports a deep run that could not analyze anything as ERROR with a visible failure', async () => {
|
|
62
|
+
mockDeep({ status: 'error', chunksTotal: 3, chunksFailed: 3, error: 'All 3 inference call(s) failed.' });
|
|
63
|
+
const report = await createRunner().run(testDir, undefined, { enabled: true, pro: false });
|
|
64
|
+
expect(report.summary['deep-analysis']).toBe('ERROR');
|
|
65
|
+
expect(report.status).toBe('FAIL');
|
|
66
|
+
expect(report.stats.deep).toMatchObject({ status: 'error', chunks_total: 3, chunks_failed: 3, error: 'All 3 inference call(s) failed.' });
|
|
67
|
+
const failure = report.failures.find(f => f.id === 'deep-analysis');
|
|
68
|
+
expect(failure?.title).toBe('Deep analysis did not run');
|
|
69
|
+
expect(failure?.details).toContain('All 3 inference call(s) failed.');
|
|
70
|
+
});
|
|
71
|
+
it('keeps a partial run as PASS/FAIL by findings and records failed chunks', async () => {
|
|
72
|
+
mockDeep({ status: 'partial', chunksTotal: 4, chunksFailed: 1 });
|
|
73
|
+
const report = await createRunner().run(testDir, undefined, { enabled: true, pro: false });
|
|
74
|
+
expect(report.summary['deep-analysis']).toBe('PASS');
|
|
75
|
+
expect(report.stats.deep).toMatchObject({ status: 'partial', chunks_total: 4, chunks_failed: 1 });
|
|
76
|
+
});
|
|
65
77
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|