@rigour-labs/core 4.1.1 → 4.2.1

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.
@@ -9,8 +9,19 @@ export declare function loadRubyGems(cwd: string): Set<string>;
9
9
  export declare function checkCSharpImports(content: string, file: string, cwd: string, projectFiles: Set<string>, hallucinated: HallucinatedImport[]): void;
10
10
  export declare function hasCsprojFile(cwd: string): boolean;
11
11
  export declare function loadNuGetPackages(cwd: string): Set<string>;
12
+ /**
13
+ * Search for .csproj files by walking up from the C# file's directory.
14
+ * Monorepo support: a C# file in tests/csharp/MyProject/ needs to find .csproj
15
+ * in that directory, not just the project root.
16
+ */
17
+ export declare function loadNuGetPackagesForFile(file: string, cwd: string): Set<string>;
12
18
  export declare function checkRustImports(content: string, file: string, cwd: string, projectFiles: Set<string>, hallucinated: HallucinatedImport[]): void;
13
19
  export declare function loadCargoDeps(cwd: string): Set<string>;
14
20
  export declare function checkJavaKotlinImports(content: string, file: string, ext: string, cwd: string, projectFiles: Set<string>, hallucinated: HallucinatedImport[]): void;
15
21
  export declare function loadJavaDeps(cwd: string): Set<string>;
22
+ /**
23
+ * Search for build.gradle/pom.xml by walking up from the Java/Kotlin file's directory.
24
+ * Monorepo support: a Java file in sdks/sandbox/java/ needs to find build.gradle there.
25
+ */
26
+ export declare function loadJavaDepsForFile(file: string, cwd: string): Set<string>;
16
27
  export declare function loadPackageJson(cwd: string): Promise<any>;
