@rigour-labs/core 4.0.4 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gates/ast-handlers/typescript.js +39 -12
- package/dist/gates/ast-handlers/universal.js +9 -3
- package/dist/gates/ast.js +15 -1
- package/dist/gates/ast.test.d.ts +1 -0
- package/dist/gates/ast.test.js +112 -0
- package/dist/gates/content.d.ts +5 -0
- package/dist/gates/content.js +66 -7
- package/dist/gates/content.test.d.ts +1 -0
- package/dist/gates/content.test.js +73 -0
- package/dist/gates/context-window-artifacts.d.ts +1 -0
- package/dist/gates/context-window-artifacts.js +10 -3
- package/dist/gates/context.d.ts +1 -0
- package/dist/gates/context.js +29 -8
- package/dist/gates/deep-analysis.js +2 -2
- package/dist/gates/deprecated-apis.d.ts +1 -0
- package/dist/gates/deprecated-apis.js +15 -2
- package/dist/gates/hallucinated-imports.d.ts +14 -0
- package/dist/gates/hallucinated-imports.js +267 -60
- package/dist/gates/hallucinated-imports.test.js +164 -1
- package/dist/gates/inconsistent-error-handling.d.ts +1 -0
- package/dist/gates/inconsistent-error-handling.js +12 -1
- package/dist/gates/phantom-apis.d.ts +2 -0
- package/dist/gates/phantom-apis.js +28 -3
- package/dist/gates/phantom-apis.test.js +14 -0
- package/dist/gates/promise-safety.d.ts +2 -0
- package/dist/gates/promise-safety.js +31 -9
- package/dist/gates/runner.js +8 -2
- package/dist/gates/runner.test.d.ts +1 -0
- package/dist/gates/runner.test.js +65 -0
- package/dist/gates/security-patterns.d.ts +1 -0
- package/dist/gates/security-patterns.js +22 -6
- package/dist/gates/security-patterns.test.js +18 -0
- package/dist/hooks/templates.d.ts +1 -1
- package/dist/hooks/templates.js +12 -12
- package/dist/inference/executable.d.ts +6 -0
- package/dist/inference/executable.js +29 -0
- package/dist/inference/executable.test.d.ts +1 -0
- package/dist/inference/executable.test.js +41 -0
- package/dist/inference/model-manager.d.ts +3 -1
- package/dist/inference/model-manager.js +76 -8
- package/dist/inference/model-manager.test.d.ts +1 -0
- package/dist/inference/model-manager.test.js +24 -0
- package/dist/inference/sidecar-provider.d.ts +1 -0
- package/dist/inference/sidecar-provider.js +124 -31
- package/dist/services/context-engine.js +1 -1
- package/dist/templates/universal-config.js +3 -3
- package/dist/types/index.js +3 -3
- package/dist/utils/scanner.js +6 -0
- package/package.json +7 -2
|
@@ -52,12 +52,14 @@ export class DeprecatedApisGate extends Gate {
|
|
|
52
52
|
cwd: context.cwd,
|
|
53
53
|
patterns: ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'],
|
|
54
54
|
ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
|
|
55
|
+
'**/*.test.*', '**/*.spec.*', '**/__tests__/**',
|
|
55
56
|
'**/.venv/**', '**/venv/**', '**/vendor/**', '**/__pycache__/**',
|
|
56
57
|
'**/bin/Debug/**', '**/bin/Release/**', '**/obj/**',
|
|
57
58
|
'**/target/**', '**/.gradle/**', '**/out/**'],
|
|
58
59
|
});
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
const analyzableFiles = files.filter(file => !this.shouldSkipFile(file));
|
|
61
|
+
Logger.info(`Deprecated APIs: Scanning ${analyzableFiles.length} files`);
|
|
62
|
+
for (const file of analyzableFiles) {
|
|
61
63
|
try {
|
|
62
64
|
const fullPath = path.join(context.cwd, file);
|
|
63
65
|
const content = await fs.readFile(fullPath, 'utf-8');
|
|
@@ -105,6 +107,17 @@ export class DeprecatedApisGate extends Gate {
|
|
|
105
107
|
}
|
|
106
108
|
return failures;
|
|
107
109
|
}
|
|
110
|
+
shouldSkipFile(file) {
|
|
111
|
+
const normalized = file.replace(/\\/g, '/');
|
|
112
|
+
return (this.config.ignore_patterns.some(pattern => new RegExp(pattern).test(normalized)) ||
|
|
113
|
+
normalized.includes('/examples/') ||
|
|
114
|
+
normalized.includes('/__tests__/') ||
|
|
115
|
+
normalized.endsWith('/deprecated-apis-rules-node.ts') ||
|
|
116
|
+
normalized.endsWith('/deprecated-apis-rules-lang.ts') ||
|
|
117
|
+
normalized.endsWith('/deprecated-apis-rules.ts') ||
|
|
118
|
+
/\.test\.[^.]+$/i.test(normalized) ||
|
|
119
|
+
/\.spec\.[^.]+$/i.test(normalized));
|
|
120
|
+
}
|
|
108
121
|
checkNodeDeprecated(content, file, deprecated) {
|
|
109
122
|
const lines = content.split('\n');
|
|
110
123
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -40,8 +40,22 @@ export declare class HallucinatedImportsGate extends Gate {
|
|
|
40
40
|
protected get provenance(): Provenance;
|
|
41
41
|
run(context: GateContext): Promise<Failure[]>;
|
|
42
42
|
private checkJSImports;
|
|
43
|
+
private collectJSImportSpecs;
|
|
44
|
+
private resolveJSDepsForFile;
|
|
45
|
+
private resolveTsPathAlias;
|
|
46
|
+
private matchTsPathRule;
|
|
47
|
+
private resolveTsPathTarget;
|
|
48
|
+
private resolveTsPathConfigForFile;
|
|
49
|
+
private loadTsPathConfig;
|
|
50
|
+
private readLooseJson;
|
|
43
51
|
private checkPyImports;
|
|
44
52
|
private resolveRelativeImport;
|
|
45
53
|
private extractPackageName;
|
|
46
54
|
private shouldIgnore;
|
|
55
|
+
/**
|
|
56
|
+
* Build candidate source paths for an import.
|
|
57
|
+
* Handles ESM-style TS source imports like "./foo.js" that map to "./foo.ts" pre-build.
|
|
58
|
+
*/
|
|
59
|
+
private buildImportCandidates;
|
|
60
|
+
private shouldSkipFile;
|
|
47
61
|
}
|
|
@@ -24,6 +24,7 @@ import { FileScanner } from '../utils/scanner.js';
|
|
|
24
24
|
import { Logger } from '../utils/logger.js';
|
|
25
25
|
import fs from 'fs-extra';
|
|
26
26
|
import path from 'path';
|
|
27
|
+
import ts from 'typescript';
|
|
27
28
|
import { isNodeBuiltin, isPythonStdlib } from './hallucinated-imports-stdlib.js';
|
|
28
29
|
import { checkGoImports, checkRubyImports, checkCSharpImports, checkRustImports, checkJavaKotlinImports, loadPackageJson } from './hallucinated-imports-lang.js';
|
|
29
30
|
export class HallucinatedImportsGate extends Gate {
|
|
@@ -50,28 +51,35 @@ export class HallucinatedImportsGate extends Gate {
|
|
|
50
51
|
cwd: context.cwd,
|
|
51
52
|
patterns: ['**/*.{ts,js,tsx,jsx,py,go,rb,cs,rs,java,kt}'],
|
|
52
53
|
ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
|
|
54
|
+
'**/examples/**',
|
|
55
|
+
'**/studio-dist/**', '**/.next/**', '**/coverage/**',
|
|
56
|
+
'**/*.test.*', '**/*.spec.*', '**/__tests__/**',
|
|
53
57
|
'**/.venv/**', '**/venv/**', '**/vendor/**', '**/bin/Debug/**', '**/bin/Release/**', '**/obj/**',
|
|
54
58
|
'**/target/debug/**', '**/target/release/**', // Rust
|
|
55
59
|
'**/out/**', '**/.gradle/**', '**/gradle/**'], // Java/Kotlin
|
|
56
60
|
});
|
|
57
|
-
|
|
61
|
+
const analyzableFiles = files.filter(file => !this.shouldSkipFile(file));
|
|
62
|
+
Logger.info(`Hallucinated Imports: Scanning ${analyzableFiles.length} files`);
|
|
58
63
|
// Build lookup sets for fast resolution
|
|
59
|
-
const projectFiles = new Set(
|
|
64
|
+
const projectFiles = new Set(analyzableFiles.map(f => f.replace(/\\/g, '/')));
|
|
60
65
|
const packageJson = await loadPackageJson(context.cwd);
|
|
61
|
-
const
|
|
66
|
+
const rootDeps = new Set([
|
|
62
67
|
...Object.keys(packageJson?.dependencies || {}),
|
|
63
68
|
...Object.keys(packageJson?.devDependencies || {}),
|
|
64
69
|
...Object.keys(packageJson?.peerDependencies || {}),
|
|
70
|
+
...Object.keys(packageJson?.optionalDependencies || {}),
|
|
65
71
|
]);
|
|
72
|
+
const depCacheByDir = new Map();
|
|
73
|
+
const tsPathCacheByDir = new Map();
|
|
66
74
|
// Check if node_modules exists (for package verification)
|
|
67
75
|
const hasNodeModules = await fs.pathExists(path.join(context.cwd, 'node_modules'));
|
|
68
|
-
for (const file of
|
|
76
|
+
for (const file of analyzableFiles) {
|
|
69
77
|
try {
|
|
70
78
|
const fullPath = path.join(context.cwd, file);
|
|
71
79
|
const content = await fs.readFile(fullPath, 'utf-8');
|
|
72
80
|
const ext = path.extname(file);
|
|
73
81
|
if (['.ts', '.js', '.tsx', '.jsx'].includes(ext)) {
|
|
74
|
-
await this.checkJSImports(content, file, context.cwd, projectFiles,
|
|
82
|
+
await this.checkJSImports(content, file, context.cwd, projectFiles, rootDeps, depCacheByDir, hasNodeModules, hallucinated, tsPathCacheByDir);
|
|
75
83
|
}
|
|
76
84
|
else if (ext === '.py') {
|
|
77
85
|
await this.checkPyImports(content, file, context.cwd, projectFiles, hallucinated);
|
|
@@ -107,62 +115,230 @@ export class HallucinatedImportsGate extends Gate {
|
|
|
107
115
|
}
|
|
108
116
|
return failures;
|
|
109
117
|
}
|
|
110
|
-
async checkJSImports(content, file, cwd, projectFiles,
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
while ((match = pattern.exec(line)) !== null) {
|
|
125
|
-
const importPath = match[1];
|
|
126
|
-
// Skip ignored patterns (assets, etc.)
|
|
127
|
-
if (this.shouldIgnore(importPath))
|
|
128
|
-
continue;
|
|
129
|
-
if (importPath.startsWith('.')) {
|
|
130
|
-
// Relative import — check file exists
|
|
131
|
-
if (this.config.check_relative) {
|
|
132
|
-
const resolved = this.resolveRelativeImport(file, importPath, projectFiles);
|
|
133
|
-
if (!resolved) {
|
|
134
|
-
hallucinated.push({
|
|
135
|
-
file, line: i + 1, importPath, type: 'relative',
|
|
136
|
-
reason: `File not found: ${importPath}`,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
118
|
+
async checkJSImports(content, file, cwd, projectFiles, rootDeps, depCacheByDir, hasNodeModules, hallucinated, tsPathCacheByDir) {
|
|
119
|
+
const depsForFile = await this.resolveJSDepsForFile(file, cwd, rootDeps, depCacheByDir);
|
|
120
|
+
for (const spec of this.collectJSImportSpecs(content, file)) {
|
|
121
|
+
const { importPath, line } = spec;
|
|
122
|
+
if (!importPath || this.shouldIgnore(importPath))
|
|
123
|
+
continue;
|
|
124
|
+
if (importPath.startsWith('.')) {
|
|
125
|
+
if (this.config.check_relative) {
|
|
126
|
+
const resolved = this.resolveRelativeImport(file, importPath, projectFiles);
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
hallucinated.push({
|
|
129
|
+
file, line, importPath, type: 'relative',
|
|
130
|
+
reason: `File not found: ${importPath}`,
|
|
131
|
+
});
|
|
140
132
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const aliasResolution = await this.resolveTsPathAlias(file, importPath, cwd, projectFiles, tsPathCacheByDir);
|
|
137
|
+
if (aliasResolution === true)
|
|
138
|
+
continue;
|
|
139
|
+
if (aliasResolution === false) {
|
|
140
|
+
hallucinated.push({
|
|
141
|
+
file, line, importPath, type: 'package',
|
|
142
|
+
reason: `Path alias '${importPath}' does not resolve to a project file`,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (this.config.check_packages) {
|
|
147
|
+
const pkgName = this.extractPackageName(importPath);
|
|
148
|
+
if (isNodeBuiltin(pkgName))
|
|
149
|
+
continue;
|
|
150
|
+
if (!depsForFile.has(pkgName)) {
|
|
151
|
+
if (hasNodeModules) {
|
|
152
|
+
const pkgPath = path.join(cwd, 'node_modules', pkgName);
|
|
153
|
+
if (await fs.pathExists(pkgPath))
|
|
147
154
|
continue;
|
|
148
|
-
if (!allDeps.has(pkgName)) {
|
|
149
|
-
// Double-check node_modules if available
|
|
150
|
-
if (hasNodeModules) {
|
|
151
|
-
const pkgPath = path.join(cwd, 'node_modules', pkgName);
|
|
152
|
-
if (await fs.pathExists(pkgPath))
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
hallucinated.push({
|
|
156
|
-
file, line: i + 1, importPath, type: 'package',
|
|
157
|
-
reason: `Package '${pkgName}' not in package.json dependencies`,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
155
|
}
|
|
156
|
+
hallucinated.push({
|
|
157
|
+
file, line, importPath, type: 'package',
|
|
158
|
+
reason: `Package '${pkgName}' not in package.json dependencies`,
|
|
159
|
+
});
|
|
161
160
|
}
|
|
162
161
|
}
|
|
163
162
|
}
|
|
164
163
|
}
|
|
165
164
|
}
|
|
165
|
+
collectJSImportSpecs(content, file) {
|
|
166
|
+
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
|
|
167
|
+
const specs = [];
|
|
168
|
+
const add = (node, value) => {
|
|
169
|
+
if (!value)
|
|
170
|
+
return;
|
|
171
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
172
|
+
specs.push({ importPath: value, line });
|
|
173
|
+
};
|
|
174
|
+
const visit = (node) => {
|
|
175
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
176
|
+
add(node, node.moduleSpecifier.text);
|
|
177
|
+
}
|
|
178
|
+
else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
179
|
+
add(node, node.moduleSpecifier.text);
|
|
180
|
+
}
|
|
181
|
+
else if (ts.isCallExpression(node)) {
|
|
182
|
+
// require('x')
|
|
183
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
184
|
+
const firstArg = node.arguments[0];
|
|
185
|
+
if (firstArg && ts.isStringLiteral(firstArg)) {
|
|
186
|
+
add(node, firstArg.text);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// import('x')
|
|
190
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
191
|
+
const firstArg = node.arguments[0];
|
|
192
|
+
if (firstArg && ts.isStringLiteral(firstArg)) {
|
|
193
|
+
add(node, firstArg.text);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
ts.forEachChild(node, visit);
|
|
198
|
+
};
|
|
199
|
+
ts.forEachChild(sourceFile, visit);
|
|
200
|
+
return specs;
|
|
201
|
+
}
|
|
202
|
+
async resolveJSDepsForFile(file, cwd, rootDeps, depCacheByDir) {
|
|
203
|
+
const rootDir = path.resolve(cwd);
|
|
204
|
+
let currentDir = path.dirname(path.resolve(cwd, file));
|
|
205
|
+
while (currentDir.startsWith(rootDir)) {
|
|
206
|
+
const cached = depCacheByDir.get(currentDir);
|
|
207
|
+
if (cached)
|
|
208
|
+
return cached;
|
|
209
|
+
const packageJsonPath = path.join(currentDir, 'package.json');
|
|
210
|
+
if (await fs.pathExists(packageJsonPath)) {
|
|
211
|
+
try {
|
|
212
|
+
const packageJson = await fs.readJson(packageJsonPath);
|
|
213
|
+
const deps = new Set([
|
|
214
|
+
...rootDeps,
|
|
215
|
+
...Object.keys(packageJson?.dependencies || {}),
|
|
216
|
+
...Object.keys(packageJson?.devDependencies || {}),
|
|
217
|
+
...Object.keys(packageJson?.peerDependencies || {}),
|
|
218
|
+
...Object.keys(packageJson?.optionalDependencies || {}),
|
|
219
|
+
]);
|
|
220
|
+
depCacheByDir.set(currentDir, deps);
|
|
221
|
+
return deps;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
depCacheByDir.set(currentDir, rootDeps);
|
|
225
|
+
return rootDeps;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const parent = path.dirname(currentDir);
|
|
229
|
+
if (parent === currentDir)
|
|
230
|
+
break;
|
|
231
|
+
currentDir = parent;
|
|
232
|
+
}
|
|
233
|
+
return rootDeps;
|
|
234
|
+
}
|
|
235
|
+
async resolveTsPathAlias(file, importPath, cwd, projectFiles, tsPathCacheByDir) {
|
|
236
|
+
const config = await this.resolveTsPathConfigForFile(file, cwd, tsPathCacheByDir);
|
|
237
|
+
if (!config || config.rules.length === 0)
|
|
238
|
+
return null;
|
|
239
|
+
for (const rule of config.rules) {
|
|
240
|
+
const wildcard = this.matchTsPathRule(rule, importPath);
|
|
241
|
+
if (wildcard === null)
|
|
242
|
+
continue;
|
|
243
|
+
for (const target of rule.targets) {
|
|
244
|
+
const candidatePattern = rule.hasWildcard ? target.replace('*', wildcard) : target;
|
|
245
|
+
if (this.resolveTsPathTarget(config.baseDir, candidatePattern, cwd, projectFiles)) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
matchTsPathRule(rule, importPath) {
|
|
254
|
+
if (!rule.hasWildcard) {
|
|
255
|
+
return importPath === rule.key ? '' : null;
|
|
256
|
+
}
|
|
257
|
+
if (!importPath.startsWith(rule.prefix) || !importPath.endsWith(rule.suffix)) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return importPath.slice(rule.prefix.length, importPath.length - rule.suffix.length);
|
|
261
|
+
}
|
|
262
|
+
resolveTsPathTarget(baseDir, candidatePattern, cwd, projectFiles) {
|
|
263
|
+
const absolute = path.resolve(baseDir, candidatePattern);
|
|
264
|
+
const relative = path.relative(cwd, absolute).replace(/\\/g, '/');
|
|
265
|
+
const normalized = relative.replace(/\/$/, '');
|
|
266
|
+
const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.d.ts'];
|
|
267
|
+
const candidates = [
|
|
268
|
+
...extensions.map(ext => normalized + ext),
|
|
269
|
+
...extensions.map(ext => `${normalized}/index${ext}`),
|
|
270
|
+
];
|
|
271
|
+
return candidates.some(c => projectFiles.has(c));
|
|
272
|
+
}
|
|
273
|
+
async resolveTsPathConfigForFile(file, cwd, tsPathCacheByDir) {
|
|
274
|
+
const rootDir = path.resolve(cwd);
|
|
275
|
+
let currentDir = path.dirname(path.resolve(cwd, file));
|
|
276
|
+
while (currentDir.startsWith(rootDir)) {
|
|
277
|
+
if (tsPathCacheByDir.has(currentDir)) {
|
|
278
|
+
const cached = tsPathCacheByDir.get(currentDir) || null;
|
|
279
|
+
if (cached)
|
|
280
|
+
return cached;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
const config = await this.loadTsPathConfig(currentDir);
|
|
284
|
+
tsPathCacheByDir.set(currentDir, config);
|
|
285
|
+
if (config)
|
|
286
|
+
return config;
|
|
287
|
+
}
|
|
288
|
+
const parent = path.dirname(currentDir);
|
|
289
|
+
if (parent === currentDir)
|
|
290
|
+
break;
|
|
291
|
+
currentDir = parent;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
async loadTsPathConfig(searchDir) {
|
|
296
|
+
const candidates = ['tsconfig.json', 'jsconfig.json', 'tsconfig.base.json'];
|
|
297
|
+
for (const configName of candidates) {
|
|
298
|
+
const configPath = path.join(searchDir, configName);
|
|
299
|
+
if (!(await fs.pathExists(configPath)))
|
|
300
|
+
continue;
|
|
301
|
+
const parsed = await this.readLooseJson(configPath);
|
|
302
|
+
const compilerOptions = parsed?.compilerOptions || {};
|
|
303
|
+
const paths = compilerOptions.paths;
|
|
304
|
+
if (!paths || typeof paths !== 'object')
|
|
305
|
+
continue;
|
|
306
|
+
const baseUrl = typeof compilerOptions.baseUrl === 'string' ? compilerOptions.baseUrl : '.';
|
|
307
|
+
const baseDir = path.resolve(searchDir, baseUrl);
|
|
308
|
+
const rules = [];
|
|
309
|
+
for (const [key, value] of Object.entries(paths)) {
|
|
310
|
+
if (typeof key !== 'string' || !Array.isArray(value) || value.length === 0)
|
|
311
|
+
continue;
|
|
312
|
+
const hasWildcard = key.includes('*');
|
|
313
|
+
const [prefix, suffix = ''] = key.split('*');
|
|
314
|
+
const targets = value.filter(v => typeof v === 'string');
|
|
315
|
+
if (targets.length === 0)
|
|
316
|
+
continue;
|
|
317
|
+
rules.push({ key, hasWildcard, prefix, suffix, targets });
|
|
318
|
+
}
|
|
319
|
+
if (rules.length === 0)
|
|
320
|
+
continue;
|
|
321
|
+
return { baseDir, rules };
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
async readLooseJson(filePath) {
|
|
326
|
+
try {
|
|
327
|
+
const text = await fs.readFile(filePath, 'utf-8');
|
|
328
|
+
try {
|
|
329
|
+
return JSON.parse(text);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
const noBlockComments = text.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
333
|
+
const noLineComments = noBlockComments.replace(/(^|\s)\/\/.*$/gm, '$1');
|
|
334
|
+
const noTrailingCommas = noLineComments.replace(/,\s*([}\]])/g, '$1');
|
|
335
|
+
return JSON.parse(noTrailingCommas);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
166
342
|
async checkPyImports(content, file, cwd, projectFiles, hallucinated) {
|
|
167
343
|
const lines = content.split('\n');
|
|
168
344
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -223,13 +399,7 @@ export class HallucinatedImportsGate extends Gate {
|
|
|
223
399
|
resolveRelativeImport(fromFile, importPath, projectFiles) {
|
|
224
400
|
const dir = path.dirname(fromFile);
|
|
225
401
|
const resolved = path.join(dir, importPath).replace(/\\/g, '/');
|
|
226
|
-
|
|
227
|
-
const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
228
|
-
const indexFiles = extensions.map(ext => `${resolved}/index${ext}`);
|
|
229
|
-
const candidates = [
|
|
230
|
-
...extensions.map(ext => resolved + ext),
|
|
231
|
-
...indexFiles,
|
|
232
|
-
];
|
|
402
|
+
const candidates = this.buildImportCandidates(resolved);
|
|
233
403
|
return candidates.some(c => projectFiles.has(c));
|
|
234
404
|
}
|
|
235
405
|
extractPackageName(importPath) {
|
|
@@ -244,4 +414,41 @@ export class HallucinatedImportsGate extends Gate {
|
|
|
244
414
|
shouldIgnore(importPath) {
|
|
245
415
|
return this.config.ignore_patterns.some(pattern => new RegExp(pattern).test(importPath));
|
|
246
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Build candidate source paths for an import.
|
|
419
|
+
* Handles ESM-style TS source imports like "./foo.js" that map to "./foo.ts" pre-build.
|
|
420
|
+
*/
|
|
421
|
+
buildImportCandidates(resolvedPath) {
|
|
422
|
+
const extension = path.extname(resolvedPath).toLowerCase();
|
|
423
|
+
const sourceExtensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.d.ts'];
|
|
424
|
+
const runtimeExtensions = new Set(['.js', '.jsx', '.mjs', '.cjs']);
|
|
425
|
+
let candidates = [];
|
|
426
|
+
if (runtimeExtensions.has(extension)) {
|
|
427
|
+
const withoutExt = resolvedPath.slice(0, -extension.length);
|
|
428
|
+
candidates = [
|
|
429
|
+
...sourceExtensions.map(ext => withoutExt + ext),
|
|
430
|
+
...sourceExtensions.map(ext => `${withoutExt}/index${ext}`),
|
|
431
|
+
resolvedPath,
|
|
432
|
+
`${resolvedPath}/index`,
|
|
433
|
+
];
|
|
434
|
+
}
|
|
435
|
+
else if (extension) {
|
|
436
|
+
candidates = [resolvedPath, `${resolvedPath}/index`];
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
candidates = [
|
|
440
|
+
...sourceExtensions.map(ext => resolvedPath + ext),
|
|
441
|
+
...sourceExtensions.map(ext => `${resolvedPath}/index${ext}`),
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
return [...new Set(candidates)];
|
|
445
|
+
}
|
|
446
|
+
shouldSkipFile(file) {
|
|
447
|
+
const normalized = file.replace(/\\/g, '/');
|
|
448
|
+
return (normalized.includes('/examples/') ||
|
|
449
|
+
normalized.includes('/studio-dist/') ||
|
|
450
|
+
normalized.includes('/__tests__/') ||
|
|
451
|
+
/\.test\.[^.]+$/i.test(normalized) ||
|
|
452
|
+
/\.spec\.[^.]+$/i.test(normalized));
|
|
453
|
+
}
|
|
247
454
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
12
|
import { HallucinatedImportsGate } from './hallucinated-imports.js';
|
|
13
|
+
import path from 'path';
|
|
13
14
|
// Mock fs-extra — vi.hoisted ensures these are available when vi.mock runs (hoisted)
|
|
14
15
|
const { mockPathExists, mockPathExistsSync, mockReadFile, mockReadFileSync, mockReadJson, mockReaddirSync } = vi.hoisted(() => ({
|
|
15
16
|
mockPathExists: vi.fn(),
|
|
@@ -40,6 +41,7 @@ vi.mock('../utils/scanner.js', () => ({
|
|
|
40
41
|
},
|
|
41
42
|
}));
|
|
42
43
|
import { FileScanner } from '../utils/scanner.js';
|
|
44
|
+
const normalizePath = (input) => input.replace(/\\/g, '/');
|
|
43
45
|
// ═══════════════════════════════════════════════════════════════
|
|
44
46
|
// GO
|
|
45
47
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -253,7 +255,8 @@ from urllib.parse import urlparse
|
|
|
253
255
|
// ═══════════════════════════════════════════════════════════════
|
|
254
256
|
describe('HallucinatedImportsGate — JS/TS Node builtins', () => {
|
|
255
257
|
let gate;
|
|
256
|
-
const testCwd = '/tmp/test-node-project';
|
|
258
|
+
const testCwd = path.resolve('/tmp/test-node-project');
|
|
259
|
+
const testCwdNormalized = normalizePath(testCwd);
|
|
257
260
|
const context = { cwd: testCwd, ignore: [] };
|
|
258
261
|
beforeEach(() => {
|
|
259
262
|
vi.clearAllMocks();
|
|
@@ -296,6 +299,166 @@ import { ReadableStream } from 'stream/web';
|
|
|
296
299
|
const failures = await gate.run(context);
|
|
297
300
|
expect(failures).toHaveLength(0);
|
|
298
301
|
});
|
|
302
|
+
it('should resolve dependencies from nearest package.json in monorepos', async () => {
|
|
303
|
+
const jsContent = `
|
|
304
|
+
import { app } from 'electron';
|
|
305
|
+
import React from 'react';
|
|
306
|
+
`;
|
|
307
|
+
FileScanner.findFiles.mockResolvedValue(['apps/desktop/src/main.ts']);
|
|
308
|
+
mockReadFile.mockResolvedValue(jsContent);
|
|
309
|
+
mockPathExists.mockImplementation(async (p) => {
|
|
310
|
+
const normalized = normalizePath(p);
|
|
311
|
+
return normalized === `${testCwdNormalized}/apps/desktop/package.json`
|
|
312
|
+
|| normalized === `${testCwdNormalized}/package.json`
|
|
313
|
+
|| normalized.includes('/node_modules/electron')
|
|
314
|
+
|| normalized.includes('/node_modules/react');
|
|
315
|
+
});
|
|
316
|
+
mockReadJson.mockImplementation(async (p) => {
|
|
317
|
+
const normalized = normalizePath(p);
|
|
318
|
+
if (normalized.endsWith('/apps/desktop/package.json')) {
|
|
319
|
+
return {
|
|
320
|
+
dependencies: { electron: '^31.0.0', react: '^18.0.0' },
|
|
321
|
+
devDependencies: {},
|
|
322
|
+
peerDependencies: {},
|
|
323
|
+
optionalDependencies: {},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// Root package.json should not incorrectly block desktop deps
|
|
327
|
+
return {
|
|
328
|
+
dependencies: {},
|
|
329
|
+
devDependencies: {},
|
|
330
|
+
peerDependencies: {},
|
|
331
|
+
optionalDependencies: {},
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
const failures = await gate.run(context);
|
|
335
|
+
expect(failures).toHaveLength(0);
|
|
336
|
+
});
|
|
337
|
+
it('should NOT flag tsconfig path aliases that resolve in monorepos', async () => {
|
|
338
|
+
const jsContent = `
|
|
339
|
+
import { logger } from '@/utils/logger';
|
|
340
|
+
import { cfg } from '~shared/config';
|
|
341
|
+
`;
|
|
342
|
+
const tsconfigContent = `{
|
|
343
|
+
"compilerOptions": {
|
|
344
|
+
"baseUrl": ".",
|
|
345
|
+
"paths": {
|
|
346
|
+
"@/*": ["src/*"],
|
|
347
|
+
"~shared/*": ["../shared/src/*"]
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}`;
|
|
351
|
+
FileScanner.findFiles.mockResolvedValue([
|
|
352
|
+
'apps/desktop/src/main.ts',
|
|
353
|
+
'apps/desktop/src/utils/logger.ts',
|
|
354
|
+
'apps/shared/src/config.ts',
|
|
355
|
+
]);
|
|
356
|
+
mockReadFile.mockImplementation(async (p) => {
|
|
357
|
+
const normalized = normalizePath(p);
|
|
358
|
+
if (normalized.endsWith('/apps/desktop/tsconfig.json'))
|
|
359
|
+
return tsconfigContent;
|
|
360
|
+
if (normalized.endsWith('/apps/desktop/src/main.ts'))
|
|
361
|
+
return jsContent;
|
|
362
|
+
return 'export const ok = true;';
|
|
363
|
+
});
|
|
364
|
+
mockPathExists.mockImplementation(async (p) => {
|
|
365
|
+
const normalized = normalizePath(p);
|
|
366
|
+
return normalized === `${testCwdNormalized}/apps/desktop/tsconfig.json`
|
|
367
|
+
|| normalized === `${testCwdNormalized}/package.json`;
|
|
368
|
+
});
|
|
369
|
+
mockReadJson.mockResolvedValue({
|
|
370
|
+
dependencies: {},
|
|
371
|
+
devDependencies: {},
|
|
372
|
+
peerDependencies: {},
|
|
373
|
+
optionalDependencies: {},
|
|
374
|
+
});
|
|
375
|
+
const failures = await gate.run(context);
|
|
376
|
+
expect(failures).toHaveLength(0);
|
|
377
|
+
});
|
|
378
|
+
it('should flag tsconfig path aliases when target does not resolve', async () => {
|
|
379
|
+
const jsContent = `import { logger } from '@/utils/missing';`;
|
|
380
|
+
const tsconfigContent = `{
|
|
381
|
+
"compilerOptions": {
|
|
382
|
+
"baseUrl": ".",
|
|
383
|
+
"paths": {
|
|
384
|
+
"@/*": ["src/*"]
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}`;
|
|
388
|
+
FileScanner.findFiles.mockResolvedValue(['apps/desktop/src/main.ts']);
|
|
389
|
+
mockReadFile.mockImplementation(async (p) => {
|
|
390
|
+
const normalized = normalizePath(p);
|
|
391
|
+
if (normalized.endsWith('/apps/desktop/tsconfig.json'))
|
|
392
|
+
return tsconfigContent;
|
|
393
|
+
return jsContent;
|
|
394
|
+
});
|
|
395
|
+
mockPathExists.mockImplementation(async (p) => {
|
|
396
|
+
const normalized = normalizePath(p);
|
|
397
|
+
return normalized === `${testCwdNormalized}/apps/desktop/tsconfig.json`
|
|
398
|
+
|| normalized === `${testCwdNormalized}/package.json`;
|
|
399
|
+
});
|
|
400
|
+
mockReadJson.mockResolvedValue({
|
|
401
|
+
dependencies: {},
|
|
402
|
+
devDependencies: {},
|
|
403
|
+
peerDependencies: {},
|
|
404
|
+
optionalDependencies: {},
|
|
405
|
+
});
|
|
406
|
+
const failures = await gate.run(context);
|
|
407
|
+
expect(failures).toHaveLength(1);
|
|
408
|
+
// Depending on tsconfig resolution context, this may surface as
|
|
409
|
+
// a direct alias resolution failure OR a missing package fallback.
|
|
410
|
+
const details = failures[0].details;
|
|
411
|
+
expect(details.includes("Path alias '@/utils/missing' does not resolve to a project file")
|
|
412
|
+
|| details.includes("Package '@/utils' not in package.json dependencies")).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
it('should NOT flag ESM .js specifiers that resolve to .ts source files', async () => {
|
|
415
|
+
const jsContent = `
|
|
416
|
+
import { helper } from './utils.js';
|
|
417
|
+
`;
|
|
418
|
+
FileScanner.findFiles.mockResolvedValue(['src/main.ts', 'src/utils.ts']);
|
|
419
|
+
mockReadFile.mockImplementation(async (p) => {
|
|
420
|
+
const normalized = p.replace(/\\/g, '/');
|
|
421
|
+
if (normalized.endsWith('/src/main.ts'))
|
|
422
|
+
return jsContent;
|
|
423
|
+
return 'export const helper = () => 42;';
|
|
424
|
+
});
|
|
425
|
+
mockPathExists.mockImplementation(async (p) => {
|
|
426
|
+
const normalized = p.replace(/\\/g, '/');
|
|
427
|
+
return normalized === '/tmp/test-node-project/package.json';
|
|
428
|
+
});
|
|
429
|
+
mockReadJson.mockResolvedValue({
|
|
430
|
+
dependencies: {},
|
|
431
|
+
devDependencies: {},
|
|
432
|
+
peerDependencies: {},
|
|
433
|
+
optionalDependencies: {},
|
|
434
|
+
});
|
|
435
|
+
const failures = await gate.run(context);
|
|
436
|
+
expect(failures).toHaveLength(0);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
describe('HallucinatedImportsGate — ignore generated/test artifacts', () => {
|
|
440
|
+
let gate;
|
|
441
|
+
const testCwd = '/tmp/test-ignore-project';
|
|
442
|
+
const context = { cwd: testCwd, ignore: [] };
|
|
443
|
+
beforeEach(() => {
|
|
444
|
+
vi.clearAllMocks();
|
|
445
|
+
mockReaddirSync.mockReturnValue([]);
|
|
446
|
+
gate = new HallucinatedImportsGate({ enabled: true });
|
|
447
|
+
});
|
|
448
|
+
it('skips studio-dist files by default', async () => {
|
|
449
|
+
FileScanner.findFiles.mockResolvedValue(['packages/rigour-cli/studio-dist/assets/index.js']);
|
|
450
|
+
mockReadFile.mockResolvedValue(`import 'definitely-not-a-real-package';`);
|
|
451
|
+
mockPathExists.mockResolvedValue(false);
|
|
452
|
+
const failures = await gate.run(context);
|
|
453
|
+
expect(failures).toHaveLength(0);
|
|
454
|
+
});
|
|
455
|
+
it('skips test files by default', async () => {
|
|
456
|
+
FileScanner.findFiles.mockResolvedValue(['src/example.test.ts']);
|
|
457
|
+
mockReadFile.mockResolvedValue(`import 'totally-not-installed';`);
|
|
458
|
+
mockPathExists.mockResolvedValue(false);
|
|
459
|
+
const failures = await gate.run(context);
|
|
460
|
+
expect(failures).toHaveLength(0);
|
|
461
|
+
});
|
|
299
462
|
});
|
|
300
463
|
// ═══════════════════════════════════════════════════════════════
|
|
301
464
|
// RUBY
|