@rigour-labs/core 6.11.1 → 6.11.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.
@@ -24,6 +24,8 @@ export interface HallucinatedImport {
24
24
  importPath: string;
25
25
  type: 'relative' | 'package' | 'python' | 'go' | 'ruby' | 'csharp' | 'rust' | 'java' | 'kotlin';
26
26
  reason: string;
27
+ /** The resolver could not read what the file may import (no manifest found): a note, never a block. */
28
+ uncertain?: boolean;
27
29
  }
28
30
  export interface HallucinatedImportsConfig {
29
31
  enabled?: boolean;
@@ -123,14 +123,24 @@ export class HallucinatedImportsGate extends Gate {
123
123
  }
124
124
  catch (e) { }
125
125
  }
126
+ // One finding per file; an import the resolver could not check against a manifest is a separate note.
126
127
  const byFile = new Map();
127
128
  for (const h of hallucinated) {
128
- const existing = byFile.get(h.file) || [];
129
+ const key = `${h.uncertain ? 'note' : 'block'}\u0000${h.file}`;
130
+ const existing = byFile.get(key) || [];
129
131
  existing.push(h);
130
- byFile.set(h.file, existing);
132
+ byFile.set(key, existing);
131
133
  }
132
- for (const [file, imports] of byFile) {
134
+ for (const imports of byFile.values()) {
135
+ const file = imports[0].file;
133
136
  const details = imports.map(i => ` L${i.line}: import '${i.importPath}' — ${i.reason}`).join('\n');
137
+ if (imports[0].uncertain) {
138
+ failures.push({
139
+ ...this.createFailure(`Imports that could not be checked in ${file} (no manifest found for it):\n${details}`, [file], 'Rigour found no manifest (Cargo.toml) for this file, so it cannot tell whether these are declared. Check them, or add the manifest.', 'Hallucinated Imports', imports[0].line, undefined, 'low'),
140
+ advisory: true,
141
+ });
142
+ continue;
143
+ }
134
144
  failures.push(this.createFailure(`Hallucinated imports in ${file}:\n${details}`, [file], `These imports reference modules that don't exist. Remove or replace with real modules. AI models often "hallucinate" package names or file paths.`, 'Hallucinated Imports', imports[0].line, undefined, 'critical'));
135
145
  }
136
146
  return failures;
@@ -16,7 +16,6 @@ export declare function loadNuGetPackages(cwd: string): Set<string>;
16
16
  */
17
17
  export declare function loadNuGetPackagesForFile(file: string, cwd: string): Set<string>;
18
18
  export declare function checkRustImports(content: string, file: string, cwd: string, projectFiles: Set<string>, hallucinated: HallucinatedImport[]): void;
19
- export declare function loadCargoDeps(cwd: string): Set<string>;
20
19
  export declare function checkJavaKotlinImports(content: string, file: string, ext: string, cwd: string, projectFiles: Set<string>, hallucinated: HallucinatedImport[]): void;
21
20
  export declare function loadJavaDeps(cwd: string): Set<string>;
22
21
  /**
@@ -10,6 +10,8 @@ export function checkGoImports(content, file, cwd, projectFiles, hallucinated) {
10
10
  let inImportBlock = false;
11
11
  // Find go.mod by walking up from the Go file's directory (monorepo support).
12
12
  let modulePath = null;
13
+ let moduleDir = ''; // where go.mod sits, relative to the repository root
14
+ const requiredModules = []; // `require` entries: another module, even under the module's own prefix
13
15
  const replaceModules = new Set(); // Modules with local replace directives
14
16
  const fileAbsDir = path.dirname(path.resolve(cwd, file));
15
17
  const rootDir = path.resolve(cwd);
@@ -22,6 +24,16 @@ export function checkGoImports(content, file, cwd, projectFiles, hallucinated) {
22
24
  const moduleMatch = goMod.match(/^module\s+(\S+)/m);
23
25
  if (moduleMatch) {
24
26
  modulePath = moduleMatch[1];
27
+ moduleDir = path.relative(rootDir, searchDir).replace(/\\/g, '/');
28
+ }
29
+ for (const m of goMod.matchAll(/^require\s+([^\s(]\S*)\s+\S+/gm))
30
+ requiredModules.push(m[1]);
31
+ for (const block of goMod.matchAll(/^require\s*\(([\s\S]*?)^\)/gm)) {
32
+ for (const entry of block[1].split('\n')) {
33
+ const m = entry.trim().match(/^([^\s/][^\s]*)\s+\S+/);
34
+ if (m)
35
+ requiredModules.push(m[1]);
36
+ }
25
37
  }
26
38
  // Parse replace directives: replace github.com/foo/bar => ../local-bar
27
39
  // These modules are valid even without being in go.sum
@@ -94,10 +106,16 @@ export function checkGoImports(content, file, cwd, projectFiles, hallucinated) {
94
106
  // 1. Skip Go standard library — comprehensive list
95
107
  if (isGoStdlib(importPath))
96
108
  continue;
97
- // 2. If we have a module path, check project-relative imports FIRST
109
+ // 1b. A module go.mod requires is another module, even under this module's own path
110
+ // (tailscale.com requiring tailscale.com/client/tailscale/v2): resolved by the module system.
111
+ if (requiredModules.some(m => importPath === m || importPath.startsWith(m + '/')))
112
+ continue;
113
+ // 2. If we have a module path, check project-relative imports FIRST: the package's own folder must hold Go files.
98
114
  if (modulePath && importPath.startsWith(modulePath + '/')) {
99
115
  const relPath = importPath.slice(modulePath.length + 1);
100
- const hasMatchingFile = [...projectFiles].some(f => f.endsWith('.go') && f.startsWith(relPath));
116
+ const packageDir = moduleDir ? `${moduleDir}/${relPath}` : relPath;
117
+ // On disk, not in the scanned file list: that list leaves out folders like `build/` that a Go package may be.
118
+ const hasMatchingFile = goPackageExists(path.join(rootDir, ...packageDir.split('/')));
101
119
  if (!hasMatchingFile) {
102
120
  hallucinated.push({
103
121
  file, line: i + 1, importPath, type: 'go',
@@ -137,6 +155,15 @@ export function checkGoImports(content, file, cwd, projectFiles, hallucinated) {
137
155
  // without go.mod context we can't verify, so skip to avoid false positives
138
156
  }
139
157
  }
158
+ /** A Go package: a folder holding at least one .go file. */
159
+ function goPackageExists(dir) {
160
+ try {
161
+ return fs.readdirSync(dir).some(entry => String(entry).endsWith('.go'));
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
140
167
  export function checkRubyImports(content, file, cwd, projectFiles, hallucinated) {
141
168
  const lines = content.split('\n');
142
169
  // Parse Gemfile for known gem dependencies
@@ -384,125 +411,182 @@ export function loadNuGetPackagesForFile(file, cwd) {
384
411
  }
385
412
  export function checkRustImports(content, file, cwd, projectFiles, hallucinated) {
386
413
  const lines = content.split('\n');
387
- const cargoDeps = loadCargoDeps(cwd);
414
+ // The file's own crate manifest and its workspace: what this crate may name. With no Cargo.toml found, nothing
415
+ // here can be proven missing, so an unresolved name is a note (`uncertain`), never a block.
416
+ const manifest = rustManifestFor(cwd, file);
417
+ // Items in scope by name: `use log::x` after `mod log;` names the module, not a crate (Rust 2018 paths).
418
+ const local = rustLocalNames(content);
419
+ const where = manifest.found ? 'its Cargo.toml' : 'any Cargo.toml found';
388
420
  for (let i = 0; i < lines.length; i++) {
389
421
  const line = lines[i].trim();
390
422
  if (line.startsWith('//') || line.startsWith('/*'))
391
423
  continue;
392
- // extern crate foo;
424
+ // extern crate foo; (pre-2018: the crate must be declared, and then `use foo::` names it)
393
425
  const externMatch = line.match(/^extern\s+crate\s+(\w+)/);
394
426
  if (externMatch) {
395
427
  const crateName = externMatch[1];
396
- if (isRustStdCrate(crateName))
397
- continue;
398
- if (cargoDeps.has(crateName))
428
+ if (isRustStdCrate(crateName) || manifest.deps.has(crateName))
399
429
  continue;
400
430
  hallucinated.push({
401
431
  file, line: i + 1, importPath: crateName, type: 'rust',
402
- reason: `extern crate '${crateName}' — not in Cargo.toml or Rust std`,
432
+ reason: `extern crate '${crateName}' — not in ${where} or Rust std`,
433
+ ...(manifest.found ? {} : { uncertain: true }),
403
434
  });
404
435
  continue;
405
436
  }
406
- // use foo::bar::baz; or use foo::{bar, baz};
407
- const useMatch = line.match(/^(?:pub\s+)?use\s+(\w+)::/);
437
+ // use foo::bar; use ::foo::bar; pub(crate) use foo::{bar, baz}; Only the first segment can be a crate.
438
+ const useMatch = line.match(/^(?:pub(?:\([^)]*\))?\s+)?use\s+(?:::)?(\w+)::/);
408
439
  if (useMatch) {
409
440
  const crateName = useMatch[1];
410
- if (isRustStdCrate(crateName))
441
+ // Path keywords, std, declared crates, and names the file itself brings into scope.
442
+ if (['crate', 'self', 'super', 'Self'].includes(crateName))
411
443
  continue;
412
- if (cargoDeps.has(crateName))
413
- continue;
414
- // 'crate' and 'self' and 'super' are Rust path keywords
415
- if (['crate', 'self', 'super'].includes(crateName))
444
+ if (isRustStdCrate(crateName) || manifest.deps.has(crateName) || local.has(crateName))
416
445
  continue;
417
446
  hallucinated.push({
418
447
  file, line: i + 1, importPath: crateName, type: 'rust',
419
- reason: `use ${crateName}:: — crate not in Cargo.toml or Rust std`,
448
+ reason: `use ${crateName}:: — crate not in ${where} or Rust std`,
449
+ ...(manifest.found ? {} : { uncertain: true }),
420
450
  });
421
451
  }
422
452
  }
423
453
  }
424
- export function loadCargoDeps(cwd) {
425
- const deps = new Set();
426
- const parseCargoToml = (filePath) => {
427
- try {
428
- if (!fs.pathExistsSync(filePath))
429
- return;
430
- const content = fs.readFileSync(filePath, 'utf-8');
431
- // Parse ALL dependency sections: [dependencies], [dev-dependencies], [build-dependencies]
432
- // Also handle [target.'cfg(...)'.dependencies]
433
- let inDeps = false;
434
- for (const line of content.split('\n')) {
435
- const trimmed = line.trim();
436
- // Match any dependencies section header
437
- if (/^\[(?:(?:dev|build)-)?dependencies(?:\.]|\])/.test(trimmed) ||
438
- /^\[target\.[^\]]*\.(?:(?:dev|build)-)?dependencies\]/.test(trimmed) ||
439
- /^\[workspace\.dependencies\]/.test(trimmed)) {
440
- inDeps = true;
441
- continue;
442
- }
443
- if (/^\[/.test(trimmed) && inDeps) {
444
- inDeps = false;
445
- continue;
446
- }
447
- if (inDeps) {
448
- const m = trimmed.match(/^([\w][\w-]*)\s*=/);
449
- if (m)
450
- deps.add(m[1].replace(/-/g, '_')); // Rust uses _ in code for - in Cargo
451
- }
452
- }
453
- // Handle [dependencies.foo] format (inline table)
454
- const inlineDepPattern = /^\[(?:(?:dev|build)-)?dependencies\.([\w][\w-]*)\]/gm;
455
- let dm;
456
- while ((dm = inlineDepPattern.exec(content)) !== null) {
457
- deps.add(dm[1].replace(/-/g, '_'));
458
- }
459
- // Handle [features] section — optional deps are valid when feature is enabled
460
- let inFeatures = false;
461
- for (const line of content.split('\n')) {
462
- const trimmed = line.trim();
463
- if (trimmed === '[features]') {
464
- inFeatures = true;
465
- continue;
466
- }
467
- if (/^\[/.test(trimmed) && inFeatures) {
468
- inFeatures = false;
469
- continue;
470
- }
471
- if (inFeatures) {
472
- // Features reference dep names: feature = ["dep:foo", "bar/feature"]
473
- const depRefs = trimmed.match(/"dep:(\w[\w-]*)"/g);
474
- if (depRefs) {
475
- for (const ref of depRefs) {
476
- const name = ref.match(/"dep:(\w[\w-]*)"/)?.[1];
477
- if (name)
478
- deps.add(name.replace(/-/g, '_'));
479
- }
480
- }
481
- }
454
+ /** Names a Rust file declares or brings into scope itself: modules, types, functions, `extern crate`, `as` aliases and the last segment of a plain `use`. */
455
+ function rustLocalNames(content) {
456
+ const names = new Set();
457
+ const declared = /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:(?:unsafe|async|const|extern(?:\s+"[^"]*")?)\s+)*(?:mod|struct|enum|union|trait|type|fn|const|static|macro_rules!)\s+(\w+)/gm;
458
+ for (const m of content.matchAll(declared))
459
+ names.add(m[1]);
460
+ for (const m of content.matchAll(/^\s*extern\s+crate\s+(\w+)(?:\s+as\s+(\w+))?/gm)) {
461
+ names.add(m[1]);
462
+ if (m[2])
463
+ names.add(m[2]);
464
+ }
465
+ for (const m of content.matchAll(/\bas\s+(\w+)\s*[;,}]/g))
466
+ names.add(m[1]);
467
+ // `use serde::de;` brings `de` into this file's scope (only this file's: it never becomes a crate name).
468
+ for (const m of content.matchAll(/^\s*(?:pub(?:\([^)]*\))?\s+)?use\s+[\w:]+::(\w+)\s*;/gm))
469
+ names.add(m[1]);
470
+ return names;
471
+ }
472
+ /**
473
+ * What a Rust file's crate may name: every dependency section of the nearest Cargo.toml from the file up to the
474
+ * repository root (its crate) and that crate's own name (integration tests and examples use it); then, from the
475
+ * nearest `[workspace]` at or above it, the shared dependencies and every member's name. `found` is false when no
476
+ * Cargo.toml was found at all.
477
+ */
478
+ function rustManifestFor(cwd, file) {
479
+ const root = path.resolve(cwd);
480
+ const crate = crateManifest(root, path.dirname(path.resolve(root, file)));
481
+ const deps = new Set(crate ? cargoNames(crate) : []);
482
+ const workspace = workspaceManifest(crate ? path.dirname(crate) : root);
483
+ if (workspace) {
484
+ for (const name of cargoNames(workspace.file))
485
+ deps.add(name);
486
+ for (const member of workspaceMembers(path.dirname(workspace.file), workspace.text))
487
+ for (const name of cargoNames(member, true))
488
+ deps.add(name);
489
+ }
490
+ return { found: !!crate || !!workspace, deps };
491
+ }
492
+ /** The nearest Cargo.toml from `dir` up to the repository root: the file's own crate. */
493
+ function crateManifest(root, dir) {
494
+ for (; dir.startsWith(root); dir = path.dirname(dir)) {
495
+ const candidate = path.join(dir, 'Cargo.toml');
496
+ if (fs.pathExistsSync(candidate))
497
+ return candidate;
498
+ if (dir === root || path.dirname(dir) === dir)
499
+ return undefined;
500
+ }
501
+ return undefined;
502
+ }
503
+ /** The nearest Cargo.toml with `[workspace]` at or above `dir`. */
504
+ function workspaceManifest(dir) {
505
+ for (;; dir = path.dirname(dir)) {
506
+ const file = path.join(dir, 'Cargo.toml');
507
+ const text = readText(file);
508
+ if (text?.includes('[workspace]'))
509
+ return { file, text };
510
+ if (path.dirname(dir) === dir)
511
+ return undefined;
512
+ }
513
+ }
514
+ function readText(file) {
515
+ try {
516
+ return fs.pathExistsSync(file) ? fs.readFileSync(file, 'utf-8') : undefined;
517
+ }
518
+ catch {
519
+ return undefined;
520
+ }
521
+ }
522
+ /** A workspace's member manifests: `members = ["crates/a", "crates/*"]`, a trailing `/*` listing that folder. */
523
+ function workspaceMembers(dir, text) {
524
+ const list = /members\s*=\s*\[([^\]]*)\]/.exec(text)?.[1] ?? '';
525
+ const out = [];
526
+ for (const m of list.matchAll(/"([^"]+)"/g)) {
527
+ const member = m[1];
528
+ if (member.endsWith('/*')) {
529
+ const parent = path.join(dir, member.slice(0, -2));
530
+ try {
531
+ for (const entry of fs.readdirSync(parent))
532
+ out.push(path.join(parent, String(entry), 'Cargo.toml'));
482
533
  }
534
+ catch { /* none */ }
483
535
  }
484
- catch { /* skip */ }
485
- };
486
- // Parse the Cargo.toml in the working directory
487
- parseCargoToml(path.join(cwd, 'Cargo.toml'));
488
- // Walk up to find workspace Cargo.toml
489
- let searchDir = cwd;
490
- const rootDir = path.parse(cwd).root;
491
- while (searchDir !== rootDir) {
492
- const parentCargoPath = path.join(searchDir, 'Cargo.toml');
493
- if (fs.pathExistsSync(parentCargoPath)) {
494
- const content = fs.readFileSync(parentCargoPath, 'utf-8');
495
- if (content.includes('[workspace]')) {
496
- parseCargoToml(parentCargoPath);
497
- break;
498
- }
536
+ else
537
+ out.push(path.join(dir, member, 'Cargo.toml'));
538
+ }
539
+ return out;
540
+ }
541
+ /** Names a Cargo.toml makes nameable in code (`-` read as `_`); `ownOnly`: just its [package] and [lib] names. */
542
+ function cargoNames(file, ownOnly = false) {
543
+ const text = readText(file);
544
+ if (!text)
545
+ return new Set();
546
+ const names = ownOnly ? new Set() : cargoDepsOf(text);
547
+ for (const section of ['package', 'lib']) {
548
+ const body = new RegExp(`^\\[${section}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? '';
549
+ const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(body)?.[1];
550
+ if (name)
551
+ names.add(name.replace(/-/g, '_'));
552
+ }
553
+ return names;
554
+ }
555
+ /** Every name a Cargo.toml's dependency sections declare: [dependencies], dev, build, target-specific, [workspace.dependencies], `[dependencies.foo]` and `dep:` features; `-` read as `_`. */
556
+ function cargoDepsOf(content) {
557
+ const deps = new Set([...sectionDeps(content), ...tableDeps(content), ...featureDeps(content)]);
558
+ return new Set([...deps].map(name => name.replace(/-/g, '_'))); // Rust uses _ in code for - in Cargo
559
+ }
560
+ /** Keys of every dependency section: [dependencies], [dev-dependencies], [build-dependencies], target-specific and the workspace's. */
561
+ function sectionDeps(content) {
562
+ const names = [];
563
+ let inDeps = false;
564
+ for (const line of content.split('\n')) {
565
+ const trimmed = line.trim();
566
+ if (/^\[(?:(?:dev|build)-)?dependencies(?:\.]|\])/.test(trimmed) ||
567
+ /^\[target\.[^\]]*\.(?:(?:dev|build)-)?dependencies\]/.test(trimmed) ||
568
+ /^\[workspace\.dependencies\]/.test(trimmed)) {
569
+ inDeps = true;
570
+ continue;
499
571
  }
500
- const parent = path.dirname(searchDir);
501
- if (parent === searchDir)
502
- break;
503
- searchDir = parent;
572
+ if (/^\[/.test(trimmed)) {
573
+ inDeps = false;
574
+ continue;
575
+ }
576
+ const m = inDeps ? trimmed.match(/^([\w][\w-]*)\s*=/) : null;
577
+ if (m)
578
+ names.push(m[1]);
504
579
  }
505
- return deps;
580
+ return names;
581
+ }
582
+ /** `[dependencies.foo]` tables. */
583
+ function tableDeps(content) {
584
+ return [...content.matchAll(/^\[(?:(?:dev|build)-)?dependencies\.([\w][\w-]*)\]/gm)].map(m => m[1]);
585
+ }
586
+ /** Optional dependencies a feature enables (`"dep:foo"` in [features]). */
587
+ function featureDeps(content) {
588
+ const features = /^\[features\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(content)?.[1] ?? '';
589
+ return [...features.matchAll(/"dep:(\w[\w-]*)"/g)].map(m => m[1]);
506
590
  }
507
591
  export function checkJavaKotlinImports(content, file, ext, cwd, projectFiles, hallucinated) {
508
592
  const lines = content.split('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.11.1",
3
+ "version": "6.11.2",
4
4
  "description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
5
5
  "engines": {
6
6
  "node": ">=22.13"
@@ -75,11 +75,11 @@
75
75
  "@anthropic-ai/sdk": "^0.132.1",
76
76
  "pg": "^8.16.3",
77
77
  "openai": "^5.23.2",
78
- "@rigour-labs/brain-darwin-x64": "6.11.1",
79
- "@rigour-labs/brain-linux-x64": "6.11.1",
80
- "@rigour-labs/brain-linux-arm64": "6.11.1",
81
- "@rigour-labs/brain-win-x64": "6.11.1",
82
- "@rigour-labs/brain-darwin-arm64": "6.11.1"
78
+ "@rigour-labs/brain-darwin-arm64": "6.11.2",
79
+ "@rigour-labs/brain-linux-arm64": "6.11.2",
80
+ "@rigour-labs/brain-linux-x64": "6.11.2",
81
+ "@rigour-labs/brain-win-x64": "6.11.2",
82
+ "@rigour-labs/brain-darwin-x64": "6.11.2"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/fs-extra": "^11.0.4",