@@ -8,18 +8,30 @@ import { isGoStdlib, isRubyStdlib, isDotNetFramework, isRustStdCrate, isJavaStdl
8
8
  export function checkGoImports(content, file, cwd, projectFiles, hallucinated) {
9
9
  const lines = content.split('\n');
10
10
  let inImportBlock = false;
11
- // Try to read go.mod for the module path
12
- const goModPath = path.join(cwd, 'go.mod');
11
+ // Find go.mod by walking up from the Go file's directory (monorepo support).
12
+ // A monorepo may have go.mod in subdirectories like kubernetes/go.mod, server/go.mod, etc.
13
13
  let modulePath = null;
14
- try {
15
- if (fs.pathExistsSync(goModPath)) {
16
- const goMod = fs.readFileSync(goModPath, 'utf-8');
17
- const moduleMatch = goMod.match(/^module\s+(\S+)/m);
18
- if (moduleMatch)
19
- modulePath = moduleMatch[1];
14
+ const fileAbsDir = path.dirname(path.resolve(cwd, file));
15
+ const rootDir = path.resolve(cwd);
16
+ let searchDir = fileAbsDir;
17
+ while (searchDir.startsWith(rootDir) || searchDir === rootDir) {
18
+ const goModPath = path.join(searchDir, 'go.mod');
19
+ try {
20
+ if (fs.pathExistsSync(goModPath)) {
21
+ const goMod = fs.readFileSync(goModPath, 'utf-8');
22
+ const moduleMatch = goMod.match(/^module\s+(\S+)/m);
23
+ if (moduleMatch) {
24
+ modulePath = moduleMatch[1];
25
+ break;
26
+ }
27
+ }
20
28
  }
29
+ catch { /* skip */ }
30
+ const parent = path.dirname(searchDir);
31
+ if (parent === searchDir)
32
+ break;
33
+ searchDir = parent;
21
34
  }
22
- catch { /* no go.mod — skip project-relative checks entirely */ }
23
35
  for (let i = 0; i < lines.length; i++) {
24
36
  const line = lines[i].trim();
25
37
  // Detect import block: import ( ... )
@@ -150,7 +162,8 @@ export function loadRubyGems(cwd) {
150
162
  }
151
163
  export function checkCSharpImports(content, file, cwd, projectFiles, hallucinated) {
152
164
  const lines = content.split('\n');
153
- const nugetPackages = loadNuGetPackages(cwd);
165
+ // Search for .csproj in file's directory and parent dirs (monorepo support)
166
+ const nugetPackages = loadNuGetPackagesForFile(file, cwd);
154
167
  for (let i = 0; i < lines.length; i++) {
155
168
  const line = lines[i].trim();
156
169
  // Match: using Namespace; and using static Namespace.Class;
@@ -178,7 +191,7 @@ export function checkCSharpImports(content, file, cwd, projectFiles, hallucinate
178
191
  // Only flag if we have .csproj context (proves this is a real .NET project)
179
192
  if (!hasMatch && namespace.includes('.') && nugetPackages.size >= 0) {
180
193
  // Check if we actually have .csproj context (a real .NET project)
181
- const hasCsproj = hasCsprojFile(cwd);
194
+ const hasCsproj = nugetPackages.size > 0 || hasCsprojFile(cwd);
182
195
  if (hasCsproj) {
183
196
  hallucinated.push({
184
197
  file, line: i + 1, importPath: namespace, type: 'csharp',
@@ -220,6 +233,26 @@ export function loadNuGetPackages(cwd) {
220
233
  catch { /* no .csproj */ }
221
234
  return packages;
222
235
  }
236
+ /**
237
+ * Search for .csproj files by walking up from the C# file's directory.
238
+ * Monorepo support: a C# file in tests/csharp/MyProject/ needs to find .csproj
239
+ * in that directory, not just the project root.
240
+ */
241
+ export function loadNuGetPackagesForFile(file, cwd) {
242
+ const allPackages = new Set();
243
+ const rootDir = path.resolve(cwd);
244
+ let searchDir = path.dirname(path.resolve(cwd, file));
245
+ while (searchDir.startsWith(rootDir) || searchDir === rootDir) {
246
+ const pkgs = loadNuGetPackages(searchDir);
247
+ for (const p of pkgs)
248
+ allPackages.add(p);
249
+ const parent = path.dirname(searchDir);
250
+ if (parent === searchDir)
251
+ break;
252
+ searchDir = parent;
253
+ }
254
+ return allPackages;
255
+ }
223
256
  export function checkRustImports(content, file, cwd, projectFiles, hallucinated) {
224
257
  const lines = content.split('\n');
225
258
  const cargoDeps = loadCargoDeps(cwd);
@@ -290,7 +323,8 @@ export function loadCargoDeps(cwd) {
290
323
  }
291
324
  export function checkJavaKotlinImports(content, file, ext, cwd, projectFiles, hallucinated) {
292
325
  const lines = content.split('\n');
293
- const buildDeps = loadJavaDeps(cwd);
326
+ // Search for build.gradle/pom.xml by walking up from the file's directory (monorepo support)
327
+ const buildDeps = loadJavaDepsForFile(file, cwd);
294
328
  const isKotlin = ext === '.kt';
295
329
  for (let i = 0; i < lines.length; i++) {
296
330
  const line = lines[i].trim();
@@ -362,6 +396,25 @@ export function loadJavaDeps(cwd) {
362
396
  catch { /* no build files */ }
363
397
  return deps;
364
398
  }
399
+ /**
400
+ * Search for build.gradle/pom.xml by walking up from the Java/Kotlin file's directory.
401
+ * Monorepo support: a Java file in sdks/sandbox/java/ needs to find build.gradle there.
402
+ */
403
+ export function loadJavaDepsForFile(file, cwd) {
404
+ const allDeps = new Set();
405
+ const rootDir = path.resolve(cwd);
406
+ let searchDir = path.dirname(path.resolve(cwd, file));
407
+ while (searchDir.startsWith(rootDir) || searchDir === rootDir) {
408
+ const deps = loadJavaDeps(searchDir);
409
+ for (const d of deps)
410
+ allDeps.add(d);
411
+ const parent = path.dirname(searchDir);
412
+ if (parent === searchDir)
413
+ break;
414
+ searchDir = parent;
415
+ }
416
+ return allDeps;
417
+ }
365
418
  export async function loadPackageJson(cwd) {
366
419
  try {
367
420
  const pkgPath = path.join(cwd, 'package.json');
@@ -49,6 +49,16 @@ export declare class HallucinatedImportsGate extends Gate {
49
49
  private loadTsPathConfig;
50
50
  private readLooseJson;
51
51
  private checkPyImports;
52
+ /**
53
+ * Find Python source roots (directories that are on sys.path) by looking at
54
+ * pyproject.toml, setup.cfg, or common patterns like src/ layouts.
55
+ */
56
+ private findPythonSourceRoots;
57
+ /**
58
+ * Load installed Python package names from pyproject.toml dependencies,
59
+ * requirements.txt, setup.cfg, or Pipfile.
60
+ */
61
+ private loadPythonInstalledPackages;
52
62
  private resolveRelativeImport;
53
63
  private extractPackageName;
54
64
  private shouldIgnore;
@@ -341,6 +341,9 @@ export class HallucinatedImportsGate extends Gate {
341
341
  }
342
342
  async checkPyImports(content, file, cwd, projectFiles, hallucinated) {
343
343
  const lines = content.split('\n');
344
+ // Lazily resolve Python source roots and installed packages for this project
345
+ const pySourceRoots = await this.findPythonSourceRoots(cwd, projectFiles);
346
+ const pyInstalledPkgs = await this.loadPythonInstalledPackages(cwd);
344
347
  for (let i = 0; i < lines.length; i++) {
345
348
  const line = lines[i].trim();
346
349
  // Match: from X import Y, import X
@@ -354,13 +357,35 @@ export class HallucinatedImportsGate extends Gate {
354
357
  continue;
355
358
  // Check if it's a relative project import
356
359
  if (modulePath.startsWith('.')) {
357
- // Relative Python import
358
- const pyFile = modulePath.replace(/\./g, '/') + '.py';
359
- const pyInit = modulePath.replace(/\./g, '/') + '/__init__.py';
360
- const fileDir = path.dirname(file);
361
- const resolved1 = path.join(fileDir, pyFile).replace(/\\/g, '/');
362
- const resolved2 = path.join(fileDir, pyInit).replace(/\\/g, '/');
363
- if (!projectFiles.has(resolved1) && !projectFiles.has(resolved2)) {
360
+ // Python relative import: count leading dots to determine traversal depth.
361
+ // N dots = go up (N-1) package levels from the file's directory.
362
+ // from .types import X → 1 dot = current package (0 levels up)
363
+ // from ..types import X → 2 dots = parent package (1 level up)
364
+ // from ...client import X → 3 dots = grandparent package (2 levels up)
365
+ const dotMatch = modulePath.match(/^(\.+)/);
366
+ const dotCount = dotMatch ? dotMatch[1].length : 0;
367
+ const moduleRest = modulePath.slice(dotCount); // e.g. 'client', 'types', 'models.permission', or '' for bare dots
368
+ // Walk up (dotCount - 1) directories from the file's directory
369
+ let baseDir = path.dirname(file);
370
+ for (let level = 1; level < dotCount; level++) {
371
+ const parent = path.dirname(baseDir);
372
+ if (parent === baseDir)
373
+ break; // at root
374
+ baseDir = parent;
375
+ }
376
+ // If moduleRest is empty (e.g. `from . import X`), we're just referencing the package directory.
377
+ // The imported names come from the `import` clause, not the module path — skip validation
378
+ // since bare-dot package references are almost never hallucinated.
379
+ if (!moduleRest)
380
+ continue;
381
+ // Resolve remaining module path within the target directory
382
+ const moduleParts = moduleRest.replace(/\./g, '/');
383
+ const candidateBase = path.join(baseDir, moduleParts).replace(/\\/g, '/');
384
+ const candidates = [
385
+ candidateBase + '.py',
386
+ candidateBase + '/__init__.py',
387
+ ];
388
+ if (!candidates.some(c => projectFiles.has(c))) {
364
389
  hallucinated.push({
365
390
  file, line: i + 1, importPath: modulePath, type: 'python',
366
391
  reason: `Relative module '${modulePath}' not found in project`,
@@ -368,33 +393,193 @@ export class HallucinatedImportsGate extends Gate {
368
393
  }
369
394
  }
370
395
  else {
371
- // Absolute import — check if it's a project module
396
+ // Absolute import — check if it's a project module or installed package
372
397
  const topLevel = modulePath.split('.')[0];
373
- const pyFile = topLevel + '.py';
374
- const pyInit = topLevel + '/__init__.py';
375
- // If it matches a project file, it's a local import verify it exists
376
- const isLocalModule = projectFiles.has(pyFile) || projectFiles.has(pyInit) ||
377
- [...projectFiles].some(f => f.startsWith(topLevel + '/'));
378
- // If not local and not stdlib, we can't easily verify pip packages
379
- // without a requirements.txt or pyproject.toml check
380
- if (isLocalModule) {
398
+ // Check if this is an installed package (from pyproject.toml, requirements.txt, etc.)
399
+ if (pyInstalledPkgs.has(topLevel) || pyInstalledPkgs.has(topLevel.replace(/_/g, '-'))) {
400
+ continue; // Known installed packageskip
401
+ }
402
+ // Check if it's a local module (searching across all Python source roots)
403
+ let foundLocal = false;
404
+ const searchRoots = ['', ...pySourceRoots]; // '' = project root
405
+ for (const root of searchRoots) {
406
+ const prefix = root ? root + '/' : '';
407
+ const pyFile = prefix + topLevel + '.py';
408
+ const pyInit = prefix + topLevel + '/__init__.py';
409
+ const dirPrefix = prefix + topLevel + '/';
410
+ const isLocalModule = projectFiles.has(pyFile) || projectFiles.has(pyInit) ||
411
+ [...projectFiles].some(f => f.startsWith(dirPrefix));
412
+ if (!isLocalModule)
413
+ continue;
414
+ foundLocal = true;
381
415
  // It's referencing a local module — verify the full path
382
- const fullModulePath = modulePath.replace(/\./g, '/');
416
+ const fullModulePath = prefix + modulePath.replace(/\./g, '/');
383
417
  const candidates = [
384
418
  fullModulePath + '.py',
385
419
  fullModulePath + '/__init__.py',
386
420
  ];
387
421
  const exists = candidates.some(c => projectFiles.has(c));
388
- if (!exists && modulePath.includes('.')) {
422
+ if (exists)
423
+ break; // Found it — no issue
424
+ if (modulePath.includes('.')) {
389
425
  // Only flag deep module paths that partially resolve
390
426
  hallucinated.push({
391
427
  file, line: i + 1, importPath: modulePath, type: 'python',
392
428
  reason: `Module '${modulePath}' partially resolves but target not found`,
393
429
  });
394
430
  }
431
+ break;
432
+ }
433
+ // If not local and not stdlib and not installed, we can't easily verify
434
+ // — skip silently rather than risk false positives
435
+ }
436
+ }
437
+ }
438
+ /**
439
+ * Find Python source roots (directories that are on sys.path) by looking at
440
+ * pyproject.toml, setup.cfg, or common patterns like src/ layouts.
441
+ */
442
+ async findPythonSourceRoots(cwd, projectFiles) {
443
+ const roots = [];
444
+ // Check pyproject.toml for package-dir or src layout hints
445
+ const pyprojectPath = path.join(cwd, 'pyproject.toml');
446
+ if (await fs.pathExists(pyprojectPath)) {
447
+ try {
448
+ const content = await fs.readFile(pyprojectPath, 'utf-8');
449
+ // Match [tool.setuptools.packages.find] where = ["src"] or similar
450
+ const whereMatch = content.match(/where\s*=\s*\[\s*"([^"]+)"\s*\]/);
451
+ if (whereMatch) {
452
+ roots.push(whereMatch[1]);
453
+ }
454
+ // Match package-dir patterns
455
+ const pkgDirMatch = content.match(/package-dir\s*=\s*\{\s*""\s*:\s*"([^"]+)"\s*\}/);
456
+ if (pkgDirMatch) {
457
+ roots.push(pkgDirMatch[1]);
458
+ }
459
+ }
460
+ catch { /* skip */ }
461
+ }
462
+ // Common source root patterns
463
+ const commonSrcDirs = ['src', 'lib', 'app'];
464
+ for (const dir of commonSrcDirs) {
465
+ if ([...projectFiles].some(f => f.startsWith(dir + '/') && f.endsWith('.py'))) {
466
+ if (!roots.includes(dir)) {
467
+ roots.push(dir);
468
+ }
469
+ }
470
+ }
471
+ // Also scan for directories containing __init__.py that aren't at root level
472
+ // (e.g. sdks/sandbox/python/src/ as a source root)
473
+ const pyprojectFiles = [...projectFiles].filter(f => f.endsWith('/pyproject.toml') || f === 'pyproject.toml');
474
+ for (const pf of pyprojectFiles) {
475
+ const pfDir = path.dirname(pf);
476
+ if (pfDir === '.')
477
+ continue;
478
+ // Check if this pyproject.toml has a src/ dir
479
+ const srcDir = pfDir + '/src';
480
+ if ([...projectFiles].some(f => f.startsWith(srcDir + '/') && f.endsWith('.py'))) {
481
+ if (!roots.includes(srcDir)) {
482
+ roots.push(srcDir);
483
+ }
484
+ }
485
+ }
486
+ return roots;
487
+ }
488
+ /**
489
+ * Load installed Python package names from pyproject.toml dependencies,
490
+ * requirements.txt, setup.cfg, or Pipfile.
491
+ */
492
+ async loadPythonInstalledPackages(cwd) {
493
+ const packages = new Set();
494
+ // Helper to normalize package names (PEP 503: lowercase, replace [-_.] with -)
495
+ const normalize = (name) => name.toLowerCase().replace(/[-_.]+/g, '-');
496
+ // Check pyproject.toml
497
+ const pyprojectPath = path.join(cwd, 'pyproject.toml');
498
+ if (await fs.pathExists(pyprojectPath)) {
499
+ try {
500
+ const content = await fs.readFile(pyprojectPath, 'utf-8');
501
+ // Match dependencies = ["fastapi>=0.100", "kubernetes", ...]
502
+ const depsMatch = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/g);
503
+ if (depsMatch) {
504
+ for (const block of depsMatch) {
505
+ const pkgs = block.match(/"([^">=<!\s\[]+)/g);
506
+ if (pkgs) {
507
+ for (const pkg of pkgs) {
508
+ const name = pkg.replace(/^"/, '').split(/[>=<!\[]/)[0].trim();
509
+ if (name && name !== 'dependencies') {
510
+ packages.add(normalize(name));
511
+ // Also add the import name (replace - with _)
512
+ packages.add(name.replace(/-/g, '_').toLowerCase());
513
+ }
514
+ }
515
+ }
516
+ }
517
+ }
518
+ // optional-dependencies
519
+ const optDepsMatch = content.match(/optional-dependencies\s*\]([\s\S]*?)(?:\n\[|\n$)/);
520
+ if (optDepsMatch) {
521
+ const pkgs = optDepsMatch[1].match(/"([^">=<!\s\[]+)/g);
522
+ if (pkgs) {
523
+ for (const pkg of pkgs) {
524
+ const name = pkg.replace(/^"/, '').split(/[>=<!\[]/)[0].trim();
525
+ if (name) {
526
+ packages.add(normalize(name));
527
+ packages.add(name.replace(/-/g, '_').toLowerCase());
528
+ }
529
+ }
530
+ }
531
+ }
532
+ }
533
+ catch { /* skip */ }
534
+ }
535
+ // Check requirements*.txt files
536
+ const reqFiles = ['requirements.txt', 'requirements-dev.txt', 'requirements_dev.txt'];
537
+ for (const reqFile of reqFiles) {
538
+ const reqPath = path.join(cwd, reqFile);
539
+ if (await fs.pathExists(reqPath)) {
540
+ try {
541
+ const content = await fs.readFile(reqPath, 'utf-8');
542
+ for (const line of content.split('\n')) {
543
+ const trimmed = line.trim();
544
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-'))
545
+ continue;
546
+ const name = trimmed.split(/[>=<!\[;@\s]/)[0].trim();
547
+ if (name) {
548
+ packages.add(normalize(name));
549
+ packages.add(name.replace(/-/g, '_').toLowerCase());
550
+ }
551
+ }
552
+ }
553
+ catch { /* skip */ }
554
+ }
555
+ }
556
+ // Also scan subdirectories for pyproject.toml (monorepo support)
557
+ const subPyprojects = ['server/pyproject.toml', 'api/pyproject.toml', 'backend/pyproject.toml'];
558
+ for (const sub of subPyprojects) {
559
+ const subPath = path.join(cwd, sub);
560
+ if (await fs.pathExists(subPath)) {
561
+ try {
562
+ const content = await fs.readFile(subPath, 'utf-8');
563
+ const depsMatch = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/g);
564
+ if (depsMatch) {
565
+ for (const block of depsMatch) {
566
+ const pkgs = block.match(/"([^">=<!\s\[]+)/g);
567
+ if (pkgs) {
568
+ for (const pkg of pkgs) {
569
+ const name = pkg.replace(/^"/, '').split(/[>=<!\[]/)[0].trim();
570
+ if (name && name !== 'dependencies') {
571
+ packages.add(normalize(name));
572
+ packages.add(name.replace(/-/g, '_').toLowerCase());
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
395
578
  }
579
+ catch { /* skip */ }
396
580
  }
397
581
  }
582
+ return packages;
398
583
  }
399
584
  resolveRelativeImport(fromFile, importPath, projectFiles) {
400
585
  const dir = path.dirname(fromFile);
@@ -43,6 +43,13 @@ export declare class PhantomApisGate extends Gate {
43
43
  protected get provenance(): Provenance;
44
44
  run(context: GateContext): Promise<Failure[]>;
45
45
  private shouldSkipFile;
46
+ /**
47
+ * Strip string literal contents from a line to prevent matching code-in-strings.
48
+ * Replaces the content inside quotes with spaces, preserving the quotes themselves.
49
+ * This prevents false positives when e.g. a Java test passes Python code as a string
50
+ * to a code interpreter sandbox.
51
+ */
52
+ private stripStringLiterals;
46
53
  /**
47
54
  * Node.js stdlib method verification.
48
55
  * For each known module, we maintain the actual exported methods.
@@ -71,9 +78,19 @@ export declare class PhantomApisGate extends Gate {
71
78
  */
72
79
  private checkCSharpPhantomApis;
73
80
  /**
74
- * Java/Kotlin phantom API detection — pattern-based.
81
+ * Java phantom API detection — pattern-based.
75
82
  * AI hallucinates Python/JS-style APIs on JDK classes.
83
+ * Strips string literal contents to avoid matching code-in-strings
84
+ * (e.g. Java test passing Python code `print('hello')` to a sandbox).
76
85
  */
77
86
  private checkJavaPhantomApis;
87
+ /**
88
+ * Kotlin phantom API detection — uses a SUBSET of Java rules.
89
+ * Kotlin has different syntax than Java, so rules must be filtered:
90
+ * - print() IS valid Kotlin (kotlin.io.print)
91
+ * - var x: Type = IS valid Kotlin syntax
92
+ * - Most other Java patterns (includes, slice, arrow syntax) still apply
93
+ */
94
+ private checkKotlinPhantomApis;
78
95
  private escapeRegex;
79
96
  }
@@ -73,9 +73,12 @@ export class PhantomApisGate extends Gate {
73
73
  else if (ext === '.cs' && this.config.check_csharp) {
74
74
  this.checkCSharpPhantomApis(content, file, phantoms);
75
75
  }
76
- else if ((ext === '.java' || ext === '.kt') && this.config.check_java) {
76
+ else if (ext === '.java' && this.config.check_java) {
77
77
  this.checkJavaPhantomApis(content, file, phantoms);
78
78
  }
79
+ else if (ext === '.kt' && this.config.check_java) {
80
+ this.checkKotlinPhantomApis(content, file, phantoms);
81
+ }
79
82
  }
80
83
  catch { /* skip unreadable files */ }
81
84
  }
@@ -99,7 +102,33 @@ export class PhantomApisGate extends Gate {
99
102
  normalized.includes('/__tests__/') ||
100
103
  normalized.endsWith('/phantom-apis-data.ts') ||
101
104
  /\.test\.[^.]+$/i.test(normalized) ||
102
- /\.spec\.[^.]+$/i.test(normalized));
105
+ /\.spec\.[^.]+$/i.test(normalized) ||
106
+ // Additional test file patterns (Python, Go, Java)
107
+ /\/tests\//.test(`/${normalized}`) ||
108
+ /\/test\//.test(`/${normalized}`) ||
109
+ /_test\.go$/i.test(normalized) ||
110
+ /(?:^|\/)test_[^/]+\.py$/i.test(normalized) ||
111
+ /(?:^|\/)conftest\.py$/i.test(normalized) ||
112
+ /\/e2e\//.test(`/${normalized}`) ||
113
+ /E2E/i.test(path.basename(normalized)));
114
+ }
115
+ /**
116
+ * Strip string literal contents from a line to prevent matching code-in-strings.
117
+ * Replaces the content inside quotes with spaces, preserving the quotes themselves.
118
+ * This prevents false positives when e.g. a Java test passes Python code as a string
119
+ * to a code interpreter sandbox.
120
+ */
121
+ stripStringLiterals(line) {
122
+ // Replace content inside triple-quoted strings (Python/Kotlin)
123
+ let result = line.replace(/"""[\s\S]*?"""/g, '""""""');
124
+ result = result.replace(/'''[\s\S]*?'''/g, "''''''");
125
+ // Replace content inside regular double-quoted strings (handles escaped quotes)
126
+ result = result.replace(/"(?:\\.|[^"\\])*"/g, '""');
127
+ // Replace content inside single-quoted strings
128
+ result = result.replace(/'(?:\\.|[^'\\])*'/g, "''");
129
+ // Replace content inside backtick strings (JS/TS template literals)
130
+ result = result.replace(/`(?:\\.|[^`\\])*`/g, '``');
131
+ return result;
103
132
  }
104
133
  /**
105
134
  * Node.js stdlib method verification.
@@ -127,7 +156,7 @@ export class PhantomApisGate extends Gate {
127
156
  return;
128
157
  // Scan for method calls on imported modules
129
158
  for (let i = 0; i < lines.length; i++) {
130
- const line = this.stripJsCommentLine(lines[i]);
159
+ const line = this.stripStringLiterals(this.stripJsCommentLine(lines[i]));
131
160
  if (!line)
132
161
  continue;
133
162
  for (const [alias, moduleName] of moduleAliases) {
@@ -185,7 +214,7 @@ export class PhantomApisGate extends Gate {
185
214
  if (moduleAliases.size === 0)
186
215
  return;
187
216
  for (let i = 0; i < lines.length; i++) {
188
- const line = lines[i];
217
+ const line = this.stripStringLiterals(lines[i]);
189
218
  for (const [alias, moduleName] of moduleAliases) {
190
219
  const callPattern = new RegExp(`\\b${this.escapeRegex(alias)}\\.(\\w+)\\s*\\(`, 'g');
191
220
  let match;
@@ -252,7 +281,7 @@ export class PhantomApisGate extends Gate {
252
281
  checkGoPhantomApis(content, file, phantoms) {
253
282
  const lines = content.split('\n');
254
283
  for (let i = 0; i < lines.length; i++) {
255
- const line = lines[i];
284
+ const line = this.stripStringLiterals(lines[i]);
256
285
  for (const rule of GO_PHANTOM_RULES) {
257
286
  if (rule.pattern.test(line)) {
258
287
  phantoms.push({
@@ -271,7 +300,7 @@ export class PhantomApisGate extends Gate {
271
300
  checkCSharpPhantomApis(content, file, phantoms) {
272
301
  const lines = content.split('\n');
273
302
  for (let i = 0; i < lines.length; i++) {
274
- const line = lines[i];
303
+ const line = this.stripStringLiterals(lines[i]);
275
304
  for (const rule of CSHARP_PHANTOM_RULES) {
276
305
  if (rule.pattern.test(line)) {
277
306
  phantoms.push({
@@ -284,13 +313,15 @@ export class PhantomApisGate extends Gate {
284
313
  }
285
314
  }
286
315
  /**
287
- * Java/Kotlin phantom API detection — pattern-based.
316
+ * Java phantom API detection — pattern-based.
288
317
  * AI hallucinates Python/JS-style APIs on JDK classes.
318
+ * Strips string literal contents to avoid matching code-in-strings
319
+ * (e.g. Java test passing Python code `print('hello')` to a sandbox).
289
320
  */
290
321
  checkJavaPhantomApis(content, file, phantoms) {
291
322
  const lines = content.split('\n');
292
323
  for (let i = 0; i < lines.length; i++) {
293
- const line = lines[i];
324
+ const line = this.stripStringLiterals(lines[i]);
294
325
  for (const rule of JAVA_PHANTOM_RULES) {
295
326
  if (rule.pattern.test(line)) {
296
327
  phantoms.push({
@@ -302,6 +333,35 @@ export class PhantomApisGate extends Gate {
302
333
  }
303
334
  }
304
335
  }
336
+ /**
337
+ * Kotlin phantom API detection — uses a SUBSET of Java rules.
338
+ * Kotlin has different syntax than Java, so rules must be filtered:
339
+ * - print() IS valid Kotlin (kotlin.io.print)
340
+ * - var x: Type = IS valid Kotlin syntax
341
+ * - Most other Java patterns (includes, slice, arrow syntax) still apply
342
+ */
343
+ checkKotlinPhantomApis(content, file, phantoms) {
344
+ // Rules that are NOT applicable to Kotlin (valid Kotlin syntax)
345
+ const kotlinExclude = new Set([
346
+ 'print()', // kotlin.io.print() is real
347
+ 'var x: Type =', // Kotlin explicitly supports typed var/val declarations
348
+ ]);
349
+ const lines = content.split('\n');
350
+ for (let i = 0; i < lines.length; i++) {
351
+ const line = this.stripStringLiterals(lines[i]);
352
+ for (const rule of JAVA_PHANTOM_RULES) {
353
+ if (kotlinExclude.has(rule.phantom))
354
+ continue;
355
+ if (rule.pattern.test(line)) {
356
+ phantoms.push({
357
+ file, line: i + 1,
358
+ module: rule.module, method: rule.phantom,
359
+ reason: `'${rule.phantom}' does not exist in Kotlin. ${rule.suggestion}`,
360
+ });
361
+ }
362
+ }
363
+ }
364
+ }
305
365
  escapeRegex(s) {
306
366
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
307
367
  }