@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.
Files changed (49) hide show
  1. package/dist/gates/ast-handlers/typescript.js +39 -12
  2. package/dist/gates/ast-handlers/universal.js +9 -3
  3. package/dist/gates/ast.js +15 -1
  4. package/dist/gates/ast.test.d.ts +1 -0
  5. package/dist/gates/ast.test.js +112 -0
  6. package/dist/gates/content.d.ts +5 -0
  7. package/dist/gates/content.js +66 -7
  8. package/dist/gates/content.test.d.ts +1 -0
  9. package/dist/gates/content.test.js +73 -0
  10. package/dist/gates/context-window-artifacts.d.ts +1 -0
  11. package/dist/gates/context-window-artifacts.js +10 -3
  12. package/dist/gates/context.d.ts +1 -0
  13. package/dist/gates/context.js +29 -8
  14. package/dist/gates/deep-analysis.js +2 -2
  15. package/dist/gates/deprecated-apis.d.ts +1 -0
  16. package/dist/gates/deprecated-apis.js +15 -2
  17. package/dist/gates/hallucinated-imports.d.ts +14 -0
  18. package/dist/gates/hallucinated-imports.js +267 -60
  19. package/dist/gates/hallucinated-imports.test.js +164 -1
  20. package/dist/gates/inconsistent-error-handling.d.ts +1 -0
  21. package/dist/gates/inconsistent-error-handling.js +12 -1
  22. package/dist/gates/phantom-apis.d.ts +2 -0
  23. package/dist/gates/phantom-apis.js +28 -3
  24. package/dist/gates/phantom-apis.test.js +14 -0
  25. package/dist/gates/promise-safety.d.ts +2 -0
  26. package/dist/gates/promise-safety.js +31 -9
  27. package/dist/gates/runner.js +8 -2
  28. package/dist/gates/runner.test.d.ts +1 -0
  29. package/dist/gates/runner.test.js +65 -0
  30. package/dist/gates/security-patterns.d.ts +1 -0
  31. package/dist/gates/security-patterns.js +22 -6
  32. package/dist/gates/security-patterns.test.js +18 -0
  33. package/dist/hooks/templates.d.ts +1 -1
  34. package/dist/hooks/templates.js +12 -12
  35. package/dist/inference/executable.d.ts +6 -0
  36. package/dist/inference/executable.js +29 -0
  37. package/dist/inference/executable.test.d.ts +1 -0
  38. package/dist/inference/executable.test.js +41 -0
  39. package/dist/inference/model-manager.d.ts +3 -1
  40. package/dist/inference/model-manager.js +76 -8
  41. package/dist/inference/model-manager.test.d.ts +1 -0
  42. package/dist/inference/model-manager.test.js +24 -0
  43. package/dist/inference/sidecar-provider.d.ts +1 -0
  44. package/dist/inference/sidecar-provider.js +124 -31
  45. package/dist/services/context-engine.js +1 -1
  46. package/dist/templates/universal-config.js +3 -3
  47. package/dist/types/index.js +3 -3
  48. package/dist/utils/scanner.js +6 -0
  49. 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
- Logger.info(`Deprecated APIs: Scanning ${files.length} files`);
60
- for (const file of files) {
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
- Logger.info(`Hallucinated Imports: Scanning ${files.length} files`);
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(files.map(f => f.replace(/\\/g, '/')));
64
+ const projectFiles = new Set(analyzableFiles.map(f => f.replace(/\\/g, '/')));
60
65
  const packageJson = await loadPackageJson(context.cwd);
61
- const allDeps = new Set([
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 files) {
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, allDeps, hasNodeModules, hallucinated);
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, allDeps, hasNodeModules, hallucinated) {
111
- const lines = content.split('\n');
112
- // Match: import ... from '...', require('...'), import('...')
113
- const importPatterns = [
114
- /import\s+(?:{[^}]*}|\*\s+as\s+\w+|\w+(?:\s*,\s*{[^}]*})?)\s+from\s+['"]([^'"]+)['"]/g,
115
- /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
116
- /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
117
- /export\s+(?:{[^}]*}|\*)\s+from\s+['"]([^'"]+)['"]/g,
118
- ];
119
- for (let i = 0; i < lines.length; i++) {
120
- const line = lines[i];
121
- for (const pattern of importPatterns) {
122
- pattern.lastIndex = 0;
123
- let match;
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
- else {
142
- // Package import — check it exists
143
- if (this.config.check_packages) {
144
- const pkgName = this.extractPackageName(importPath);
145
- // Skip Node.js built-ins
146
- if (isNodeBuiltin(pkgName))
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
- // Try exact match, then common extensions
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
@@ -36,4 +36,5 @@ export declare class InconsistentErrorHandlingGate extends Gate {
36
36
  private classifyStrategy;
37
37
  private extractCatchBody;
38
38
  private extractCatchCallbackBody;
39
+ private shouldSkipFile;
39
40
  }