@rigour-labs/core 6.2.3 → 6.2.4

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.
@@ -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,16 @@
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';
24
25
  import fs from 'fs-extra';
25
26
  import path from 'path';
26
27
  import crypto from 'crypto';
@@ -50,16 +51,35 @@ export class LogicDriftGate extends Gate {
50
51
  });
51
52
  if (files.length === 0)
52
53
  return [];
54
+ // In Git worktrees, compare only files changed from an immutable base.
55
+ const gitBase = resolveGitLogicBase(context.cwd);
56
+ if (!gitBase && isGitWorktree(context.cwd)) {
57
+ Logger.info('Logic Drift: no main reference available; comparison unavailable');
58
+ return [];
59
+ }
60
+ const scanFiles = gitBase ? files.filter(file => gitBase.changedFiles.has(file)) : files;
61
+ if (gitBase && scanFiles.length === 0)
62
+ return [];
53
63
  // Extract current function baselines
54
64
  const currentFunctions = [];
55
- const contents = await FileScanner.readFiles(context.cwd, files, context.fileCache);
65
+ const contents = await FileScanner.readFiles(context.cwd, scanFiles, context.fileCache);
56
66
  for (const [file, content] of contents) {
57
67
  const extracted = this.extractFunctionBaselines(content, file);
58
68
  currentFunctions.push(...extracted);
59
69
  }
60
- // Load previous baseline
70
+ // Git is authoritative when present; old local snapshots are fallback
71
+ // only for projects without an available Git main reference.
61
72
  let previousBaseline = null;
