@rigour-labs/core 6.2.3 → 6.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/deep/code-context.d.ts +28 -0
  2. package/dist/deep/code-context.js +93 -0
  3. package/dist/deep/code-context.test.d.ts +1 -0
  4. package/dist/deep/code-context.test.js +48 -0
  5. package/dist/deep/code-pass.d.ts +15 -0
  6. package/dist/deep/code-pass.js +44 -0
  7. package/dist/deep/code-review-prompt.d.ts +17 -0
  8. package/dist/deep/code-review-prompt.js +30 -0
  9. package/dist/deep/code-review-prompt.test.d.ts +1 -0
  10. package/dist/deep/code-review-prompt.test.js +20 -0
  11. package/dist/deep/code-verifier.d.ts +19 -0
  12. package/dist/deep/code-verifier.js +48 -0
  13. package/dist/deep/code-verifier.test.d.ts +1 -0
  14. package/dist/deep/code-verifier.test.js +46 -0
  15. package/dist/deep/fact-extractor.d.ts +7 -1
  16. package/dist/deep/fact-extractor.js +16 -8
  17. package/dist/deep/fact-extractor.test.js +34 -1
  18. package/dist/deep/facts-pass.d.ts +24 -0
  19. package/dist/deep/facts-pass.js +69 -0
  20. package/dist/deep/index.d.ts +6 -1
  21. package/dist/deep/index.js +5 -1
  22. package/dist/deep/parse-findings.d.ts +12 -0
  23. package/dist/deep/parse-findings.js +73 -0
  24. package/dist/deep/parse-findings.test.d.ts +1 -0
  25. package/dist/deep/parse-findings.test.js +25 -0
  26. package/dist/deep/prompts.d.ts +1 -1
  27. package/dist/deep/prompts.js +7 -8
  28. package/dist/deep/prompts.test.js +15 -0
  29. package/dist/gates/context.js +7 -0
  30. package/dist/gates/deep-analysis.d.ts +31 -8
  31. package/dist/gates/deep-analysis.js +165 -306
  32. package/dist/gates/deep-analysis.test.d.ts +1 -0
  33. package/dist/gates/deep-analysis.test.js +86 -0
  34. package/dist/gates/deep-runner.d.ts +15 -0
  35. package/dist/gates/deep-runner.js +65 -0
  36. package/dist/gates/dependency.js +4 -0
  37. package/dist/gates/hallucinated-imports/index.js +13 -9
  38. package/dist/gates/hallucinated-imports.test.js +10 -0
  39. package/dist/gates/js-style-context.d.ts +3 -0
  40. package/dist/gates/js-style-context.js +65 -0
  41. package/dist/gates/js-style-context.test.d.ts +1 -0
  42. package/dist/gates/js-style-context.test.js +35 -0
  43. package/dist/gates/logic-drift-git-base.d.ts +7 -0
  44. package/dist/gates/logic-drift-git-base.js +41 -0
  45. package/dist/gates/logic-drift.d.ts +3 -3
  46. package/dist/gates/logic-drift.js +39 -7
  47. package/dist/gates/logic-drift.test.d.ts +1 -0
  48. package/dist/gates/logic-drift.test.js +45 -0
  49. package/dist/gates/runner.js +5 -40
  50. package/dist/gates/runner.test.js +37 -25
  51. package/dist/gates/scoped-gates.test.d.ts +1 -0
  52. package/dist/gates/scoped-gates.test.js +52 -0
  53. package/dist/gates/security-command-execution.d.ts +5 -0
  54. package/dist/gates/security-command-execution.js +123 -0
  55. package/dist/gates/security-patterns-data.js +1 -17
  56. package/dist/gates/security-patterns.d.ts +0 -5
  57. package/dist/gates/security-patterns.js +11 -19
  58. package/dist/gates/security-patterns.test.js +37 -2
  59. package/dist/gates/style-drift.d.ts +4 -4
  60. package/dist/gates/style-drift.js +15 -16
  61. package/dist/gates/style-drift.test.d.ts +1 -0
  62. package/dist/gates/style-drift.test.js +26 -0
  63. package/dist/index.d.ts +6 -1
  64. package/dist/index.js +6 -1
  65. package/dist/inference/http-download.d.ts +22 -0
  66. package/dist/inference/http-download.js +132 -0
  67. package/dist/inference/http-download.test.d.ts +1 -0
  68. package/dist/inference/http-download.test.js +109 -0
  69. package/dist/inference/index.d.ts +5 -1
  70. package/dist/inference/index.js +2 -1
  71. package/dist/inference/llama-engine.d.ts +27 -0
  72. package/dist/inference/llama-engine.js +152 -0
  73. package/dist/inference/llama-engine.test.d.ts +1 -0
  74. package/dist/inference/llama-engine.test.js +51 -0
  75. package/dist/inference/llama-process.d.ts +72 -0
  76. package/dist/inference/llama-process.js +126 -0
  77. package/dist/inference/llama-process.test.d.ts +1 -0
  78. package/dist/inference/llama-process.test.js +61 -0
  79. package/dist/inference/model-download.test.d.ts +1 -0
  80. package/dist/inference/model-download.test.js +73 -0
  81. package/dist/inference/model-manager.d.ts +14 -1
  82. package/dist/inference/model-manager.js +65 -80
  83. package/dist/inference/sidecar-provider.d.ts +19 -10
  84. package/dist/inference/sidecar-provider.js +115 -343
  85. package/dist/templates/universal-config.js +0 -2
  86. package/dist/types/index.d.ts +58 -14
  87. package/dist/types/index.js +9 -2
  88. package/dist/utils/deep-status.d.ts +7 -0
  89. package/dist/utils/deep-status.js +11 -0
  90. package/dist/utils/diff.d.ts +3 -0
  91. package/dist/utils/diff.js +52 -0
  92. package/dist/utils/diff.test.d.ts +1 -0
  93. package/dist/utils/diff.test.js +38 -0
  94. package/dist/utils/scanner.js +13 -2
  95. package/dist/utils/scanner.test.js +22 -8
  96. package/dist/utils/scope.d.ts +15 -0
  97. package/dist/utils/scope.js +51 -0
  98. package/dist/utils/scope.test.d.ts +1 -0
  99. package/dist/utils/scope.test.js +38 -0
  100. package/package.json +6 -6
