@rigour-labs/core 4.2.0 → 4.2.2
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/deep/verifier.js +526 -17
- package/dist/deep/verifier.test.js +144 -23
- package/dist/gates/hallucinated-imports-lang.d.ts +11 -0
- package/dist/gates/hallucinated-imports-lang.js +65 -12
- package/dist/gates/hallucinated-imports.d.ts +10 -0
- package/dist/gates/hallucinated-imports.js +203 -18
- package/dist/gates/phantom-apis.d.ts +18 -1
- package/dist/gates/phantom-apis.js +68 -8
- package/dist/gates/promise-safety.js +61 -1
- package/dist/gates/security-patterns.d.ts +5 -0
- package/dist/gates/security-patterns.js +51 -1
- package/dist/gates/test-quality.js +20 -0
- package/dist/inference/model-manager.js +10 -1
- package/package.json +6 -6
|
@@ -441,26 +441,147 @@ describe('Verifier', () => {
|
|
|
441
441
|
expect(result).toHaveLength(0);
|
|
442
442
|
});
|
|
443
443
|
});
|
|
444
|
-
// ──
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
444
|
+
// ── Structurally-verified categories ──
|
|
445
|
+
// After verifier hardening, these categories use entity names, cross-file
|
|
446
|
+
// checks, or raised confidence floors — not just confidence >= 0.3.
|
|
447
|
+
describe('structurally-verified categories', () => {
|
|
448
|
+
// Tier 1: Entity-name required — accepts when entity name matches
|
|
449
|
+
it('should accept feature_envy when entity name exists in file', () => {
|
|
450
|
+
const findings = [makeFinding({
|
|
451
|
+
category: 'feature_envy',
|
|
452
|
+
description: 'The processData function accesses too many external modules',
|
|
453
|
+
confidence: 0.5,
|
|
454
|
+
})];
|
|
455
|
+
const facts = [makeFileFacts()];
|
|
456
|
+
const result = verifyFindings(findings, facts);
|
|
457
|
+
expect(result).toHaveLength(1);
|
|
458
|
+
});
|
|
459
|
+
it('should reject feature_envy with no entity name and low confidence', () => {
|
|
460
|
+
const findings = [makeFinding({
|
|
461
|
+
category: 'feature_envy',
|
|
462
|
+
description: 'Some function accesses external modules',
|
|
463
|
+
confidence: 0.3,
|
|
464
|
+
})];
|
|
465
|
+
const facts = [makeFileFacts()];
|
|
466
|
+
const result = verifyFindings(findings, facts);
|
|
467
|
+
expect(result).toHaveLength(0); // No entity name → needs confidence >= 0.6
|
|
468
|
+
});
|
|
469
|
+
// Tier 2: dead_code — needs entity unreferenced externally
|
|
470
|
+
it('should accept dead_code when entity is unreferenced', () => {
|
|
471
|
+
const findings = [makeFinding({
|
|
472
|
+
category: 'dead_code',
|
|
473
|
+
description: 'processData is never called',
|
|
474
|
+
confidence: 0.5,
|
|
475
|
+
})];
|
|
476
|
+
// Only one file — processData can't be imported by other files
|
|
477
|
+
const facts = [makeFileFacts()];
|
|
478
|
+
const result = verifyFindings(findings, facts);
|
|
479
|
+
expect(result).toHaveLength(1);
|
|
480
|
+
});
|
|
481
|
+
it('should reject dead_code when entity is imported by another file', () => {
|
|
482
|
+
const findings = [makeFinding({
|
|
483
|
+
category: 'dead_code',
|
|
484
|
+
description: 'processData is never called',
|
|
485
|
+
confidence: 0.9,
|
|
486
|
+
})];
|
|
487
|
+
const facts = [
|
|
488
|
+
makeFileFacts(),
|
|
489
|
+
makeFileFacts({
|
|
490
|
+
path: 'src/consumer.ts',
|
|
491
|
+
imports: ['./service', 'processData'], // References the entity
|
|
492
|
+
}),
|
|
493
|
+
];
|
|
494
|
+
const result = verifyFindings(findings, facts);
|
|
495
|
+
expect(result).toHaveLength(0); // Entity is referenced — not dead code
|
|
496
|
+
});
|
|
497
|
+
// Tier 2: naming_convention — checks actual language rules
|
|
498
|
+
it('should accept naming_convention when name violates rules', () => {
|
|
499
|
+
const findings = [makeFinding({
|
|
500
|
+
category: 'naming_convention',
|
|
501
|
+
file: 'pkg/utils.go',
|
|
502
|
+
description: 'get_user_name uses snake_case instead of MixedCaps',
|
|
503
|
+
confidence: 0.5,
|
|
504
|
+
})];
|
|
505
|
+
const facts = [makeFileFacts({
|
|
506
|
+
path: 'pkg/utils.go',
|
|
507
|
+
language: 'go',
|
|
508
|
+
functions: [{
|
|
509
|
+
name: 'get_user_name',
|
|
510
|
+
lineStart: 10, lineEnd: 30, lineCount: 20,
|
|
511
|
+
paramCount: 1, params: ['id'],
|
|
512
|
+
maxNesting: 1, hasReturn: true, isAsync: false, isExported: false,
|
|
513
|
+
}],
|
|
514
|
+
})];
|
|
515
|
+
const result = verifyFindings(findings, facts);
|
|
516
|
+
expect(result).toHaveLength(1);
|
|
517
|
+
expect(result[0].verified).toBe(true);
|
|
518
|
+
});
|
|
519
|
+
it('should reject naming_convention when name follows rules', () => {
|
|
520
|
+
const findings = [makeFinding({
|
|
521
|
+
category: 'naming_convention',
|
|
522
|
+
description: 'processData naming issue',
|
|
523
|
+
confidence: 0.8,
|
|
524
|
+
})];
|
|
525
|
+
const facts = [makeFileFacts()]; // processData is camelCase — correct for TS
|
|
526
|
+
const result = verifyFindings(findings, facts);
|
|
527
|
+
expect(result).toHaveLength(0); // Name follows conventions → FP
|
|
528
|
+
});
|
|
529
|
+
// Tier 2: performance — needs non-trivial function
|
|
530
|
+
it('should accept performance when function is substantial', () => {
|
|
531
|
+
const findings = [makeFinding({
|
|
532
|
+
category: 'performance',
|
|
533
|
+
description: 'processData has O(n²) complexity',
|
|
534
|
+
confidence: 0.5,
|
|
535
|
+
})];
|
|
536
|
+
const facts = [makeFileFacts()]; // processData is 70 lines
|
|
537
|
+
const result = verifyFindings(findings, facts);
|
|
538
|
+
expect(result).toHaveLength(1);
|
|
539
|
+
});
|
|
540
|
+
// Tier 3: dry_violation — needs cross-file similarity
|
|
541
|
+
it('should accept dry_violation when similar function exists in another file', () => {
|
|
542
|
+
const findings = [makeFinding({
|
|
543
|
+
category: 'dry_violation',
|
|
544
|
+
description: 'processData is duplicated across files',
|
|
545
|
+
confidence: 0.5,
|
|
546
|
+
})];
|
|
547
|
+
const facts = [
|
|
548
|
+
makeFileFacts(),
|
|
549
|
+
makeFileFacts({
|
|
550
|
+
path: 'src/other.ts',
|
|
551
|
+
functions: [{
|
|
552
|
+
name: 'processData', // Same name = similar
|
|
553
|
+
lineStart: 10, lineEnd: 80, lineCount: 70,
|
|
554
|
+
paramCount: 5, params: ['a', 'b', 'c', 'd', 'e'],
|
|
555
|
+
maxNesting: 3, hasReturn: true, isAsync: true, isExported: true,
|
|
556
|
+
}],
|
|
557
|
+
}),
|
|
558
|
+
];
|
|
559
|
+
const result = verifyFindings(findings, facts);
|
|
560
|
+
expect(result).toHaveLength(1);
|
|
561
|
+
});
|
|
562
|
+
it('should reject dry_violation when no similar function exists', () => {
|
|
563
|
+
const findings = [makeFinding({
|
|
564
|
+
category: 'dry_violation',
|
|
565
|
+
description: 'processData is duplicated',
|
|
566
|
+
confidence: 0.5,
|
|
567
|
+
})];
|
|
568
|
+
const facts = [makeFileFacts()]; // Only one file — no cross-file match
|
|
569
|
+
const result = verifyFindings(findings, facts);
|
|
570
|
+
expect(result).toHaveLength(0);
|
|
571
|
+
});
|
|
572
|
+
// Tier 4: Confidence floor raised to 0.5
|
|
573
|
+
it('should accept architecture with confidence >= 0.5', () => {
|
|
574
|
+
const findings = [makeFinding({ category: 'architecture', confidence: 0.6 })];
|
|
575
|
+
const facts = [makeFileFacts()];
|
|
576
|
+
const result = verifyFindings(findings, facts);
|
|
577
|
+
expect(result).toHaveLength(1);
|
|
578
|
+
});
|
|
579
|
+
it('should reject architecture with confidence < 0.5', () => {
|
|
580
|
+
const findings = [makeFinding({ category: 'architecture', confidence: 0.4 })];
|
|
581
|
+
const facts = [makeFileFacts()];
|
|
582
|
+
const result = verifyFindings(findings, facts);
|
|
583
|
+
expect(result).toHaveLength(0);
|
|
584
|
+
});
|
|
464
585
|
});
|
|
465
586
|
// ── Resource leak (Go-specific) ──
|
|
466
587
|
describe('resource leak verification', () => {
|
|
@@ -502,13 +623,13 @@ describe('Verifier', () => {
|
|
|
502
623
|
makeFinding({ category: 'god_function', file: 'nonexistent.ts' }), // Should fail (no file)
|
|
503
624
|
makeFinding({ category: 'long_file' }), // Should fail (300 lines, need >300)
|
|
504
625
|
makeFinding({ category: 'magic_number' }), // Should fail (no magicNumbers set)
|
|
505
|
-
makeFinding({ category: '
|
|
506
|
-
makeFinding({ category: '
|
|
626
|
+
makeFinding({ category: 'architecture', confidence: 0.1 }), // Should fail (below 0.5 floor)
|
|
627
|
+
makeFinding({ category: 'architecture', confidence: 0.6 }), // Should pass (above 0.5 floor)
|
|
507
628
|
];
|
|
508
629
|
const facts = [makeFileFacts()];
|
|
509
630
|
const result = verifyFindings(findings, facts);
|
|
510
631
|
const verified = result.filter(r => r.verified);
|
|
511
|
-
expect(verified.length).toBeGreaterThanOrEqual(2); // god_class,
|
|
632
|
+
expect(verified.length).toBeGreaterThanOrEqual(2); // god_class, architecture(0.6)
|
|
512
633
|
});
|
|
513
634
|
});
|
|
514
635
|
});
|
|
@@ -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
|
-
//
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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 package — skip
|
|
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 (
|
|
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
|
|
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
|
}
|