62
- if (await fs.pathExists(baselinePath)) {
73
+ if (gitBase) {
74
+ const functions = [];
75
+ for (const file of scanFiles) {
76
+ const content = gitBase.readAtBase(file);
77
+ if (content !== null)
78
+ functions.push(...this.extractFunctionBaselines(content, file));
79
+ }
80
+ previousBaseline = { functions, createdAt: '', lastUpdated: '', scanCount: 0 };
81
+ }
82
+ else if (await fs.pathExists(baselinePath)) {
63
83
  try {
64
84
  const raw = await fs.readJson(baselinePath);
65
85
  // Validate baseline has required structure
@@ -131,7 +151,8 @@ export class LogicDriftGate extends Gate {
131
151
  lastUpdated: new Date().toISOString(),
132
152
  scanCount: previousBaseline.scanCount + 1,
133
153
  };
134
- await fs.writeJson(baselinePath, updatedBaseline, { spaces: 2 });
154
+ if (!gitBase)
155
+ await fs.writeJson(baselinePath, updatedBaseline, { spaces: 2 });
135
156
  if (failures.length > 0) {
136
157
  Logger.info(`Logic Drift: Found ${failures.length} logic mutations`);
137
158
  }
@@ -146,7 +167,12 @@ export class LogicDriftGate extends Gate {
146
167
  }
147
168
  const skipKeywords = new Set(['if', 'for', 'while', 'switch', 'catch', 'constructor', 'else', 'elif', 'elsif', 'rescue']);
148
169
  const functions = adapter.extractFunctions(content, file);
170
+ const seen = new Set();
149
171
  for (const fn of functions) {
172
+ const identity = `${fn.name}:${fn.startLine}`;
173
+ if (seen.has(identity))
174
+ continue;
175
+ seen.add(identity);
150
176
  // Skip control flow keywords
151
177
  if (skipKeywords.has(fn.name))
152
178
  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
+ });
@@ -0,0 +1,5 @@
1
+ export interface UnsafeShellCall {
2
+ line: number;
3
+ text: string;
4
+ }
5
+ export declare function findUnsafeShellCalls(content: string, file: string): UnsafeShellCall[];
@@ -0,0 +1,123 @@
1
+ import ts from 'typescript';
2
+ const SHELL_MODULES = new Set(['child_process', 'node:child_process']);
3
+ const EXEC_METHODS = new Set(['exec', 'execSync']);
4
+ const SPAWN_METHODS = new Set(['spawn', 'spawnSync']);
5
+ const USER_INPUT = /\b(?:req|request|query|params|body|input|user|argv)\b|process\.env/;
6
+ function shellModule(node) {
7
+ return ts.isStringLiteral(node) && SHELL_MODULES.has(node.text);
8
+ }
9
+ function isShellRequire(node) {
10
+ return ts.isCallExpression(node)
11
+ && ts.isIdentifier(node.expression)
12
+ && node.expression.text === 'require'
13
+ && node.arguments.length === 1
14
+ && shellModule(node.arguments[0]);
15
+ }
16
+ function collectBindings(source) {
17
+ const bindings = { functions: new Map(), namespaces: new Set() };
18
+ for (const statement of source.statements) {
19
+ if (ts.isImportDeclaration(statement) && shellModule(statement.moduleSpecifier)) {
20
+ const clause = statement.importClause;
21
+ if (clause?.name)
22
+ bindings.namespaces.add(clause.name.text);
23
+ const named = clause?.namedBindings;
24
+ if (named && ts.isNamespaceImport(named))
25
+ bindings.namespaces.add(named.name.text);
26
+ if (named && ts.isNamedImports(named)) {
27
+ for (const element of named.elements) {
28
+ const exported = element.propertyName?.text ?? element.name.text;
29
+ if (EXEC_METHODS.has(exported) || SPAWN_METHODS.has(exported)) {
30
+ bindings.functions.set(element.name.text, exported);
31
+ }
32
+ }
33
+ }
34
+ }
35
+ if (!ts.isVariableStatement(statement))
36
+ continue;
37
+ for (const declaration of statement.declarationList.declarations) {
38
+ const initializer = declaration.initializer;
39
+ if (!initializer)
40
+ continue;
41
+ if (isShellRequire(initializer)) {
42
+ if (ts.isIdentifier(declaration.name))
43
+ bindings.namespaces.add(declaration.name.text);
44
+ if (ts.isObjectBindingPattern(declaration.name)) {
45
+ for (const element of declaration.name.elements) {
46
+ const exported = element.propertyName?.getText(source) ?? element.name.getText(source);
47
+ if ((EXEC_METHODS.has(exported) || SPAWN_METHODS.has(exported)) && ts.isIdentifier(element.name)) {
48
+ bindings.functions.set(element.name.text, exported);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ else if (ts.isPropertyAccessExpression(initializer) && isShellRequire(initializer.expression)
54
+ && ts.isIdentifier(declaration.name)) {
55
+ bindings.functions.set(declaration.name.text, initializer.name.text);
56
+ }
57
+ }
58
+ }
59
+ return bindings;
60
+ }
61
+ function shadowsImportedName(node, name) {
62
+ for (let scope = node.parent; scope && !ts.isSourceFile(scope); scope = scope.parent) {
63
+ if (ts.isFunctionLike(scope) && scope.parameters.some(param => ts.isIdentifier(param.name) && param.name.text === name)) {
64
+ return true;
65
+ }
66
+ if (!ts.isBlock(scope))
67
+ continue;
68
+ for (const statement of scope.statements) {
69
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === name)
70
+ return true;
71
+ if (!ts.isVariableStatement(statement))
72
+ continue;
73
+ if (statement.declarationList.declarations.some(decl => ts.isIdentifier(decl.name) && decl.name.text === name)) {
74
+ return true;
75
+ }
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+ function shellMethod(call, bindings) {
81
+ const expression = call.expression;
82
+ if (ts.isIdentifier(expression)) {
83
+ return shadowsImportedName(call, expression.text) ? undefined : bindings.functions.get(expression.text);
84
+ }
85
+ if (ts.isPropertyAccessExpression(expression)) {
86
+ const receiver = expression.expression;
87
+ if (isShellRequire(receiver))
88
+ return expression.name.text;
89
+ if (ts.isIdentifier(receiver) && bindings.namespaces.has(receiver.text)
90
+ && !shadowsImportedName(call, receiver.text))
91
+ return expression.name.text;
92
+ }
93
+ return undefined;
94
+ }
95
+ function shellEnabled(call) {
96
+ return call.arguments.some(arg => ts.isObjectLiteralExpression(arg)
97
+ && arg.properties.some(prop => ts.isPropertyAssignment(prop)
98
+ && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))
99
+ && prop.name.text === 'shell'
100
+ && prop.initializer.kind === ts.SyntaxKind.TrueKeyword));
101
+ }
102
+ function taintedArgument(call, source) {
103
+ return call.arguments.some(arg => USER_INPUT.test(arg.getText(source)));
104
+ }
105
+ export function findUnsafeShellCalls(content, file) {
106
+ const kind = file.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS;
107
+ const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
108
+ const bindings = collectBindings(source);
109
+ const calls = [];
110
+ const visit = (node) => {
111
+ if (ts.isCallExpression(node)) {
112
+ const method = shellMethod(node, bindings);
113
+ const executesShell = method && (EXEC_METHODS.has(method) || (SPAWN_METHODS.has(method) && shellEnabled(node)));
114
+ if (executesShell && taintedArgument(node, source)) {
115
+ calls.push({ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
116
+ text: node.getText(source).slice(0, 120) });
117
+ }
118
+ }
119
+ ts.forEachChild(node, visit);
120
+ };
121
+ visit(source);
122
+ return calls;
123
+ }
@@ -98,23 +98,7 @@ export const VULNERABILITY_PATTERNS = [
98
98
  cwe: 'CWE-338',
99
99
  languages: ['ts', 'js', 'tsx', 'jsx']
100
100
  },
101
- // Command Injection
102
- {
103
- type: 'command_injection',
104
- regex: /(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{[^}]*(?:req\.|query|params|body|input|user|argv|process\.env)[^}]*\}[^`]*`|[^)]*(?:req\.|query|params|body|input|user|argv|process\.env)[^)]*)\)/g,
105
- severity: 'critical',
106
- description: 'Potential command injection: shell execution with user input',
107
- cwe: 'CWE-78',
108
- languages: ['ts', 'js']
109
- },
110
- {
111
- type: 'command_injection',
112
- regex: /child_process.*\s*\.\s*(?:exec|spawn)\s*\([^)]*(?:req\.|query|params|body|input|user|argv|process\.env)/g,
113
- severity: 'high',
114
- description: 'child_process usage detected (verify input sanitization)',
115
- cwe: 'CWE-78',
116
- languages: ['ts', 'js']
117
- },
101
+ // JavaScript shell calls are checked with receiver-aware syntax analysis.
118
102
  // Python subprocess command injection
119
103
  {
120
104
  type: 'command_injection',
@@ -61,11 +61,6 @@ export declare class SecurityPatternsGate extends Gate {
61
61
  * Known sanitizers: DOMPurify.sanitize(), sanitize(), xss(), escapeHtml(), htmlEncode().
62
62
  */
63
63
  private isSanitizedAssignment;
64
- /**
65
- * Check if a shell execution call is using { shell: false } option (safe pattern).
66
- * Also, spawn() without { shell: true } is safe by default — don't flag it.
67
- */
68
- private isSafeShellCall;
69
64
  }
70
65
  /**
71
66
  * Quick helper to check a single file for security issues
@@ -16,6 +16,7 @@ import { Gate } from './base.js';
16
16
  import { FileScanner } from '../utils/scanner.js';
17
17
  import { Logger } from '../utils/logger.js';
18
18
  import { VULNERABILITY_PATTERNS } from './security-patterns-data.js';
19
+ import { findUnsafeShellCalls } from './security-command-execution.js';
19
20
  import fs from 'fs-extra';
20
21
  import path from 'path';
21
22
  export class SecurityPatternsGate extends Gate {
@@ -127,7 +128,16 @@ export class SecurityPatternsGate extends Gate {
127
128
  return false;
128
129
  }
129
130
  scanFileForVulnerabilities(content, file, ext, vulnerabilities) {
130
- const lines = content.split('\n');
131
+ if (ext === 'ts' || ext === 'js') {
132
+ for (const call of findUnsafeShellCalls(content, file)) {
133
+ vulnerabilities.push({
134
+ type: 'command_injection', severity: 'critical', file,
135
+ line: call.line, match: call.text,
136
+ description: 'Potential command injection: shell execution with user input',
137
+ cwe: 'CWE-78',
138
+ });
139
+ }
140
+ }
131
141
  for (const pattern of VULNERABILITY_PATTERNS) {
132
142
  // Check if pattern applies to this file type
133
143
  if (!pattern.languages.includes('*') && !pattern.languages.includes(ext)) {
@@ -145,10 +155,6 @@ export class SecurityPatternsGate extends Gate {
145
155
  if (pattern.type === 'xss' && this.isSanitizedAssignment(match[0])) {
146
156
  continue;
147
157
  }
148
- // For command_injection: check if spawn/exec uses { shell: false } (safe)
149
- if (pattern.type === 'command_injection' && this.isSafeShellCall(match[0], content, match.index)) {
150
- continue;
151
- }
152
158
  // Find line number
153
159
  const beforeMatch = content.slice(0, match.index);
154
160
  const lineNumber = beforeMatch.split('\n').length;
@@ -244,20 +250,6 @@ export class SecurityPatternsGate extends Gate {
244
250
  const rhs = matchText.includes('=') ? matchText.split('=').slice(1).join('=') : matchText;
245
251
  return sanitizers.some(s => rhs.toLowerCase().includes(s.toLowerCase()));
246
252
  }
247
- /**
248
- * Check if a shell execution call is using { shell: false } option (safe pattern).
249
- * Also, spawn() without { shell: true } is safe by default — don't flag it.
250
- */
251
- isSafeShellCall(matchText, fullContent, matchIndex) {
252
- // Check surrounding context (100 chars after match) for shell: false
253
- const context = fullContent.slice(matchIndex, matchIndex + matchText.length + 100);
254
- if (/shell\s*:\s*false/.test(context))
255
- return true;
256
- // spawn() without shell: true is safe by default
257
- if (/\bspawn(?:Sync)?\s*\(/.test(matchText) && !/shell\s*:\s*true/.test(context))
258
- return true;
259
- return false;
260
- }
261
253
  }
262
254
  /**
263
255
  * Quick helper to check a single file for security issues
@@ -105,14 +105,49 @@ describe('SecurityPatternsGate', () => {
105
105
  });
106
106
  });
107
107
  describe('command injection detection', () => {
108
- it('should detect exec with user input', async () => {
108
+ it('detects imported shell execution with user input', async () => {
109
109
  const filePath = path.join(testDir, 'shell.ts');
110
110
  fs.writeFileSync(filePath, `
111
- exec(\`ls \${req.query.path}\`);
111
+ import { exec as runShell } from 'node:child_process';
112
+ runShell(\`ls \${req.query.path}\`);
112
113
  `);
113
114
  const vulns = await checkSecurityPatterns(filePath);
114
115
  expect(vulns.some(v => v.type === 'command_injection')).toBe(true);
115
116
  });
117
+ it('does not treat RegExp.exec or unrelated methods as shell execution', async () => {
118
+ const filePath = path.join(testDir, 'template.ts');
119
+ fs.writeFileSync(filePath, `
120
+ const separator = HB_ELSE.exec(body);
121
+ const other = LIQUID_BRANCH.exec(user);
122
+ const local = { exec(value: string) { return value; } };
123
+ local.exec(request.body);
124
+ `);
125
+ const vulns = await checkSecurityPatterns(filePath);
126
+ expect(vulns.filter(v => v.type === 'command_injection')).toEqual([]);
127
+ });
128
+ it('detects namespace, require, and shell-enabled spawn calls', async () => {
129
+ const filePath = path.join(testDir, 'shell.js');
130
+ fs.writeFileSync(filePath, `
131
+ const cp = require('child_process');
132
+ const { exec: runShell } = require('node:child_process');
133
+ cp.exec(req.body.command);
134
+ runShell(user.command);
135
+ cp.spawn(process.env.SHELL, [input], { shell: true });
136
+ cp.spawn(process.env.SHELL, [input]);
137
+ `);
138
+ const vulns = await checkSecurityPatterns(filePath);
139
+ expect(vulns.filter(v => v.type === 'command_injection')).toHaveLength(3);
140
+ });
141
+ it('does not use an imported shell binding shadowed by a parameter', async () => {
142
+ const filePath = path.join(testDir, 'shadow.ts');
143
+ fs.writeFileSync(filePath, `
144
+ import { exec } from 'child_process';
145
+ function parse(exec: RegExp) { return exec.exec(body); }
146
+ function run(exec: (value: string) => void) { exec(user); }
147
+ `);
148
+ const vulns = await checkSecurityPatterns(filePath);
149
+ expect(vulns.filter(v => v.type === 'command_injection')).toEqual([]);
150
+ });
116
151
  });
117
152
  describe('severity filtering', () => {
118
153
  it('should block on high severity by default', async () => {
@@ -6,10 +6,8 @@
6
6
  * style which may differ from the project norm.
7
7
  *
8
8
  * What it checks:
9
- * 1. Naming conventions — camelCase vs snake_case vs PascalCase consistency
9
+ * 1. Naming conventions — compare names with the same language conventions
10
10
  * 2. Error handling patterns — try-catch vs .catch()/except/rescue consistency
11
- * 3. Import style — named vs default vs wildcard import consistency
12
- * 4. Quote style — single vs double quote consistency
13
11
  *
14
12
  * How it works:
15
13
  * 1. First scan: sample source files → compute per-language style fingerprints → store baseline
@@ -17,7 +15,9 @@
17
15
  * 3. If a file deviates >25% on any dimension → flag as style drift
18
16
  *
19
17
  * Baselines are per-language to avoid cross-language contamination
20
- * (e.g., Python snake_case shouldn't flag JS camelCase).
18
+ * (e.g., Python snake_case should not flag JS camelCase).
19
+ * JS names mandated by frameworks are excluded from style comparison, and
20
+ * imports are never compared by a language-wide named/default ratio.
21
21
  */
22
22
  import { Gate, GateContext } from './base.js';
23
23
  import { Failure, Provenance } from '../types/index.js';
@@ -6,10 +6,8 @@
6
6
  * style which may differ from the project norm.
7
7
  *
8
8
  * What it checks:
9
- * 1. Naming conventions — camelCase vs snake_case vs PascalCase consistency
9
+ * 1. Naming conventions — compare names with the same language conventions
10
10
  * 2. Error handling patterns — try-catch vs .catch()/except/rescue consistency
11
- * 3. Import style — named vs default vs wildcard import consistency
12
- * 4. Quote style — single vs double quote consistency
13
11
  *
14
12
  * How it works:
15
13
  * 1. First scan: sample source files → compute per-language style fingerprints → store baseline
@@ -17,12 +15,15 @@
17
15
  * 3. If a file deviates >25% on any dimension → flag as style drift
18
16
  *
19
17
  * Baselines are per-language to avoid cross-language contamination
20
- * (e.g., Python snake_case shouldn't flag JS camelCase).
18
+ * (e.g., Python snake_case should not flag JS camelCase).
19
+ * JS names mandated by frameworks are excluded from style comparison, and
20
+ * imports are never compared by a language-wide named/default ratio.
21
21
  */
22
22
  import { Gate } from './base.js';
23
23
  import { FileScanner } from '../utils/scanner.js';
24
24
  import { Logger } from '../utils/logger.js';
25
25
  import { languageAdapters } from './language-adapters/index.js';
26
+ import { extractComparableJsNames } from './js-style-context.js';
26
27
  import { TRY_CATCH_PATTERN, CATCH_PATTERN, RESULT_TYPE_PATTERN, NAMED_IMPORT_PATTERN, WILDCARD_IMPORT_PATTERN, SIDE_EFFECT_IMPORT_PATTERN, DEFAULT_IMPORT_PATTERN, countQuotes, } from './style-drift-rules.js';
27
28
  import fs from 'fs-extra';
28
29
  import path from 'path';
@@ -67,7 +68,7 @@ export class StyleDriftGate extends Gate {
67
68
  try {
68
69
  const raw = await fs.readJson(baselinePath);
69
70
  // Handle migration from old single-fingerprint format
70
- if (raw.version === 2) {
71
+ if (raw.version === 3) {
71
72
  baseline = raw;
72
73
  }
73
74
  else {
@@ -116,7 +117,7 @@ export class StyleDriftGate extends Gate {
116
117
  const baseline = {
117
118
  languages: {},
118
119
  createdAt: new Date().toISOString(),
119
- version: 2,
120
+ version: 3,
120
121
  };
121
122
  for (const [langId, langFiles] of filesByLang) {
122
123
  // Sample up to sample_size files per language
@@ -157,7 +158,9 @@ export class StyleDriftGate extends Gate {
157
158
  const adapter = languageAdapters.getAdapter(filePath);
158
159
  // ── Naming conventions (via adapter) ──
159
160
  if (adapter) {
160
- const namingPatterns = adapter.extractNamingPatterns(content);
161
+ const namingPatterns = adapter.id === 'js'
162
+ ? extractComparableJsNames(content, filePath)
163
+ : adapter.extractNamingPatterns(content);
161
164
  for (const pattern of namingPatterns) {
162
165
  if (pattern.kind === 'function' || pattern.kind === 'method') {
163
166
  fp.naming.functions[pattern.convention]++;
@@ -244,15 +247,8 @@ export class StyleDriftGate extends Gate {
244
247
  suggestion: `Use ${errDev.baselinePredominant} error handling pattern to match project conventions.`,
245
248
  });
246
249
  }
247
- const impDev = this.distributionDeviation(file.importStyle, baseline.importStyle);
248
- if (impDev.score > 0 && this.hasSignificantData(file.importStyle)) {
249
- deviations.push({
250
- dimension: 'import style',
251
- score: impDev.score,
252
- detail: `file uses ${impDev.filePredominant}, project uses ${impDev.baselinePredominant}`,
253
- suggestion: `Use ${impDev.baselinePredominant} imports to match project conventions.`,
254
- });
255
- }
250
+ // Import form depends on the imported module's exports. A language-wide
251
+ // named/default ratio cannot establish a valid replacement.
256
252
  return deviations;
257
253
  }
258
254
  distributionDeviation(file, baseline) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { StyleDriftGate } from './style-drift.js';
6
+ describe('StyleDriftGate', () => {
7
+ let cwd;
8
+ beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'style-drift-')); });
9
+ afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
10
+ it('does not compare default imports against unrelated named imports', async () => {
11
+ const src = path.join(cwd, 'src');
12
+ fs.mkdirSync(src);
13
+ for (let i = 0; i < 5; i++) {
14
+ fs.writeFileSync(path.join(src, `named${i}.ts`), `import { value } from './shared';\nexport function helper${i}() { return value; }\n`);
15
+ }
16
+ fs.writeFileSync(path.join(src, 'mui.ts'), [
17
+ "import Box from '@mui/material/Box';",
18
+ "import TextField from '@mui/material/TextField';",
19
+ "import Button from '@mui/material/Button';",
20
+ ].join('\n'));
21
+ const gate = new StyleDriftGate();
22
+ expect(await gate.run({ cwd })).toEqual([]);
23
+ const findings = await gate.run({ cwd });
24
+ expect(findings.some(finding => finding.details.includes('import style'))).toBe(false);
25
+ });
26
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.2.3",
3
+ "version": "6.2.4",
4
4
  "description": "AI-native quality gate engine with local Bayesian learning. AST analysis, drift detection, Fix Packet generation, and agent self-healing across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rigour.run",
@@ -67,11 +67,11 @@
67
67
  "pg": "^8.16.3",
68
68
  "sqlite3": "^5.1.7",
69
69
  "openai": "^4.104.0",
70
- "@rigour-labs/brain-darwin-x64": "6.2.3",
71
- "@rigour-labs/brain-darwin-arm64": "6.2.3",
72
- "@rigour-labs/brain-linux-arm64": "6.2.3",
73
- "@rigour-labs/brain-win-x64": "6.2.3",
74
- "@rigour-labs/brain-linux-x64": "6.2.3"
70
+ "@rigour-labs/brain-darwin-arm64": "6.2.4",
71
+ "@rigour-labs/brain-darwin-x64": "6.2.4",
72
+ "@rigour-labs/brain-linux-x64": "6.2.4",
73
+ "@rigour-labs/brain-win-x64": "6.2.4",
74
+ "@rigour-labs/brain-linux-arm64": "6.2.4"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@types/fs-extra": "^11.0.4",