@@ -0,0 +1,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
+ }
@@ -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: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
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
- const projectFiles = new Set(analyzableFiles.map(f => f.replace(/\\/g, '/')));
65
- const allProjectFiles = new Set(files.map(f => f.replace(/\\/g, '/')));
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,3 @@
1
+ import { type NamingPattern } from './language-adapters/types.js';
2
+ /** Extract comparable JS/TS names, excluding framework-defined identifiers. */
3
+ export declare function extractComparableJsNames(content: string, file: string): NamingPattern[];
@@ -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: Collect baselines for critical functions, then detect
16
- * mutations between scans. This foundation enables future LLM-powered
17
- * deeper analysis (feeding baselines into DriftBench training).
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: Collect baselines for critical functions, then detect
16
- * mutations between scans. This foundation enables future LLM-powered
17
- * deeper analysis (feeding baselines into DriftBench training).
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, files, context.fileCache);
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
- // Load previous baseline
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 (await fs.pathExists(baselinePath)) {
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
- await fs.writeJson(baselinePath, updatedBaseline, { spaces: 2 });
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
+ });
@@ -1,5 +1,5 @@
1
1
  import { SEVERITY_WEIGHTS } from '../types/index.js';
2
- import { DeepAnalysisGate } from './deep-analysis.js';
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 deepSetupStart = Date.now();
204
- const deepGate = new DeepAnalysisGate({
205
- options: deepOptions,
206
- checks: this.config.gates.deep?.checks,
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 when provider=local even with apiKey', async () => {
29
- vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
30
- const runner = createRunner();
31
- const report = await runner.run(testDir, undefined, {
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
- vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
42
- const runner = createRunner();
43
- const report = await runner.run(testDir, undefined, {
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
- vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
54
- const runner = createRunner();
55
- const report = await runner.run(testDir, undefined, {
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 {};