diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 641df1821..6f4bfdbec 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -608,6 +608,16 @@ jobs: run: node --import tsx bench/python-workspace-import-scan/measure.mjs --check working-directory: gitnexus + - name: Rust Cargo target membership guards (#3253) + if: ${{ !cancelled() }} + # Build-free: same baseline approach as parse-dispatch-rounds — + # exact membership floors plus a fingerprint, then ratio timing + # only (loadRustCargoTargets 4n/n). Pins typical-Rust completeness + # (derive / println!), include!-abort, and src/target vs Cargo + # artifact layouts. See bench/rust-cargo-targets/measure.mjs. + run: node --import tsx bench/rust-cargo-targets/measure.mjs --check + working-directory: gitnexus + - name: MCP tools/list countRepos vs listRepos guards (#3259, #3184) if: ${{ !cancelled() }} # Build-free: exact registry cardinality + tool-roster + schema-flag diff --git a/README.md b/README.md index e798c7284..c11f20984 100644 --- a/README.md +++ b/README.md @@ -662,7 +662,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas | Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | | Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | | Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/gitnexus/README.md b/gitnexus/README.md index 816ca0ef0..a0f8b7fe1 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -431,7 +431,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, | Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | | Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | | Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/gitnexus/bench/rust-cargo-targets/baselines.json b/gitnexus/bench/rust-cargo-targets/baselines.json new file mode 100644 index 000000000..ca6f3f5ce --- /dev/null +++ b/gitnexus/bench/rust-cargo-targets/baselines.json @@ -0,0 +1,31 @@ +{ + "_what": "Baselines for bench/rust-cargo-targets/measure.mjs --check. Guards Cargo target membership after #3253: typical-Rust completeness, include!-abort, src/target and explicit target/entry.rs survival. Same approach as bench/parse-dispatch-rounds/baselines.json — exact floors and a fingerprint first; the only timing arm is a ratio.", + "_triage": "READ THIS BEFORE RE-RUNNING. packages, rust_files, typical_complete, include_unknown, explicit_disjoint, disjoint_false, shared_nested, shared_target_module and layout_fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. load_scaling_ratio is the only timing arm; runner contention dominates it, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is that one, suspect the machine.", + + "packages": 8, + "rust_files": 32, + "_shape_note": "THE FLOOR. Without these two, every arm below is a ceiling over nothing. typical_complete only asserts something while the corpus still walks many crates. Shrink it to one happy-path package and typical_complete still reads 1 and still passes, asserting a property the corpus no longer has.", + + "typical_complete": 1, + "include_unknown": 1, + "explicit_disjoint": 1, + "disjoint_false": 8, + "shared_nested": 8, + "shared_target_module": 8, + "_membership_note": "Exact membership counts. typical_complete=1 pins that #[derive]/println!/assert_eq! do not abort the snapshot. include_unknown=1 pins item-position include! still aborts. explicit_disjoint=1 pins [lib] path = target/entry.rs against tests/helper.rs as a complete negative proof. disjoint_false=8 is each crate's lib vs tests/helper. shared_nested=8 and shared_target_module=8 pin src/nested.rs and src/target/mod.rs as library modules — the latter is the glob that would drop every path segment named target.", + + "layout_fingerprint": "1f85812c93302dc4c5089b284c6c395379efc146bfc11b886de499417ec1ae01", + "_layout_fingerprint_note": "sha256 over sorted caller|candidate|share rows on the typical corpus. A change here is a BEHAVIOUR change — the loader returned a different membership set. Explain it, never re-baseline it alone.", + + "load_scaling_budget": 1.6, + "_load_scaling_note": "(t_4n / t_n) / 4 for loadRustCargoTargets over the typical corpus; ~1.0 is linear. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. Budget is 1.6, matching parse-dispatch-rounds' pack_scaling_budget. min-of-15 estimator.", + + "_measured": { + "load_scaling_ratio": 0.924, + "load_scaling_ratio_samples": [0.681, 0.859, 0.798, 0.834, 0.924], + "small_ms": 24.43, + "large_ms_4x": 75.08, + "reps": 15 + }, + "_measured_note": "Maxima (and sample lists) over 5 consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds. Milliseconds are diagnostic context only — nothing gates on them." +} diff --git a/gitnexus/bench/rust-cargo-targets/measure.mjs b/gitnexus/bench/rust-cargo-targets/measure.mjs new file mode 100644 index 000000000..3321dc841 --- /dev/null +++ b/gitnexus/bench/rust-cargo-targets/measure.mjs @@ -0,0 +1,309 @@ +/** + * Build-free bench for Rust Cargo target membership (#3253). + * + * WHY THIS EXISTS. `loadRustCargoTargets` is a negative-proof loader: a + * complete snapshot lets name-fallback refuse a cross-target unique name, and + * an incomplete one fail-opens. Graph output does not show "how many files we + * walked" or "we aborted because of `#[derive]`". A revert to treating + * derive/println! as unknown, a glob that drops every path segment named + * target, or a superlinear membership walk can still emit the same one CALLS + * edge on a tiny fixture. + * This file is the same shape as `bench/parse-dispatch-rounds`: exact floors + * first, one ratio timing arm, never a millisecond ceiling. + * + * ARMS: + * + * - `typical_complete` / `include_unknown` / `explicit_disjoint` — EXACT. + * Typical crates (derive + expression-position std macros) must still + * certify a snapshot. `include!` must still abort it. An explicit + * `[lib] path = "target/entry.rs"` must stay a complete negative proof + * against `tests/helper.rs`, not vanish into the artifact glob. + * + * - `packages` / `rust_files` / `disjoint_false` / `shared_nested` / + * `shared_target_module` — EXACT, and they are the FLOOR. `typical_complete` + * only asserts something while the corpus still has many crates to walk. + * Shrink it to one happy-path package and the complete arm still passes, + * gating a property the corpus no longer has. + * + * - `layout_fingerprint` — EXACT. sha256 over sorted `caller|candidate|share` + * rows on the typical corpus. Catches a membership-set change that leaves + * the counts intact. + * + * - `load_scaling_ratio` — the only timing arm, a RATIO not a millisecond + * ceiling. `(t_4n / t_n) / 4` divides the machine out; ~1.0 is linear. + * Superlinear AST work over crate count lands here. + * + * Usage: + * node --import tsx bench/rust-cargo-targets/measure.mjs + * node --import tsx bench/rust-cargo-targets/measure.mjs --check + */ +import { createHash } from 'node:crypto'; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { + loadRustCargoTargets, + rustFilesShareCargoTarget, +} from '../../src/core/ingestion/languages/rust/cargo-targets.ts'; + +const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8')); + +const REPS = 15; +const PACKAGES = 8; +const TYPICAL_LIB = [ + '#[derive(Debug)]', + 'struct S;', + 'mod nested;', + 'mod target;', + 'pub fn helper() {}', + 'fn t() { println!("hi"); assert_eq!(1, 1); let _ = vec![1]; let _ = format!("{}", 1); }', + '', +].join('\n'); + +function writeCrate(root, name, { libPath = 'src/lib.rs', libBody = TYPICAL_LIB } = {}) { + const crateDir = path.join(root, 'crates', name); + mkdirSync(path.join(crateDir, 'src', 'target'), { recursive: true }); + mkdirSync(path.join(crateDir, 'src', 'nested'), { recursive: true }); + mkdirSync(path.join(crateDir, 'tests'), { recursive: true }); + mkdirSync(path.join(crateDir, path.dirname(libPath)), { recursive: true }); + const manifest = ['[package]', `name="${name}"`, 'version="0.1.0"', 'edition="2021"', '']; + if (libPath !== 'src/lib.rs') { + manifest.push('[lib]', `path="${libPath}"`, ''); + } + writeFileSync(path.join(crateDir, 'Cargo.toml'), `${manifest.join('\n')}`); + writeFileSync(path.join(crateDir, libPath), libBody); + const moduleDir = path.posix.dirname(libPath); + writeFileSync(path.join(crateDir, moduleDir, 'nested.rs'), 'pub fn nested_helper() {}\n'); + writeFileSync(path.join(crateDir, 'src', 'target', 'mod.rs'), 'pub fn target_helper() {}\n'); + writeFileSync(path.join(crateDir, 'tests', 'helper.rs'), 'pub fn helper() {}\n'); +} + +function writeTypical(root, packages) { + const names = Array.from({ length: packages }, (_, i) => `c${i}`); + writeFileSync( + path.join(root, 'Cargo.toml'), + `[workspace]\nmembers=[${names.map((n) => `"crates/${n}"`).join(', ')}]\n`, + ); + for (const name of names) writeCrate(root, name); + return names; +} + +function cratePaths(name, libPath = 'src/lib.rs') { + const lib = `crates/${name}/${libPath}`; + const moduleDir = path.posix.dirname(libPath); + return { + lib, + nested: `crates/${name}/${moduleDir}/nested.rs`, + target: `crates/${name}/src/target/mod.rs`, + tests: `crates/${name}/tests/helper.rs`, + }; +} + +function countRs(dir) { + let n = 0; + for (const ent of readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) n += countRs(p); + else if (ent.name.endsWith('.rs')) n++; + } + return n; +} + +function shareLabel(value) { + if (value === true) return 'true'; + if (value === false) return 'false'; + return 'undefined'; +} + +function probesFor(config, names, libPath = 'src/lib.rs') { + const rows = []; + let disjointFalse = 0; + let sharedNested = 0; + let sharedTarget = 0; + for (const name of names) { + const paths = cratePaths(name, libPath); + const nested = rustFilesShareCargoTarget(config, paths.lib, paths.nested); + const target = rustFilesShareCargoTarget(config, paths.lib, paths.target); + const tests = rustFilesShareCargoTarget(config, paths.lib, paths.tests); + if (tests === false) disjointFalse++; + if (nested === true) sharedNested++; + if (target === true) sharedTarget++; + rows.push( + `${paths.lib}|${paths.nested}|${shareLabel(nested)}`, + `${paths.lib}|${paths.target}|${shareLabel(target)}`, + `${paths.lib}|${paths.tests}|${shareLabel(tests)}`, + ); + } + return { + disjointFalse, + sharedNested, + sharedTarget, + fingerprint: createHash('sha256').update(rows.sort().join('\n')).digest('hex'), + }; +} + +async function fastest(fn, reps) { + await fn(); + let best = Infinity; + for (let r = 0; r < reps; r++) { + const t0 = performance.now(); + await fn(); + best = Math.min(best, performance.now() - t0); + } + return best; +} + +const roots = []; +function workspace(build) { + const root = mkdtempSync(path.join(tmpdir(), 'gn-rust-cargo-bench-')); + roots.push(root); + build(root); + return root; +} + +try { + const typicalRoot = workspace((root) => writeTypical(root, PACKAGES)); + const typical4xRoot = workspace((root) => writeTypical(root, PACKAGES * 4)); + const explicitRoot = workspace((root) => { + writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers=["crates/explicit"]\n'); + writeCrate(root, 'explicit', { + libPath: 'target/entry.rs', + libBody: '#[derive(Debug)] struct S;\nmod nested;\npub fn helper() {}\n', + }); + }); + const includeRoot = workspace((root) => { + writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers=["crates/unknown"]\n'); + writeCrate(root, 'unknown', { libBody: 'include!("generated.rs");\n' }); + }); + + const typicalNames = Array.from({ length: PACKAGES }, (_, i) => `c${i}`); + const [typicalConfig, explicitConfig, includeConfig] = await Promise.all([ + loadRustCargoTargets(typicalRoot), + loadRustCargoTargets(explicitRoot), + loadRustCargoTargets(includeRoot), + ]); + + const typical = probesFor(typicalConfig, typicalNames); + const explicitPaths = cratePaths('explicit', 'target/entry.rs'); + const explicitShare = rustFilesShareCargoTarget( + explicitConfig, + explicitPaths.lib, + explicitPaths.tests, + ); + const includeShare = rustFilesShareCargoTarget( + includeConfig, + 'crates/unknown/src/lib.rs', + 'crates/unknown/tests/helper.rs', + ); + + const rustFiles = countRs(typicalRoot); + const typicalComplete = typicalConfig !== undefined ? 1 : 0; + const includeUnknown = includeConfig === undefined && includeShare === undefined ? 1 : 0; + const explicitDisjoint = explicitShare === false ? 1 : 0; + + const smallMs = await fastest(() => loadRustCargoTargets(typicalRoot), REPS); + const largeMs = await fastest(() => loadRustCargoTargets(typical4xRoot), REPS); + const loadScaling = largeMs / smallMs / 4; + + console.log(`packages : ${PACKAGES} (expect ${baselines.packages})`); + console.log(`rust_files : ${rustFiles} (expect ${baselines.rust_files})`); + console.log( + `typical_complete : ${typicalComplete} (expect ${baselines.typical_complete})`, + ); + console.log(`include_unknown : ${includeUnknown} (expect ${baselines.include_unknown})`); + console.log( + `explicit_disjoint : ${explicitDisjoint} (expect ${baselines.explicit_disjoint})`, + ); + console.log( + `disjoint_false : ${typical.disjointFalse} (expect ${baselines.disjoint_false})`, + ); + console.log( + `shared_nested : ${typical.sharedNested} (expect ${baselines.shared_nested})`, + ); + console.log( + `shared_target_module : ${typical.sharedTarget} (expect ${baselines.shared_target_module})`, + ); + console.log(`layout_fingerprint : ${typical.fingerprint}`); + console.log( + `load_scaling_ratio : ${loadScaling.toFixed(3)} (budget <= ${baselines.load_scaling_budget}; ~1.0 is linear)`, + ); + console.log( + `reps : ${REPS} small ${smallMs.toFixed(2)}ms / 4x ${largeMs.toFixed(2)}ms`, + ); + + if (process.argv.includes('--check')) { + let failed = false; + + if (typical.fingerprint !== baselines.layout_fingerprint) { + failed = true; + console.error( + `\nFAIL layout_fingerprint: ${typical.fingerprint}\n` + + ` expected ${baselines.layout_fingerprint}\n` + + ` Typical-corpus membership moved. Explain it; do not re-baseline alone.`, + ); + } + + if (typicalComplete !== baselines.typical_complete) { + failed = true; + console.error( + `\nFAIL typical_complete: ${typicalComplete}, expected ${baselines.typical_complete}.\n` + + ` Ordinary #[derive] / println! / assert_eq! aborted the snapshot, so the\n` + + ` #3253 veto never loads on typical crates.`, + ); + } + if (includeUnknown !== baselines.include_unknown) { + failed = true; + console.error( + `\nFAIL include_unknown: ${includeUnknown}, expected ${baselines.include_unknown}.\n` + + ` Item-position include! must still abort the membership proof.`, + ); + } + if (explicitDisjoint !== baselines.explicit_disjoint) { + failed = true; + console.error( + `\nFAIL explicit_disjoint: ${explicitDisjoint}, expected ${baselines.explicit_disjoint}.\n` + + ` [lib] path = "target/entry.rs" was dropped or left unknown — usually\n` + + ` **/target/** glob ignore rather than Cargo artifact layouts.`, + ); + } + if ( + typical.disjointFalse !== baselines.disjoint_false || + typical.sharedNested !== baselines.shared_nested || + typical.sharedTarget !== baselines.shared_target_module + ) { + failed = true; + console.error( + `\nFAIL membership counts: disjoint_false ${typical.disjointFalse} (expected ${baselines.disjoint_false}), ` + + `shared_nested ${typical.sharedNested} (expected ${baselines.shared_nested}), ` + + `shared_target_module ${typical.sharedTarget} (expected ${baselines.shared_target_module}).\n` + + ` Cross-target tests/helper.rs must stay proven-false; src/target/mod.rs must stay a library module.`, + ); + } + + if (PACKAGES !== baselines.packages || rustFiles !== baselines.rust_files) { + failed = true; + console.error( + `\nFAIL shape: packages ${PACKAGES} (expected ${baselines.packages}), ` + + `rust_files ${rustFiles} (expected ${baselines.rust_files}).\n` + + ` The corpus must stay large enough that the complete/disjoint arms still measure a walk.`, + ); + } + + if (loadScaling > baselines.load_scaling_budget) { + failed = true; + console.error( + `\nFAIL load_scaling_ratio: ${loadScaling.toFixed(3)} exceeds ` + + `${baselines.load_scaling_budget} (~1.0 is linear).\n` + + ` Re-run on an idle machine before investigating, and check \`reps\` first.`, + ); + } + + if (failed) process.exit(1); + console.log('\nOK — within budget.'); + } +} finally { + for (const root of roots) { + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 5a4af535b..d39daf3c1 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -37,6 +37,7 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "proxy-addr": "^2.0.7", + "smol-toml": "^1.8.0", "tree-sitter": "0.21.1", "tree-sitter-c-sharp": "0.23.1", "tree-sitter-cpp": "0.23.2", @@ -4184,6 +4185,18 @@ "dev": true, "license": "ISC" }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 73b76a481..1e2bc42ad 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -92,6 +92,7 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "proxy-addr": "^2.0.7", + "smol-toml": "^1.8.0", "tree-sitter": "0.21.1", "tree-sitter-c-sharp": "0.23.1", "tree-sitter-cpp": "0.23.2", diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 6b1d46fe1..89e5842f7 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -305,6 +305,9 @@ const NATIVE_ADDON_SMOKE = [ // Filesystem behavior tests — exercise operations that vary across // platforms (CRLF, symlinks, permissions, temp dirs) const FILESYSTEM = [ + // Cargo membership uses path normalization, descriptor validation, symlinks, + // and Rust native parsing (including long Windows source strings). + 'test/unit/scope-resolution/rust-cargo-targets.test.ts', // The durable ParsedFile store's prune tolerates a chunk directory it cannot // delete (#3204). The failures that motivate it — held handles, read-only // mounts — are Windows- and macOS-flavored, and the permission-based case diff --git a/gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts b/gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts new file mode 100644 index 000000000..c73ede641 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts @@ -0,0 +1,340 @@ +import path from 'node:path'; +import type Parser from 'tree-sitter'; +import { splitRustUseDeclaration } from './import-decomposer.js'; + +// Built-in attributes cannot expand to new module declarations. cfg is a union: +// visiting both alternatives is conservative; cfg_attr may change a path. +const NON_EXPANDING_ATTRIBUTES = new Set([ + 'cfg', + 'path', + 'allow', + 'warn', + 'deny', + 'forbid', + 'expect', + 'doc', + 'test', + 'should_panic', + 'ignore', + 'automatically_derived', + 'proc_macro', + 'proc_macro_derive', + 'proc_macro_attribute', + 'inline', + 'cold', + 'no_mangle', + 'export_name', + 'repr', + 'non_exhaustive', + 'must_use', + 'deprecated', + 'no_std', + 'no_main', + 'feature', + 'crate_type', + 'crate_name', + 'recursion_limit', + 'type_length_limit', +]); + +const BUILTIN_DERIVES = new Set([ + 'Clone', + 'Copy', + 'Debug', + 'Default', + 'Eq', + 'Hash', + 'Ord', + 'PartialEq', + 'PartialOrd', +]); +const EXPRESSION_MACROS = new Set([ + 'print', + 'println', + 'eprint', + 'eprintln', + 'assert', + 'assert_eq', + 'assert_ne', + 'debug_assert', + 'debug_assert_eq', + 'debug_assert_ne', + 'vec', + 'format', + 'format_args', + 'write', + 'writeln', + 'panic', + 'todo', + 'unimplemented', + 'unreachable', + 'dbg', + 'matches', +]); + +/** Token arguments can contain blocks/modules or further macro expansion. + * Inspect token nodes, never strings/comments that merely mention those words. */ +function hasExpandingArguments(tokens: Parser.SyntaxNode): boolean { + for (let i = 0; i < tokens.childCount; i++) { + const child = tokens.child(i)!; + if (child.type === 'mod' || child.type === '#') return true; + if (child.type === '!' && tokens.child(i - 1)?.type === 'identifier') return true; + if (child.type === 'token_tree' && hasExpandingArguments(child)) return true; + } + return false; +} + +/** Public use evidence from the same AST used for membership. Keeps restricted + * and private globs distinct without changing the shared import/cache shape. */ +export function rustPublicUses(root: Parser.SyntaxNode): ReadonlySet { + const uses = new Set(); + const pending = [{ node: root, module: '' }]; + while (pending.length > 0) { + const { node, module } = pending.pop()!; + for (const child of node.namedChildren) { + if (child.type === 'mod_item') { + const body = child.childForFieldName('body'); + const name = child.childForFieldName('name')?.text; + if (body && name) + pending.push({ node: body, module: [module, name].filter(Boolean).join('::') }); + } else if ( + child.type === 'use_declaration' && + child.namedChildren.some( + (part) => part.type === 'visibility_modifier' && part.text === 'pub', + ) + ) { + for (const capture of splitRustUseDeclaration(child)) { + uses.add( + JSON.stringify([ + module, + capture['@import.source']?.text, + capture['@import.kind']?.text, + capture['@import.name']?.text, + ]), + ); + } + } + } + } + return uses; +} + +/** Decode a literal path without mistaking strings/comments for Rust syntax. */ +function literalPath(text: string): string | undefined { + const raw = /^r(#+)?"([\s\S]*)"\1$/.exec(text); + if (raw) return raw[2]; + // Escape forms beyond JSON's subset remain unknown, never a guessed path. + try { + const value: unknown = JSON.parse(text); + return typeof value === 'string' ? value : undefined; + } catch { + return undefined; + } +} + +/** External modules reachable from one source file; undefined is incomplete. */ +export function rustModuleFiles( + root: Parser.SyntaxNode, + file: string, + ownsDirectory: boolean, + files: ReadonlySet, + missingFiles?: Set, + isCrateRoot = false, +): readonly { file: string; ownsDirectory: boolean }[] | undefined { + if (root.hasError) return undefined; + // Built-in spellings are not proof when an import/local macro can shadow + // them. Cross-file macro_use/macro_export remain unknown attributes below. + const shadowed = new Set(); + const globs: { node: Parser.SyntaxNode; path: string }[] = []; + const scan = [root]; + while (scan.length) { + const node = scan.pop()!; + if (node.type === 'macro_definition' || node.type === 'mod_item') { + const name = node.childForFieldName('name')?.text; + if (name) shadowed.add(name); + // macro_rules textual scope can extend into child module files. Without + // expansion/scope receipts, do not assume their same-named calls are std. + if (node.type === 'macro_definition' && name && EXPRESSION_MACROS.has(name)) return undefined; + } + if (node.type === 'use_declaration') { + for (const capture of splitRustUseDeclaration(node)) { + if (capture['@import.kind']?.text === 'wildcard') + globs.push({ node, path: capture['@import.source']?.text ?? '' }); + const name = capture['@import.name']?.text; + if (name) shadowed.add(name); + } + } + if (node.type !== 'macro_definition' && node.type !== 'token_tree') + scan.push(...node.namedChildren); + } + const wildcard = globs.some(({ node, path: imported }) => { + const parts = imported.split('::').filter(Boolean); + // Cargo aliases for these names are rejected by the loader. A local + // module/import can still shadow a standard-library path in this file. + if (['std', 'core', 'alloc'].includes(parts[0] ?? '') && !shadowed.has(parts[0]!)) return false; + let depth = 0; + let localModuleScope = true; + for (let parent = node.parent; parent && parent !== root; parent = parent.parent) { + if (parent.type === 'mod_item') depth++; + if ( + parent.type === 'function_item' || + parent.type === 'block' || + parent.type === 'closure_expression' + ) + localModuleScope = false; + } + // The common inline unit-test `use super::*` stays within this AST. + // A file-level super glob has an external parent and remains unknown. + if ( + localModuleScope && + parts.length > 0 && + parts.every((part) => part === 'super') && + parts.length <= depth + ) + return false; + // At a Cargo root, bare/self/crate paths to inline modules have the same + // meaning across editions. Do not extend this assumption to file modules. + if (isCrateRoot && node.parent === root) { + if (parts[0] === 'self' || parts[0] === 'crate') parts.shift(); + let body: Parser.SyntaxNode | undefined = root; + for (const part of parts) { + body = + body?.namedChildren + .find( + (child) => + child.type === 'mod_item' && child.childForFieldName('name')?.text === part, + ) + ?.childForFieldName('body') ?? undefined; + } + if (parts.length > 0 && body !== undefined) return false; + } + return true; + }); + const builtin = (name: string): boolean => !wildcard && !shadowed.has(name); + const fileDir = path.posix.dirname(file); + const moduleDir = + ownsDirectory || path.posix.basename(file) === 'mod.rs' + ? fileDir + : file.slice(0, -'.rs'.length); + const pending = [{ node: root, moduleDir, attributeDir: fileDir }]; + const result: { file: string; ownsDirectory: boolean }[] = []; + let unresolved = false; + while (pending.length) { + const context = pending.pop()!; + let attributes: Parser.SyntaxNode[] = []; + for (const node of context.node.namedChildren) { + if (node.type === 'line_comment' || node.type === 'block_comment') continue; + if (node.type === 'attribute_item' || node.type === 'inner_attribute_item') { + const attribute = node.namedChildren[0]; + const name = attribute?.namedChildren[0]?.text; + if (name === 'derive') { + const argumentsNode = attribute?.childForFieldName('arguments'); + if ( + !argumentsNode || + !builtin('derive') || + argumentsNode.namedChildren.length === 0 || + argumentsNode.namedChildren.some( + (item) => + item.type !== 'identifier' || + !BUILTIN_DERIVES.has(item.text) || + !builtin(item.text), + ) || + argumentsNode.children.some( + (item) => + !['(', ')', ',', 'identifier', 'line_comment', 'block_comment'].includes(item.type), + ) + ) + return undefined; + } else if (!name || !NON_EXPANDING_ATTRIBUTES.has(name)) return undefined; + if (node.type === 'attribute_item') attributes.push(node); + continue; + } + const attrs = attributes; + attributes = []; + // The current scope captures do not carry extern-crate aliases. Do not + // certify a negative import-root proof from an incomplete namespace view. + if (node.type === 'extern_crate_declaration' && node.childForFieldName('alias') !== null) { + return undefined; + } + const macro = + node.type === 'macro_invocation' + ? node + : node.type === 'expression_statement' && + node.namedChildren[0]?.type === 'macro_invocation' + ? node.namedChildren[0] + : undefined; + if (macro) { + const name = macro.childForFieldName('macro')?.text; + const tokens = macro.namedChildren.find((child) => child.type === 'token_tree'); + const parts = name?.split('::').filter(Boolean) ?? []; + const standard = + parts.length === 1 || + (parts.length === 2 && ['std', 'core', 'alloc'].includes(parts[0]!)); + if ( + !standard || + !EXPRESSION_MACROS.has(parts.at(-1) ?? '') || + !parts.every(builtin) || + !tokens || + hasExpandingArguments(tokens) || + context.node.type === 'source_file' || + context.node.type === 'declaration_list' + ) + return undefined; + continue; + } + if (node.type !== 'mod_item') { + // Items (including external #[path] modules) can also occur in blocks. + // Inspect them too; a macro expansion there can add shared membership. + // Macro definitions/token trees and literal contents are not expansions. + if ( + node.type !== 'macro_definition' && + node.type !== 'token_tree' && + node.namedChildCount > 0 + ) { + pending.push({ ...context, node }); + } + continue; + } + const name = node.childForFieldName('name')?.text; + if (!name) return undefined; + let override: string | undefined; + for (const attr of attrs) { + const attribute = attr.namedChildren[0]!; + if (attribute.namedChildren[0]?.text !== 'path') continue; + const value = attribute.childForFieldName('value'); + if (!value || override !== undefined) return undefined; + override = literalPath(value.text); + if (override === undefined || path.posix.isAbsolute(override) || override.includes('\\')) + return undefined; + } + const body = node.childForFieldName('body'); + if (body) { + const dir = + override === undefined + ? path.posix.join(context.moduleDir, name) + : path.posix.join(context.attributeDir, override); + pending.push({ node: body, moduleDir: dir, attributeDir: dir }); + continue; + } + const candidates = + override !== undefined + ? [path.posix.normalize(path.posix.join(context.attributeDir, override))] + : [ + path.posix.join(context.moduleDir, `${name}.rs`), + path.posix.join(context.moduleDir, name, 'mod.rs'), + ]; + const existing = candidates.filter((candidate) => files.has(candidate)); + if (existing.length === 0) { + if (!missingFiles) return undefined; + for (const candidate of candidates) missingFiles.add(candidate); + unresolved = true; + continue; + } + // Union conditional alternatives; shared membership must never be erased. + // #[path] makes the loaded file own its containing directory, just like + // a crate root; its children do NOT acquire the file stem as a prefix. + result.push(...existing.map((file) => ({ file, ownsDirectory: override !== undefined }))); + } + } + return unresolved ? undefined : result; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts b/gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts new file mode 100644 index 000000000..95377f032 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts @@ -0,0 +1,513 @@ +/** Cargo target evidence for the name-guess veto, never a directory heuristic. */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { glob, escape } from 'glob'; +import { parse } from 'smol-toml'; +import Parser from 'tree-sitter'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { readRepoControlFile } from '../../../../config/repo-control-file.js'; +import { rustModuleFiles, rustPublicUses } from './cargo-module-files.js'; + +const MAX_FILES = 100_000; +type Table = Record; +const table = (value: unknown): value is Table => + value !== null && typeof value === 'object' && !Array.isArray(value); + +/** A target is identified by its entry file, not its package directory. */ +export function cargoTargetRoots( + manifest: string, + content: string, + files: ReadonlySet, + workspaceEditions?: ReadonlyMap, +): readonly string[] | undefined { + let data: Table; + try { + data = parse(content); + } catch { + return undefined; + } + if (!table(data.package)) return table(data.workspace) ? [] : undefined; + const pkg = data.package; + if (typeof pkg.name !== 'string') return undefined; + if (pkg.build !== undefined && typeof pkg.build !== 'string' && typeof pkg.build !== 'boolean') + return undefined; + const dir = path.posix.dirname(manifest); + let edition: unknown = pkg.edition ?? '2015'; + if (table(edition) && edition.workspace === true) { + let workspace = + typeof pkg.workspace === 'string' ? path.posix.join(dir, pkg.workspace, '.') : dir; + while ( + !workspaceEditions?.has(workspace) && + typeof pkg.workspace !== 'string' && + workspace !== '.' + ) { + workspace = path.posix.dirname(workspace); + } + edition = workspaceEditions?.get(workspace); + } + if (typeof edition !== 'string' || !['2015', '2018', '2021', '2024'].includes(edition)) + return undefined; + const relative = (file: string): string => path.posix.normalize(path.posix.join(dir, file)); + const roots = new Set(); + for (const [kind, folder] of [ + ['lib', 'src'], + ['bin', 'src/bin'], + ['test', 'tests'], + ['bench', 'benches'], + ['example', 'examples'], + ] as const) { + const autoKey = { + lib: 'autolib', + bin: 'autobins', + test: 'autotests', + bench: 'autobenches', + example: 'autoexamples', + }[kind]; + if (pkg[autoKey] !== undefined && typeof pkg[autoKey] !== 'boolean') return undefined; + const discovered = new Map(); + if (kind === 'lib') { + if (files.has(relative('src/lib.rs'))) discovered.set(pkg.name, relative('src/lib.rs')); + } else { + if (kind === 'bin' && files.has(relative('src/main.rs'))) { + discovered.set(pkg.name, relative('src/main.rs')); + } + const prefix = `${relative(folder)}/`; + for (const file of files) { + if (!file.startsWith(prefix)) continue; + const tail = file.slice(prefix.length); + const match = /^([^/]+)\.rs$/.exec(tail) ?? /^([^/]+)\/main\.rs$/.exec(tail); + if (match) discovered.set(match[1]!, file); + } + } + const explicit = data[kind] === undefined ? [] : kind === 'lib' ? [data[kind]] : data[kind]; + if (!Array.isArray(explicit)) return undefined; + // Cargo 2015's opt-in discovery rule is PER target kind: an explicit + // binary does not disable integration tests, examples, benches or the lib. + const legacy = edition === '2015' && explicit.length > 0; + const overridden = new Set(); + for (const entry of explicit) { + if (!table(entry)) return undefined; + const name = kind === 'lib' ? pkg.name : entry.name; + if (typeof name !== 'string') return undefined; + if (entry.path !== undefined && typeof entry.path !== 'string') return undefined; + if (typeof entry.path === 'string' && path.posix.isAbsolute(entry.path)) return undefined; + const file = typeof entry.path === 'string' ? relative(entry.path) : discovered.get(name); + if (!file || !files.has(file)) return undefined; + roots.add(file); + overridden.add(name); + } + if (pkg[autoKey] === true || (pkg[autoKey] !== false && !legacy)) { + for (const [name, file] of discovered) if (!overridden.has(name)) roots.add(file); + } + } + // Build scripts are crates too, even when located outside src/. + if (typeof pkg.build === 'string') { + const file = relative(pkg.build); + if (!files.has(file)) return undefined; + if (roots.has(file)) return undefined; // Multiple target roles need separate identities. + roots.add(file); + } else if (pkg.build !== false && files.has(relative('build.rs'))) { + if (roots.has(relative('build.rs'))) return undefined; + roots.add(relative('build.rs')); + } + return [...roots]; +} + +class RustCargoTargets { + constructor( + readonly targetsByFile: ReadonlyMap>, + readonly rootImports: ReadonlyMap>>, + readonly publicUsesByFile: ReadonlyMap>, + ) {} +} + +/** Exact public-use evidence, independent of the capture's coarse reexport kind. */ +export function rustCargoPubliclyReexports( + config: unknown, + file: string, + module: string, + target: string, + kind: string, + name: string, +): boolean { + return ( + config instanceof RustCargoTargets && + config.publicUsesByFile.get(file)?.has(JSON.stringify([module, target, kind, name])) === true + ); +} + +/** Positive evidence that this import names this library's ROOT, not a module + * elsewhere (or a binary/test entry point that cannot be imported as a lib). */ +export function rustImportNamesCargoRoot( + config: unknown, + caller: string, + candidate: string, + importedModule: string, +): boolean { + if (!(config instanceof RustCargoTargets)) return false; + const segments = importedModule.split('::').filter(Boolean); + if (segments.length !== 1) return false; + for (const target of config.targetsByFile.get(caller) ?? []) { + if (config.rootImports.get(target)?.get(segments[0]!)?.has(candidate)) return true; + } + return false; +} + +/** Every known membership must identify this file as the entry point. A file + * shared as a module in another target does not have a single root role. */ +export function rustIsExclusiveCargoRoot(config: unknown, file: string): boolean { + if (!(config instanceof RustCargoTargets)) return false; + const targets = config.targetsByFile.get(file); + return targets?.size === 1 && targets.has(file); +} + +/** Establish crate identity before the existing module-path plausibility test. */ +export function rustImportReachesCargoTarget( + config: unknown, + caller: string, + candidate: string, + importedModule: string, +): boolean { + if (!(config instanceof RustCargoTargets)) return false; + const name = importedModule.split('::').filter(Boolean)[0]; + if (!name) return false; + const candidates = config.targetsByFile.get(candidate); + for (const target of config.targetsByFile.get(caller) ?? []) { + for (const imported of config.rootImports.get(target)?.get(name) ?? []) { + if (candidates?.has(imported)) return true; + } + } + return false; +} + +/** Import names are target/package-relative. A dependency alias in another + * package must not authorize a guess here merely because its spelling matches. */ +function cargoRootImports( + manifests: ReadonlyMap, + targets: ReadonlyMap, +): Map>> { + const libraries = new Map(); + for (const [manifest, data] of manifests) { + if (!table(data.package)) continue; + const lib = table(data.lib) ? data.lib : undefined; + if (!lib && data.package.autolib === false) continue; + const root = path.posix.join( + path.posix.dirname(manifest), + typeof lib?.path === 'string' ? lib.path : 'src/lib.rs', + ); + const name = lib?.name ?? data.package.name; + if (typeof name === 'string' && targets.get(manifest)?.includes(root)) { + libraries.set(manifest, { + root, + name: name.replaceAll('-', '_'), + }); + } + } + const result = new Map>>(); + for (const [manifest, data] of manifests) { + if (!table(data.package)) continue; + const dir = path.posix.dirname(manifest); + let workspace = + typeof data.package.workspace === 'string' + ? path.posix.join(dir, data.package.workspace, '.') + : dir; + while ( + !table(manifests.get(path.posix.join(workspace, 'Cargo.toml'))?.workspace) && + typeof data.package.workspace !== 'string' && + workspace !== '.' + ) + workspace = path.posix.dirname(workspace); + const workspaceData = manifests.get(path.posix.join(workspace, 'Cargo.toml'))?.workspace; + const workspaceDeps = + table(workspaceData) && table(workspaceData.dependencies) ? workspaceData.dependencies : {}; + const sections = [ + data, + ...(table(data.target) ? Object.values(data.target).filter(table) : []), + ]; + for (const target of targets.get(manifest) ?? []) { + let imports = result.get(target); + if (!imports) result.set(target, (imports = new Map())); + const add = (name: string, root: string) => { + let roots = imports.get(name); + if (!roots) imports.set(name, (roots = new Set())); + roots.add(root); + }; + const own = libraries.get(manifest); + const buildRoot = path.posix.join( + dir, + typeof data.package.build === 'string' ? data.package.build : 'build.rs', + ); + const isBuild = data.package.build !== false && target === buildRoot; + if (own && !isBuild) add(own.name, own.root); + for (const section of sections) { + // Libraries/binaries can also compile as unit-test targets, so retain + // dev dependencies across cfg modes. Build scripts have their own + // dependency namespace and cannot import the package's own library. + const kinds = isBuild ? ['build-dependencies'] : ['dependencies', 'dev-dependencies']; + for (const kind of kinds) { + const deps = section[kind]; + if (!table(deps)) continue; + for (const [key, declared] of Object.entries(deps)) { + const inherited = table(declared) && declared.workspace === true; + const dep = inherited ? workspaceDeps[key] : declared; + if (!table(dep) || typeof dep.path !== 'string') continue; + const dependency = libraries.get( + path.posix.join(inherited ? workspace : dir, dep.path, 'Cargo.toml'), + ); + if (!dependency) continue; + // Cargo uses the dependency key whenever `package` is explicit, + // even if it equals the package name and [lib].name differs. + const renamed = typeof dep.package === 'string'; + add(renamed ? key.replaceAll('-', '_') : dependency.name, dependency.root); + } + } + } + } + } + return result; +} + +/** Undefined means no complete membership proof; never interpret it as disjoint. */ +export function rustFilesShareCargoTarget( + config: unknown, + caller: string, + candidate: string, +): boolean | undefined { + if (!(config instanceof RustCargoTargets)) return undefined; + const callers = config.targetsByFile.get(caller); + const candidates = config.targetsByFile.get(candidate); + if (!callers || !candidates) return undefined; + return [...callers].some((root) => candidates.has(root)); +} + +/** + * Static, bounded, one-shot provider loader. No cargo/rustc, build scripts, + * repository wrappers, or network. Parse only files reachable from Cargo roots. + * Unknown expansion anywhere can add shared membership, so it invalidates the + * negative proof for this snapshot rather than producing a partial veto. + */ +export async function loadRustCargoTargets(repoPath: string): Promise { + try { + const root = await fs.realpath(repoPath); + const files = new Set(); + const manifests: string[] = []; + for await (const entry of glob.iterate('**/Cargo.toml', { + // Cargo metadata must include targets the graph scanner omits (notably + // src/bin). An omitted target can share a source file with another crate. + cwd: root, + nodir: true, + follow: false, + posix: true, + dot: true, + ignore: [ + '**/.git/**', + '**/node_modules/**', + '**/.gitnexus/**', + '**/target/debug/**', + '**/target/release/**', + '**/target/incremental/**', + '**/target/doc/**', + '**/target/tmp/**', + '**/target/.fingerprint/**', + '**/target/CACHEDIR.TAG', + ], + })) { + if (files.size >= MAX_FILES) return undefined; + files.add(entry); + manifests.push(entry); + } + if (manifests.length === 0) return undefined; + // Artifact-layout pruning (`target/debug`, `target/release`, …) must not + // erase a source path whose segment is named `target` (`src/target/mod.rs`, + // `[lib] path = "target/entry.rs"`, `src/bin/target/main.rs`). Re-scan + // Cargo's auto-target slots without that filter. + for (const manifest of manifests) { + for await (const entry of glob.iterate( + [ + 'src/lib.rs', + 'src/main.rs', + 'src/bin/*.rs', + 'src/bin/*/main.rs', + 'tests/*.rs', + 'tests/*/main.rs', + 'benches/*.rs', + 'benches/*/main.rs', + 'examples/*.rs', + 'examples/*/main.rs', + 'build.rs', + ], + { + cwd: path.join(root, path.posix.dirname(manifest)), + nodir: true, + follow: false, + posix: true, + }, + )) { + if (files.size >= MAX_FILES) return undefined; + files.add(path.posix.join(path.posix.dirname(manifest), entry)); + } + } + const read = async (file: string): Promise => { + const requested = path.resolve(root, file); + const absolute = await fs.realpath(requested); + const rel = path.relative(root, absolute); + if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { + throw new Error('Cargo module outside repository'); + } + if (absolute !== requested) throw new Error('Cargo module alias has unknown membership'); + // The shared reader validates its opened descriptor and bounds streamed + // bytes; the outer realpath check is a separate containment/alias guard. + const content = await readRepoControlFile(root, file); + if (content === null) throw new Error('Cargo source disappeared'); + return content; + }; + const contents = new Map(); + const manifestData = new Map(); + const workspaceEditions = new Map(); + for (const manifest of manifests) { + const content = await read(manifest); + contents.set(manifest, content); + const data = parse(content); + // A dependency alias can replace a std/core/alloc extern-prelude entry. + // In that case the module walker cannot identify standard macros safely. + const sections = [ + data, + ...(table(data.target) ? Object.values(data.target).filter(table) : []), + ]; + if ( + sections.some((section) => + ['dependencies', 'dev-dependencies', 'build-dependencies'].some( + (kind) => + table(section[kind]) && + ['std', 'core', 'alloc'].some((name) => Object.hasOwn(section[kind], name)), + ), + ) + ) + return undefined; + manifestData.set(manifest, data); + if ( + table(data.workspace) && + table(data.workspace.package) && + typeof data.workspace.package.edition === 'string' + ) { + workspaceEditions.set(path.posix.dirname(manifest), data.workspace.package.edition); + } + } + // Manifest discovery prunes artifacts, but explicit source paths and mod + // declarations are authoritative candidates even beneath a `target` folder. + // Probe only those literal paths; never crawl the artifact tree recursively. + const discover = async (candidates: Iterable): Promise => { + const patterns = [...candidates].map((file) => { + if ( + path.posix.isAbsolute(file) || + file === '..' || + file.startsWith('../') || + file.includes('\\') + ) + throw new Error('Cargo source outside repository'); + if ( + !file.endsWith('.rs') || + file + .split('/') + .some((part) => ['.git', '.gitnexus', 'node_modules'].includes(part.toLowerCase())) + ) + throw new Error('Cargo source excluded from inventory'); + return escape(file); + }); + for await (const entry of glob.iterate(patterns, { + cwd: root, + nodir: true, + follow: false, + posix: true, + dot: true, + })) { + if (files.size >= MAX_FILES) throw new Error('Cargo file limit'); + files.add(entry); + } + }; + for (const [manifest, data] of manifestData) { + const explicit = [ + data.lib, + ...['bin', 'test', 'bench', 'example'].flatMap((kind) => + Array.isArray(data[kind]) ? data[kind] : [], + ), + ]; + const paths = explicit + .filter(table) + .map((entry) => entry.path) + .filter((value): value is string => typeof value === 'string'); + if (table(data.package) && typeof data.package.build === 'string') + paths.push(data.package.build); + await discover(paths.map((file) => path.posix.join(path.posix.dirname(manifest), file))); + } + const roots = new Set(); + const targetsByManifest = new Map(); + for (const [manifest, content] of contents) { + const targets = cargoTargetRoots(manifest, content, files, workspaceEditions); + if (targets === undefined) return undefined; + targetsByManifest.set(manifest, targets); + for (const target of targets) roots.add(target); + } + const parser = new Parser(); + parser.setLanguage(getLanguageGrammar(SupportedLanguages.Rust)); + const targetsByFile = new Map>(); + const publicUsesByFile = new Map>(); + // A file may be reached conventionally AND through #[path]. Those have + // different submodule bases, so cache and visit both contexts separately. + const childrenByFile = new Map>>(); + let visits = 0; + for (const target of roots) { + const pending = [{ file: target, ownsDirectory: true }]; + const visited = new Set(); + while (pending.length > 0) { + if (++visits > MAX_FILES) return undefined; + const { file, ownsDirectory } = pending.pop()!; + const key = `${file === target ? 'root' : ownsDirectory ? 'owned' : 'module'}:${file}`; + if (visited.has(key)) continue; + visited.add(key); + let owners = targetsByFile.get(file); + if (!owners) targetsByFile.set(file, (owners = new Set())); + owners.add(target); + let children = childrenByFile.get(key); + if (!children) { + const tree = parseSourceSafe(parser, await read(file)); + const missing = new Set(); + let result = rustModuleFiles( + tree.rootNode, + file, + ownsDirectory, + files, + missing, + file === target, + ); + if (result === undefined && missing.size > 0) { + await discover(missing); + result = rustModuleFiles( + tree.rootNode, + file, + ownsDirectory, + files, + undefined, + file === target, + ); + } + if (result === undefined) return undefined; + publicUsesByFile.set(file, rustPublicUses(tree.rootNode)); + children = result; + childrenByFile.set(key, children); + } + pending.push(...children); + } + } + return new RustCargoTargets( + targetsByFile, + cargoRootImports(manifestData, targetsByManifest), + publicUsesByFile, + ); + } catch { + // I/O, parse or containment failure cannot establish target separation. + return undefined; + } +} diff --git a/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts index c209d570b..2ed1ce418 100644 --- a/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts @@ -10,16 +10,19 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js'; export function splitRustUseDeclaration(node: SyntaxNode): CaptureMatch[] { if (node.type !== 'use_declaration') return []; - const isReexport = hasVisibilityModifier(node); + const isReexport = hasUnrestrictedPub(node); const argument = getUseArgument(node); if (argument === null) return []; return decomposeUseArgument(argument, '', isReexport, node); } -function hasVisibilityModifier(node: SyntaxNode): boolean { +function hasUnrestrictedPub(node: SyntaxNode): boolean { for (let i = 0; i < node.childCount; i++) { - if (node.child(i)?.type === 'visibility_modifier') return true; + const child = node.child(i); + // `pub(crate)` / `pub(super)` / `pub(in …)` / `crate` are not the crate's + // public surface, so they must not walk as `pub use` re-export evidence. + if (child?.type === 'visibility_modifier' && child.text === 'pub') return true; } return false; } @@ -146,7 +149,9 @@ function buildScopedPath(node: SyntaxNode): string { if (node.type === 'scoped_identifier') { const parts: string[] = []; collectScopedParts(node, parts); - return parts.join('::'); + // An absolute extern-prelude path bypasses a same-named local module. + // Keep that evidence when flattening the AST into an import specifier. + return `${node.text.startsWith('::') ? '::' : ''}${parts.join('::')}`; } return node.text; } @@ -174,7 +179,7 @@ function buildWildcardPath(node: SyntaxNode): string { const child = node.child(i); if (child === null) continue; if (child.type === 'scoped_identifier') return buildScopedPath(child); - if (child.type === 'identifier') return child.text; + if (['identifier', 'crate', 'self', 'super'].includes(child.type)) return child.text; } return ''; } diff --git a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts index 1afdb900f..52140814d 100644 --- a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts @@ -20,6 +20,13 @@ */ import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { + rustFilesShareCargoTarget, + rustImportNamesCargoRoot, + rustIsExclusiveCargoRoot, + rustImportReachesCargoTarget, + rustCargoPubliclyReexports, +} from './cargo-targets.js'; import { modulePathReaches, stripExtension, @@ -34,6 +41,16 @@ const RUST_CRATE_ROOT_DIRS: ReadonlySet = new Set(['src', 'tests', 'benc /** Path prefixes of a `use` that name a root rather than a module segment. */ const RUST_USE_ROOT_PREFIXES: ReadonlySet = new Set(['crate', '$crate']); +const RUST_TYPE_NAMESPACE_KINDS: ReadonlySet = new Set([ + 'Namespace', + 'Class', + 'Struct', + 'Enum', + 'Trait', + 'Interface', + 'TypeAlias', + 'Union', +]); // One scope lookup per immutable parsed-file snapshot, not per fallback site. // Weak keys release both the snapshot and its index at the end of ingestion. @@ -92,6 +109,8 @@ function rustUsePathOf(targetRaw: string, callerFilePath: string): string { export function rustIsGlobalNameFallbackPlausible(ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + readonly resolutionConfig?: unknown; + readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined; readonly site: { readonly name: string; readonly rawQualifiedName?: string; @@ -106,12 +125,79 @@ export function rustIsGlobalNameFallbackPlausible(ctx: { if (ctx.site.rawQualifiedName !== undefined) return true; const candidateModule = rustModulePathOf(ctx.candidate.filePath); - // A candidate whose file maps to no module path (a crate root reduced to '') - // is not something this rule can speak about; allow the labeled edge rather - // than refuse on an unanswered question. - if (candidateModule === '') return true; + const sharesTarget = rustFilesShareCargoTarget( + ctx.resolutionConfig, + ctx.callerParsed.filePath, + ctx.candidate.filePath, + ); + const separateRoot = + sharesTarget === false && + rustIsExclusiveCargoRoot(ctx.resolutionConfig, ctx.candidate.filePath); + // Cargo membership does not establish cross-file lexical visibility. + // Root candidates must also pass the import checks below; same-file and + // explicitly qualified calls have already been handled above. const candidateName = rustSimpleNameOf(ctx.candidate); + const exportModules = new Set([(ctx.candidate.namespacePrefix ?? '').replaceAll('.', '::')]); + const candidateParsed = + sharesTarget === false ? ctx.parsedFileOf?.(ctx.candidate.filePath) : undefined; + if (candidateParsed !== undefined) { + const moduleByScope = new Map(); + const moduleScopes = new Set(); + for (const scope of candidateParsed.scopes) { + const parent = scope.parent === null ? '' : (moduleByScope.get(scope.parent) ?? ''); + const own = + scope.kind === 'Namespace' + ? scope.ownedDefs.find((def) => def.type === 'Namespace')?.qualifiedName + : undefined; + moduleByScope.set(scope.id, [parent, own].filter(Boolean).join('::')); + if (scope.kind === 'Namespace' || scope.kind === 'Module') moduleScopes.add(scope.id); + } + // Follow same-file re-exports without confusing the defining module with + // the module an importer sees. Each iteration adds a known module scope, + // so cycles terminate. Cargo's AST snapshot supplies public visibility, + // which the coarse parsed reexport/wildcard kind does not preserve. + let changed = true; + while (changed) { + changed = false; + for (const imp of candidateParsed.parsedImports) { + if (imp.declaredAtScope === undefined || !moduleScopes.has(imp.declaredAtScope)) continue; + if ( + imp.kind !== 'wildcard' && + (imp.kind !== 'reexport' || + imp.localName !== candidateName || + imp.importedName !== candidateName) + ) + continue; + if (imp.targetRaw.startsWith('::')) continue; + const owner = moduleByScope.get(imp.declaredAtScope)!; + if ( + !rustCargoPubliclyReexports( + ctx.resolutionConfig, + ctx.candidate.filePath, + owner, + imp.targetRaw, + imp.kind, + imp.kind === 'wildcard' ? '*' : imp.localName, + ) + ) + continue; + const parts = imp.targetRaw.split('::').filter(Boolean); + if (imp.kind !== 'wildcard') parts.pop(); + const base = + parts[0] === 'self' || parts[0] === 'super' ? owner.split('::').filter(Boolean) : []; + if (parts[0] === 'crate' || parts[0] === 'self') parts.shift(); + while (parts[0] === 'super') { + base.pop(); + parts.shift(); + } + if (exportModules.has([...base, ...parts].join('::')) && !exportModules.has(owner)) { + exportModules.add(owner); + changed = true; + } + } + } + } // Imports are lexical evidence, not a file-wide allowlist. Legacy/synthetic // imports without a scope receipt retain the previous conservative behavior. let visibleScopes: Set | undefined; @@ -130,25 +216,120 @@ export function rustIsGlobalNameFallbackPlausible(ctx: { current = scope.parent; } } - for (const imp of ctx.callerParsed.parsedImports) { - if ( - imp.declaredAtScope !== undefined && - visibleScopes !== undefined && - !visibleScopes.has(imp.declaredAtScope) - ) - continue; + const visibleImports = ctx.callerParsed.parsedImports.filter( + (imp) => + imp.declaredAtScope === undefined || + visibleScopes === undefined || + visibleScopes.has(imp.declaredAtScope), + ); + const scopeRanks = new Map([...(visibleScopes ?? [])].map((scope, rank) => [scope, rank])); + const namesCandidateRoot = (module: string, entryOnly: boolean): 'root' | 'module' | false => { + const pending = [module]; + const seen = new Set(); + while (pending.length > 0) { + const name = pending.pop()!; + const parts = name.split('::').filter(Boolean); + const head = parts[0]; + const bindingName = name.startsWith('::') ? name : head; + if (!bindingName || seen.has(bindingName)) continue; + seen.add(bindingName); + const aliases = visibleImports.filter( + (imported) => 'localName' in imported && imported.localName === bindingName, + ); + const rank = (imported: (typeof visibleImports)[number]) => + imported.declaredAtScope === undefined + ? Infinity + : (scopeRanks.get(imported.declaredAtScope) ?? Infinity); + const nearest = aliases.length > 0 ? Math.min(...aliases.map(rank)) : Infinity; + let localTypeRank = Infinity; + for (const [scopeId, depth] of scopeRanks) { + const bindings = scopeLookupByFile + .get(ctx.callerParsed) + ?.get(scopeId) + ?.bindings.get(bindingName); + if ( + bindings?.some( + (binding) => + binding.origin === 'local' && RUST_TYPE_NAMESPACE_KINDS.has(binding.def.type), + ) + ) { + localTypeRank = depth; + break; + } + } + // A local module/type shadows the extern prelude, but a nearer import + // can shadow that declaration. Value-namespace functions do not block it. + if (localTypeRank !== Infinity && localTypeRank <= nearest) continue; + if (aliases.length > 0) { + const destinations = new Set( + aliases + .filter((imported) => rank(imported) === nearest) + .map((imported) => imported.targetRaw), + ); + if (destinations.size !== 1) continue; + const destination = [...destinations][0]!; + if (destination !== bindingName) { + pending.push([destination, ...parts.slice(1)].join('::')); + continue; + } + } + // A root FILE can also contain inline modules. Its Cargo identity names + // the crate, while the scope model supplies the member's module suffix. + if ( + rustImportNamesCargoRoot( + ctx.resolutionConfig, + ctx.callerParsed.filePath, + ctx.candidate.filePath, + head!, + ) + ) { + if (exportModules.has(parts.slice(1).join('::'))) return 'root'; + continue; + } + if ( + !entryOnly && + rustImportReachesCargoTarget( + ctx.resolutionConfig, + ctx.callerParsed.filePath, + ctx.candidate.filePath, + name, + ) + ) + return 'module'; + } + return false; + }; + for (const imp of visibleImports) { + let importedRoot = false; // `crate::` is the caller's crate. A same trailing module in another // workspace crate is a different item and cannot authorize the guess. if (imp.targetRaw === 'crate' || imp.targetRaw.startsWith('crate::')) { + if (sharesTarget === false) continue; const callerRoot = rustCrateRootOf(ctx.callerParsed.filePath); const candidateRoot = rustCrateRootOf(ctx.candidate.filePath); - if (callerRoot !== '' && candidateRoot !== '' && callerRoot !== candidateRoot) continue; + if ( + sharesTarget === undefined && + callerRoot !== '' && + candidateRoot !== '' && + callerRoot !== candidateRoot + ) + continue; + } + if (sharesTarget === false) { + const module = + imp.kind === 'wildcard' + ? imp.targetRaw + : imp.targetRaw.slice(0, Math.max(0, imp.targetRaw.lastIndexOf('::'))); + const reached = namesCandidateRoot(module, separateRoot); + if (!reached) continue; + importedRoot = reached === 'root'; } const usePath = rustUsePathOf(imp.targetRaw, ctx.callerParsed.filePath); // Only a glob introduces every bare item of a module. A named import must // match both the candidate's original name and the call's local spelling. if (imp.kind === 'wildcard') { - if (modulePathReaches(usePath, candidateModule)) return true; + if (importedRoot || candidateModule === '' || modulePathReaches(usePath, candidateModule)) + return true; continue; } if (!('localName' in imp) || imp.localName !== ctx.site.name) continue; @@ -158,8 +339,12 @@ export function rustIsGlobalNameFallbackPlausible(ctx: { // parent-path match used to accept every item of `a` on its strength. // An alias authorizes only the local spelling checked above. if (importedNameOf(imp) !== candidateName) continue; - if (modulePathReaches(usePath, candidateModule)) return true; + // A different target may import the library crate's root exports. Even + // when that root has no path segment to compare, a named import must name + // THIS callable: `use std::fmt` cannot revive a rejected `crate::helper`. const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::'))); + if (importedRoot || candidateModule === '') return true; + if (modulePathReaches(usePath, candidateModule)) return true; if (parent !== '' && modulePathReaches(parent, candidateModule)) return true; } return false; diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index 71fb697db..4beda7c03 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -20,6 +20,7 @@ import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/gene import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { rustIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; +import { loadRustCargoTargets } from './cargo-targets.js'; /** * Emit Rust `S IMPLEMENTS T` edges from `impl T for S` trait implementations. @@ -157,6 +158,7 @@ export const rustScopeResolver: ScopeResolver = { language: SupportedLanguages.Rust, languageProvider: rustProvider, importEdgeReason: 'rust-scope: use', + loadResolutionConfig: loadRustCargoTargets, resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index d6da6d4d9..71385a41f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -920,6 +920,8 @@ export interface ScopeResolver { readonly isGlobalNameFallbackPlausible?: (ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + /** Opaque workspace metadata from this provider's loadResolutionConfig. */ + readonly resolutionConfig?: unknown; readonly parsedFileOf: (filePath: string) => ParsedFile | undefined; /** * Raw source of any parsed file, for languages whose visibility rule needs diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 7dcc792f3..3e7c3013c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -74,6 +74,7 @@ export function emitFreeCallFallback( /** Per-language veto on a name guess — see * `ScopeResolver.isGlobalNameFallbackPlausible`. */ readonly isGlobalNameFallbackPlausible?: ScopeResolver['isGlobalNameFallbackPlausible']; + readonly resolutionConfig?: unknown; /** Raw source lookup handed to `isGlobalNameFallbackPlausible` (optional). */ readonly sourceTextOf?: (filePath: string) => string | undefined; /** When true, `Type(...)` constructor calls link to the Class def @@ -650,6 +651,7 @@ export function emitFreeCallFallback( options.isGlobalNameFallbackPlausible?.({ callerParsed: parsed, candidate: vetoCandidate, + resolutionConfig: options.resolutionConfig, parsedFileOf: parsedFileByPath(), sourceTextOf: options.sourceTextOf, site: { diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 7cc96e829..58dd19248 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -1102,6 +1102,7 @@ export function runScopeResolution( allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, language: provider.language, isGlobalNameFallbackPlausible: provider.isGlobalNameFallbackPlausible, + resolutionConfig, sourceTextOf: provider.isGlobalNameFallbackPlausible !== undefined ? (filePath: string) => getFileContents().get(filePath) diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 1c7c1073b..e49044b7e 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -763,7 +763,13 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // #3190. Old durable ParsedFiles lack the facts needed for scoped binding; // invalidate both stores so warm indexing actually applies the correction. // origin/main took 98 for #3219; 99 is the next free value. -const SCHEMA_BUMP = 99; +// v100 (#3253): Rust import captures preserve the leading `::` that selects +// the extern prelude. Old warm captures erase it and cannot distinguish an +// absolute library import from a same-named local module. Reparse both stores. +// v101 (#3294 review): Rust bare-keyword glob imports retain crate/self/super +// instead of an empty target path; restricted pub(...) imports are no longer +// captured as unrestricted reexports. Re-extract both facts on warm indexes. +const SCHEMA_BUMP = 101; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/resolvers/rust-cargo-review-regressions.test.ts b/gitnexus/test/integration/resolvers/rust-cargo-review-regressions.test.ts new file mode 100644 index 000000000..7a56418ec --- /dev/null +++ b/gitnexus/test/integration/resolvers/rust-cargo-review-regressions.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js'; +import { + loadRustCargoTargets, + rustFilesShareCargoTarget, +} from '../../../src/core/ingestion/languages/rust/cargo-targets.js'; + +const PACKAGE = '[package]\nname="demo"\nedition="2021"\n'; + +async function check(files: Record, expectedCalls: number, complete = true) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cargo-review-')); + try { + writeFixtureRepo(dir, { 'Cargo.toml': PACKAGE, ...files }); + const config = await loadRustCargoTargets(dir); + if (complete) expect(config).toBeDefined(); + else expect(config).toBeUndefined(); + const result = await runPipelineFromRepo(dir, () => {}); + const candidates = [...result.graph.iterNodes()].filter( + (node) => node.properties.name === 'helper', + ); + expect(candidates.length).toBeGreaterThan(0); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(expectedCalls); + return { config, calls }; + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } +} + +describe('Cargo review regressions (#3294)', () => { + it.each(['', 'use crate::helper;', 'use super::helper;', 'use crate::*;', 'use super::*;'])( + 'requires lexical import evidence within the same Cargo target: %s', + async (imported) => { + await check( + { + 'src/lib.rs': 'fn helper() {} mod child;', + 'src/child.rs': `${imported} pub fn caller() { helper(); }`, + }, + imported === '' ? 0 : 1, + ); + }, + ); + it.each([ + '#[derive(Debug, Clone)] pub struct T;', + 'fn noisy() { println!("x"); assert_eq!(1, 1); let _v = vec![1, 2]; }', + 'fn noisy() { std::println!("x"); core::assert_eq!(1, 1); }', + '#[derive(Debug)] struct T; #[cfg(test)] mod tests { use super::*; #[test] fn f() { assert_eq!(1,1); } }', + 'use std::fmt::*; fn noisy() { println!("x"); }', + '#[test] #[should_panic] #[ignore] fn expected_panic() { panic!("expected"); }', + ])('retains target separation with ordinary Rust: %s', async (source) => { + await check( + { + 'src/lib.rs': `${source} use crate::helper; pub fn caller() { helper(); }`, + 'tests/helper.rs': 'pub fn helper() {}', + }, + 0, + ); + }); + + it('ordinary macros in a sibling target do not erase the proof', async () => { + await check( + { + 'src/lib.rs': 'use crate::helper; pub fn caller() { helper(); }', + 'tests/helper.rs': 'pub fn helper() {}', + 'tests/other.rs': '#[test] fn ordinary() { assert_eq!(1, 1); }', + }, + 0, + ); + }); + + it('restores a real module directory named target', async () => { + const { config } = await check( + { + 'src/lib.rs': 'mod target; use crate::helper; pub fn caller() { helper(); }', + 'src/target/mod.rs': 'pub fn other() {}', + 'tests/helper.rs': 'pub fn helper() {}', + }, + 0, + ); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'src/target/mod.rs')).toBe(true); + }); + + it('does not import a build-script root into the library', async () => { + await check( + { + 'src/lib.rs': 'use std::build::*; pub fn caller() { helper(); }', + 'build.rs': 'pub fn helper() {} fn main() {}', + }, + 0, + ); + }); + + it('restores explicit roots under artifact-pruned directories', async () => { + await check( + { + 'Cargo.toml': `${PACKAGE}[[bin]]\nname="custom"\npath="target/entry.rs"\n`, + 'target/entry.rs': 'fn main() {}', + 'src/lib.rs': 'use crate::helper; pub fn caller() { helper(); }', + 'tests/helper.rs': 'pub fn helper() {}', + }, + 0, + ); + }); + + it('keeps a custom library root import when that file is shared with a binary', async () => { + await check( + { + 'Cargo.toml': `${PACKAGE}[lib]\npath="custom/entry.rs"\n`, + 'custom/entry.rs': 'pub fn helper() {}', + 'src/main.rs': '#[path="../custom/entry.rs"] mod shared; fn main() {}', + 'tests/caller.rs': 'use demo::*; pub fn caller() { helper(); }', + }, + 1, + ); + }); + + it.each(['', 'pub(crate) ', 'pub(super) ', 'pub(in crate) ', 'pub '])( + 'honors re-export visibility for %suse', + async (visibility) => { + for (const imported of ['helper', '*']) { + await check( + { + 'src/lib.rs': `#[derive(Debug)] struct T; mod nested { pub fn helper() {} } ${visibility}use nested::${imported};`, + 'tests/caller.rs': 'use demo::*; pub fn caller() { helper(); }', + }, + visibility === 'pub ' ? 1 : 0, + ); + } + }, + ); + + it.each([ + ['pub fn helper() {}', 'demo', 1], + ['pub fn helper() {}', 'demo::nested', 0], + ['pub mod nested { pub fn helper() {} }', 'demo', 0], + ['pub mod nested { pub fn helper() {} }', 'demo::nested', 1], + ] as const)( + 'selects the imported root role even for a shared root: %s / %s', + async (source, imported, count) => { + await check( + { + 'src/lib.rs': source, + 'src/main.rs': '#[path="lib.rs"] mod shared; fn main() {}', + 'tests/caller.rs': `use ${imported}::*; pub fn caller() { helper(); }`, + }, + count, + ); + }, + ); + + it('keeps uncertainty for an unknown macro that can share both files', async () => { + const { calls } = await check( + { + 'src/lib.rs': 'use crate::helper; pub fn caller() { helper(); }', + 'tests/helper.rs': 'pub fn helper() {}', + 'src/main.rs': + 'macro_rules! share { () => { include!("../tests/helper.rs"); #[path="lib.rs"] mod library; } } share!(); fn main() {}', + }, + 1, + false, + ); + expect(calls[0]?.rel).toMatchObject({ reason: 'global-name-fallback', confidence: 0.5 }); + }); + + it('preserves a labeled guess for an unmodeled extern-crate alias, not an alias-resolution claim', async () => { + const { calls } = await check( + { + 'src/lib.rs': 'pub fn helper() {}', + 'tests/caller.rs': 'extern crate demo as api; use api::*; pub fn caller() { helper(); }', + }, + 1, + false, + ); + expect(calls[0]?.targetFilePath).toBe('src/lib.rs'); + expect(calls[0]?.rel).toMatchObject({ reason: 'global-name-fallback', confidence: 0.5 }); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts b/gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts new file mode 100644 index 000000000..87d59d441 --- /dev/null +++ b/gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js'; +import { loadRustCargoTargets } from '../../../src/core/ingestion/languages/rust/cargo-targets.js'; + +describe('Rust Cargo target boundaries in name fallback (#3253)', () => { + it.each([ + ['use demo::*;', false], + ['use demo::helper;', false], + ['use demo::nested::*;', true], + ['use demo::nested::helper;', true], + ['use demo as api; use api::nested::*;', true], + ['use ::demo as demo; use demo::nested::*;', true], + ['use b as a; use a as b; use a::*;', false], + ] as const)('matches inline modules within a Cargo root: %s', async (source, allowed) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-inline-root-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nedition="2021"\n', + 'src/lib.rs': 'pub mod nested { pub fn helper() {} }', + 'tests/caller.rs': `${source} pub fn caller() { helper(); }`, + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toHaveLength(allowed ? 1 : 0); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['pub mod nested { pub fn helper() {} } pub use nested::helper;', 'demo'], + ['pub mod nested { pub fn helper() {} } pub use nested::*;', 'demo'], + ['pub fn helper() {} pub mod nested { pub use super::helper; }', 'demo::nested'], + [ + 'pub mod a { pub fn helper() {} } pub mod b { pub use crate::a::helper; } pub use b::helper;', + 'demo', + ], + ])('preserves same-file re-export evidence: %s', async (library, imported) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-reexport-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nedition="2021"\n', + 'src/lib.rs': library, + 'tests/caller.rs': `use ${imported}::*; pub fn caller() { helper(); }`, + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each(['tests/fmt.rs', 'benches/fmt.rs', 'examples/fmt.rs'])( + 'uses Cargo root identity for the nonempty file stem %s', + async (target) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-root-stem-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': 'use std::fmt::*; pub fn caller() { helper(); }', + [target]: 'pub fn helper() {}', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect(result.graph.getNode(`Function:${target}:helper`)).toBeDefined(); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }, + ); + + it('recognizes a custom library entry file as the imported crate root', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-custom-root-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': + '[package]\nname="demo"\nversion="0.1.0"\nedition="2021"\n[lib]\npath="library/entry.rs"\n', + 'library/entry.rs': 'pub fn helper() {}', + 'tests/caller.rs': 'use demo::*; pub fn caller() { helper(); }', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['use target_boundary::shared::*;', true], + ['use target_boundary as api; use api::shared::*;', true], + ['use std::shared::*;', false], + ] as const)( + 'keeps crate identity for a file shared as both module and target: %s', + async (source, allowed) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-shared-root-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': '#[path="../tests/shared.rs"] pub mod shared;', + 'tests/shared.rs': 'pub fn helper() {}', + 'examples/caller.rs': `${source} pub fn caller() { helper(); }`, + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toHaveLength(allowed ? 1 : 0); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }, + ); + + it.each(['use std::fmt::*;', 'use target_boundary::nested::*;', 'use std::helper;'])( + 'an unrelated import cannot reach a binary-root helper: %s', + async (source) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-unrelated-glob-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': `${source} pub fn caller() { helper(); }`, + 'src/main.rs': 'pub fn helper() {}', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect(result.graph.getNode('Function:src/main.rs:helper')).toBeDefined(); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }, + ); + + it('an unrelated import cannot revive a rejected crate-root candidate', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-unrelated-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': 'use crate::helper; use std::fmt; pub fn caller() { helper(); }', + 'src/main.rs': 'pub fn helper() {}', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect(result.graph.getNode('Function:src/main.rs:helper')).toBeDefined(); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + 'use target_boundary::helper;', + 'use target_boundary::*;', + 'use target_boundary as api; use api::*;', + ])('preserves an explicit library import from an integration target: %s', async (source) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-library-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': 'pub fn helper() {}', + 'tests/caller.rs': `${source} pub fn caller() { helper(); }`, + }); + expect(await loadRustCargoTargets(dir)).toBeDefined(); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.targetFilePath).toBe('src/lib.rs'); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['mod target_boundary {} use target_boundary::*;', '', false], + ['mod target_boundary {} use ::target_boundary::*;', '', true], + ['fn target_boundary() {} use target_boundary::*;', '', true], + ['mod api {} fn allowed() { use target_boundary as api; use api::*; helper(); }', '', true], + ['use std::*;', '', false], + ['use target_boundary::nested::*;', '', false], + ['use public_api::*;', '[lib]\nname="public_api"\n', true], + ['use target_boundary::*;', '[lib]\nname="public_api"\n', false], + [ + 'use target_boundary as api; fn denied() { use std::fmt as api; use api::*; helper(); }', + '', + false, + ], + ] as const)('requires the actual library root for %s', async (source, lib, allowed) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-root-name-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': `[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n${lib}`, + 'src/lib.rs': 'pub fn helper() {}', + 'tests/caller.rs': `${source} pub fn caller() { helper(); }`, + }); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter((edge) => edge.target === 'helper'); + expect(calls).toHaveLength(allowed ? 1 : 0); + expect(calls.map((edge) => edge.source)).toEqual( + allowed ? [source.includes('fn allowed()') ? 'allowed' : 'caller'] : [], + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([false, true])( + 'recognizes a renamed path dependency (workspace inherited: %s)', + async (inherited) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-dep-alias-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': + '[package]\nname="root-lib"\nversion="0.1.0"\nedition="2021"\n[workspace]\nmembers=["consumer"]\n' + + (inherited ? '[workspace.dependencies]\nrenamed={package="root-lib",path="."}\n' : ''), + 'src/lib.rs': 'pub fn helper() {}', + 'consumer/Cargo.toml': + '[package]\nname="consumer"\nversion="0.1.0"\nedition="2021"\n[dependencies]\n' + + (inherited ? 'renamed={workspace=true}\n' : 'renamed={package="root-lib",path=".."}\n'), + 'consumer/src/lib.rs': 'use renamed::*; pub fn caller() { helper(); }', + }); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.targetFilePath).toBe('src/lib.rs'); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }, + ); + + it.each([ + 'tests/helper.rs', + 'benches/helper.rs', + 'examples/helper.rs', + 'src/bin/helper.rs', + 'src/main.rs', + ])('does not use the separate target %s to satisfy a library crate import', async (target) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-target-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname = "target-boundary"\nversion = "0.1.0"\nedition = "2021"\n', + '.gitnexusignore': '!src/bin/\n', + 'src/lib.rs': 'use crate::helper;\npub fn caller() { helper(); }\n', + [target]: 'pub fn helper() {}\n', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect(result.graph.getNode(`Function:${target}:helper`)).toBeDefined(); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['library module', 'src/helper.rs', 'mod helper; use crate::helper::helper;'], + [ + 'unit-test module', + 'src/tests/helper.rs', + '#[cfg(test)] mod tests { pub mod helper; } use crate::tests::helper::helper;', + ], + [ + 'shared integration-test source', + 'tests/helper.rs', + '#[path="../tests/helper.rs"] mod shared; use crate::shared::helper;', + ], + ])('preserves a valid %s call', async (_name, target, source) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-positive-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="positive"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': `${source}\npub fn caller() { helper(); }\n`, + [target]: 'pub fn helper() {}\n', + }); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.targetFilePath).toBe(target); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['missing metadata', undefined, ''], + ['malformed metadata', '[package', ''], + [ + 'unmodeled module expansion', + '[package]\nname="unknown"\nversion="0.1.0"\nedition="2021"\n', + 'include!("generated.rs");', + ], + [ + 'unmodeled extern-crate alias', + '[package]\nname="unknown"\nversion="0.1.0"\nedition="2021"\n', + 'extern crate self as api;', + ], + ])('preserves a labeled guess with %s', async (_name, manifest, prefix) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-unknown-')); + try { + writeFixtureRepo(dir, { + ...(manifest === undefined ? {} : { 'Cargo.toml': manifest }), + 'src/lib.rs': `${prefix}\nuse crate::helper; pub fn caller() { helper(); }`, + 'tests/helper.rs': 'pub fn helper() {}', + }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.rel.reason).toBe('global-name-fallback'); + expect(calls[0]!.rel.confidence).toBe(0.5); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it('refuses a crate-root helper from another target on typical derive/assert_eq source', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-derive-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': + '#[derive(Debug)] struct S;\npub fn helper() {}\nfn t() { assert_eq!(1, 1); }\n', + 'tests/caller.rs': 'pub fn caller() { helper(); }\n', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it('does not treat pub(crate) use as a public re-export for integration targets', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-pub-crate-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nedition="2021"\n', + 'src/lib.rs': 'pub mod nested { pub fn helper() {} } pub(crate) use nested::helper;', + 'tests/caller.rs': 'use demo::*; pub fn caller() { helper(); }', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust-coverage.test.ts b/gitnexus/test/integration/resolvers/rust-coverage.test.ts index 6d8d983b8..a317743b9 100644 --- a/gitnexus/test/integration/resolvers/rust-coverage.test.ts +++ b/gitnexus/test/integration/resolvers/rust-coverage.test.ts @@ -88,3 +88,16 @@ describe('F72 — macro invocations (capture layer)', () => { expect(macroDecls[0]['@declaration.name'].text).toBe('greet'); }); }); + +describe('restricted visibility use is not a re-export', () => { + it.each([ + ['pub use foo::helper;', 'reexport'], + ['pub(crate) use foo::helper;', 'named'], + ['pub(super) use foo::helper;', 'named'], + ['use foo::helper;', 'named'], + ])('%s', (src, kind) => { + const matches = emitRustScopeCaptures(src, 'test.rs') as CaptureMatch[]; + const imps = matches.filter((m) => m['@import.kind']); + expect(imps.map((m) => m['@import.kind']?.text)).toEqual([kind]); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index feedc664f..f02c3879e 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -275,9 +275,12 @@ describe('PARSE_CACHE_VERSION', () => { // registered accessor goes back to `epistemic: "exact"`: the #3399 defect, // silently un-fixed on exactly the incremental path most users are on. // Moved 98 -> 99 for #3190: lexical import provenance and corrected export - // evidence. origin/main took 98 for #3219; 99 is the next free value. - it('pins SCHEMA_BUMP to 99 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(99); + // evidence. origin/main took 98 for #3219 and 99 for #3190. + // Moved 99 -> 100 for #3253: retain absolute Rust import qualifiers. + // Moved 100 -> 101 for #3294 review: retain keyword glob paths and distinguish + // restricted pub(...) imports from unrestricted reexports. + it('pins SCHEMA_BUMP to 101 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(101); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -285,7 +288,7 @@ describe('PARSE_CACHE_VERSION', () => { // Every nearby historical or in-flight value is rejected. for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, - 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, + 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts index 8548a0232..64d095d9e 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts @@ -3,11 +3,11 @@ * shared path arithmetic they are built on. * * These hooks decide whether a UNIQUE-NAME GUESS is allowed to become a labeled - * CALLS edge or must be dropped as impossible. The asymmetry matters for how - * these tests are written: a wrong `false` deletes a real edge, so every case - * that the language cannot decide is asserted to return `true`. "Refuses when - * impossible" and "does not refuse when merely unproven" are therefore BOTH - * requirements, and both are tested per language. + * CALLS edge or must be dropped by a language visibility rule. A wrong `false` + * deletes a real edge, so uncertainty alone must not invent a refusal. It also + * must not bypass an independent rule: Rust cross-file bare calls still need + * a visible use path, whether Cargo membership is known or unknown. Tests pin + * both required refusals and permitted guesses under incomplete evidence. * * Pure functions over synthetic stubs — no pipeline, no fixtures. */ @@ -438,13 +438,23 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { ).toBe(true); }); - it('does not refuse when the candidate file maps to no module path', () => { + it('REFUSES a crate-root candidate without cargo identity or a covering use', () => { expect( rustIsGlobalNameFallbackPlausible({ site: BARE_SITE, callerParsed: mkCaller('src/b.rs'), candidate: mkCandidate('lib.rs', 'unique_helper_xyz'), }), + ).toBe(false); + }); + + it('allows a crate-root candidate when a covering use names it', () => { + expect( + rustIsGlobalNameFallbackPlausible({ + site: BARE_SITE, + callerParsed: mkCaller('src/b.rs', [{ kind: 'wildcard', targetRaw: 'crate' }]), + candidate: mkCandidate('lib.rs', 'unique_helper_xyz'), + }), ).toBe(true); }); diff --git a/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts b/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts new file mode 100644 index 000000000..7458c5fcc --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts @@ -0,0 +1,636 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + cargoTargetRoots, + loadRustCargoTargets, + rustFilesShareCargoTarget, + rustImportNamesCargoRoot, +} from '../../../src/core/ingestion/languages/rust/cargo-targets.js'; +import { emitRustScopeCaptures } from '../../../src/core/ingestion/languages/rust/captures.js'; +import { interpretRustImport } from '../../../src/core/ingestion/languages/rust/interpret.js'; + +const PACKAGE = '[package]\nname = "demo"\nversion = "0.1.0"\nedition = "2021"\n'; +const temporary: string[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of temporary.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); +function fixture(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cargo-targets-')); + temporary.push(dir); + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), content); + } + return dir; +} + +describe('Cargo manifest target metadata', () => { + it.each(['crate', 'self', 'super'])('preserves the %s keyword in a glob import', (keyword) => { + const imports = emitRustScopeCaptures(`use ${keyword}::*;`, 'fixture.rs') + .map(interpretRustImport) + .filter((entry) => entry !== null); + expect(imports).toEqual([{ kind: 'wildcard', targetRaw: keyword }]); + }); + const files = new Set([ + 'src/lib.rs', + 'src/main.rs', + 'src/bin/tool.rs', + 'src/bin/other/main.rs', + 'tests/helper.rs', + 'benches/speed.rs', + 'examples/demo/main.rs', + 'custom/entry.rs', + 'build.rs', + ]); + + it('discovers lib, main, binary, test, bench, example and build-script roots', () => { + expect(new Set(cargoTargetRoots('Cargo.toml', PACKAGE, files))).toEqual( + new Set([...files].filter((file) => file !== 'custom/entry.rs')), + ); + }); + + it('accepts Cargo build=true and declines overlapping build and library roles', () => { + expect(cargoTargetRoots('Cargo.toml', `${PACKAGE}build=true\n`, files)).toContain('build.rs'); + expect( + cargoTargetRoots('Cargo.toml', `${PACKAGE}[lib]\npath="build.rs"\n`, files), + ).toBeUndefined(); + }); + + it.each([ + ['autolib', 'src/lib.rs'], + ['autobins', 'src/bin/tool.rs'], + ['autotests', 'tests/helper.rs'], + ['autobenches', 'benches/speed.rs'], + ['autoexamples', 'examples/demo/main.rs'], + ])('honors %s = false', (key, absent) => { + const roots = cargoTargetRoots('Cargo.toml', `${PACKAGE}${key} = false\n`, files); + expect(roots).toBeDefined(); + expect(roots).not.toContain(absent); + expect(roots).toContain(key === 'autolib' ? 'src/main.rs' : 'src/lib.rs'); + }); + + it('uses an explicit build-script path instead of the default', () => { + const roots = cargoTargetRoots('Cargo.toml', `${PACKAGE}build="custom/entry.rs"\n`, files); + expect(roots).toContain('custom/entry.rs'); + expect(roots).toContain('src/lib.rs'); + expect(roots).not.toContain('build.rs'); + }); + + it('explicit paths override auto-discovered targets of the same name', () => { + const roots = cargoTargetRoots( + 'Cargo.toml', + `${PACKAGE}\n[[test]]\nname = 'helper'\npath = 'custom/entry.rs'\n`, + files, + ); + expect(roots).toContain('custom/entry.rs'); + expect(roots).not.toContain('tests/helper.rs'); + }); + + it('explicit lib paths work with autolib disabled', () => { + const roots = cargoTargetRoots( + 'Cargo.toml', + `${PACKAGE}autolib = false\n[lib]\npath = 'custom/entry.rs'\n`, + files, + ); + expect(roots).toContain('custom/entry.rs'); + expect(roots).not.toContain('src/lib.rs'); + }); + + it.each([ + ['bin', 'tool', ['src/main.rs', 'src/bin/other/main.rs']], + ['test', 'helper', ['tests/extra.rs']], + ['bench', 'speed', ['benches/extra.rs']], + ['example', 'demo', ['examples/extra.rs']], + ] as const)( + 'Cargo 2015 explicit %s targets only disable discovery of that kind', + (kind, name, excluded) => { + const discovered = new Set([ + ...files, + 'tests/extra.rs', + 'benches/extra.rs', + 'examples/extra.rs', + ]); + expect( + new Set( + cargoTargetRoots( + 'Cargo.toml', + `[package]\nname="demo"\nbuild=false\n[[${kind}]]\nname="${name}"\n`, + discovered, + ), + ), + ).toEqual( + new Set( + [...discovered].filter( + (file) => + file !== 'build.rs' && + file !== 'custom/entry.rs' && + !(excluded as readonly string[]).includes(file), + ), + ), + ); + }, + ); + + it('retains workspace/package prefixes', () => { + expect( + cargoTargetRoots('crates/a/Cargo.toml', PACKAGE, new Set(['crates/a/src/lib.rs'])), + ).toEqual(['crates/a/src/lib.rs']); + expect(cargoTargetRoots('Cargo.toml', '[workspace]\nmembers=["crates/a"]\n', files)).toEqual( + [], + ); + }); + + it.each([ + '[package', + `${PACKAGE}\n[[test]]\npath="missing.rs"\n`, + `${PACKAGE}autotests="false"\n`, + `${PACKAGE}build=1\n`, + `${PACKAGE}[[bin]]\npath="custom/entry.rs"\n`, + `${PACKAGE}[[test]]\npath="custom/entry.rs"\n`, + ])('does not manufacture evidence from malformed metadata', (manifest) => { + expect(cargoTargetRoots('Cargo.toml', manifest, files)).toBeUndefined(); + }); +}); + +describe('Rust module membership', () => { + it.each([ + '.env', + '.git/hidden.rs', + '.GiT/hidden.rs', + '.gitnexus/hidden.rs', + 'node_modules/pkg/hidden.rs', + ])('does not restore excluded non-source/control paths: %s', async (hidden) => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': `#[path="../${hidden}"] mod hidden;`, + [hidden]: 'pub fn helper() {}', + }); + const realpath = vi.spyOn(fs.promises, 'realpath'); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + expect(realpath.mock.calls.some(([file]) => String(file) === path.join(dir, hidden))).toBe( + false, + ); + }); + + it('does not interpret a Cargo std alias as the standard library', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[dependencies]\nstd={package="custom",version="1"}\n`, + 'src/lib.rs': 'use std::*; fn f(){ println!(); }', + }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + }); + it('retains path dependencies from conditional target sections', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[target.'cfg(unix)'.dependencies]\nother={path="other"}\n`, + 'src/lib.rs': '', + 'other/Cargo.toml': '[package]\nname="other"\nedition="2021"\n', + 'other/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(config).toBeDefined(); + expect(rustImportNamesCargoRoot(config, 'src/lib.rs', 'other/src/lib.rs', 'other')).toBe(true); + }); + it.each(['../..', '../../'])( + 'resolves a directory-form workspace pointer: %s', + async (workspace) => { + const dir = fixture({ + 'Cargo.toml': + '[workspace]\nmembers=["crates/a", "crates/b"]\n[workspace.package]\nedition="2021"\n[workspace.dependencies]\nb={path="crates/b"}\n', + 'crates/a/Cargo.toml': `[package]\nname="a"\nworkspace="${workspace}"\nedition.workspace=true\n[dependencies]\nb.workspace=true\n`, + 'crates/a/src/lib.rs': '', + 'crates/a/tests/helper.rs': '', + 'crates/b/Cargo.toml': '[package]\nname="b"\nedition="2021"\n', + 'crates/b/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(config).toBeDefined(); + expect( + rustFilesShareCargoTarget(config, 'crates/a/src/lib.rs', 'crates/a/tests/helper.rs'), + ).toBe(false); + expect( + rustImportNamesCargoRoot(config, 'crates/a/src/lib.rs', 'crates/b/src/lib.rs', 'b'), + ).toBe(true); + }, + ); + + it('rejects a manifest filename as workspace pointer, as Cargo does', async () => { + const dir = fixture({ + 'Cargo.toml': '[workspace]\nmembers=["crates/a"]\n[workspace.package]\nedition="2021"\n', + 'crates/a/Cargo.toml': + '[package]\nname="a"\nworkspace="../../Cargo.toml"\nedition.workspace=true\n', + 'crates/a/src/lib.rs': '', + }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + }); + + it.each([ + '#[derive(Custom)] struct T;', + 'use custom::Debug; #[derive(Debug)] struct T;', + 'macro_rules! println { () => { #[path="../tests/helper.rs"] mod shared; } } fn f() { println!(); }', + 'use custom::println; fn f() { println!(); }', + 'use custom::*; fn f() { println!(); }', + 'fn f() { println!("{}", { #[path="../tests/helper.rs"] mod shared; 1 }); }', + 'fn f() { println!("{}", include!("generated.rs")); }', + '#[tokio::test] async fn f() {}', + '#[some_macro::cfg] fn f() {}', + '#[some_macro::allow] fn f() {}', + 'fn f() { custom::println!(); }', + 'println!("item position");', + ])('does not mistake unknown or shadowed expansion for a builtin: %s', async (source) => { + const dir = fixture({ 'Cargo.toml': PACKAGE, 'src/lib.rs': source, 'tests/helper.rs': '' }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + }); + + it('does not assume a child-module macro is std when its parent shadows that name', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'macro_rules! println { () => { mod generated; } } mod child;', + 'src/child.rs': 'fn f() { println!(); }', + 'tests/helper.rs': '', + }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + }); + it('keeps build dependencies separate while retaining unit-test dependencies', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[dependencies]\nnormal={path="normal"}\n[dev-dependencies]\ndev={path="dev"}\n[build-dependencies]\nbuilder={path="builder"}\n`, + 'src/lib.rs': '', + 'build.rs': 'fn main() {}', + ...Object.fromEntries( + ['normal', 'dev', 'builder'].flatMap((name) => [ + [`${name}/Cargo.toml`, `[package]\nname="${name}"\nedition="2021"\n`], + [`${name}/src/lib.rs`, ''], + ]), + ), + }); + const config = await loadRustCargoTargets(dir); + expect(config).toBeDefined(); + for (const name of ['normal', 'dev', 'builder']) { + expect(rustImportNamesCargoRoot(config, 'src/lib.rs', `${name}/src/lib.rs`, name)).toBe( + name !== 'builder', + ); + expect(rustImportNamesCargoRoot(config, 'build.rs', `${name}/src/lib.rs`, name)).toBe( + name === 'builder', + ); + } + expect(rustImportNamesCargoRoot(config, 'build.rs', 'src/lib.rs', 'demo')).toBe(false); + }); + + it.each([false, true])( + 'uses the correct import name for a custom library (package explicit: %s)', + async (explicit) => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[lib]\nname="public_api"\n`, + 'src/lib.rs': '', + 'consumer/Cargo.toml': `[package]\nname="consumer"\nedition="2021"\n[dependencies]\ndemo={${explicit ? 'package="demo",' : ''}path=".."}\n`, + 'consumer/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect( + rustImportNamesCargoRoot( + config, + 'consumer/src/lib.rs', + 'src/lib.rs', + explicit ? 'demo' : 'public_api', + ), + ).toBe(true); + expect( + rustImportNamesCargoRoot( + config, + 'consumer/src/lib.rs', + 'src/lib.rs', + explicit ? 'public_api' : 'demo', + ), + ).toBe(false); + }, + ); + + it('a dependency alias in another package is not import evidence for this caller', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '', + 'a/Cargo.toml': + '[package]\nname="a"\nedition="2021"\n[dependencies]\napi={package="demo",path=".."}\n', + 'a/src/lib.rs': '', + 'b/Cargo.toml': '[package]\nname="b"\nedition="2021"\n', + 'b/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(rustImportNamesCargoRoot(config, 'a/src/lib.rs', 'src/lib.rs', 'api')).toBe(true); + expect(rustImportNamesCargoRoot(config, 'b/src/lib.rs', 'src/lib.rs', 'api')).toBe(false); + }); + + it('uses Cargo library metadata rather than the entry file name', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[lib]\npath="src/main.rs"\nname="api"\n`, + 'src/main.rs': '', + 'tests/caller.rs': '', + }); + expect( + rustImportNamesCargoRoot( + await loadRustCargoTargets(dir), + 'tests/caller.rs', + 'src/main.rs', + 'api', + ), + ).toBe(true); + fs.writeFileSync( + path.join(dir, 'Cargo.toml'), + `${PACKAGE}autolib=false\n[[bin]]\nname="api"\npath="src/main.rs"\n`, + ); + expect( + rustImportNamesCargoRoot( + await loadRustCargoTargets(dir), + 'tests/caller.rs', + 'src/main.rs', + 'api', + ), + ).toBe(false); + }); + + it('distinguishes all package targets even though directory prefixes overlap', async () => { + const paths = [ + 'src/lib.rs', + 'src/main.rs', + 'src/bin/tool.rs', + 'tests/helper.rs', + 'benches/helper.rs', + 'examples/helper.rs', + ]; + const dir = fixture({ + 'Cargo.toml': PACKAGE, + ...Object.fromEntries(paths.map((file) => [file, 'pub fn helper() {}'])), + }); + const config = await loadRustCargoTargets(dir); + for (const target of paths.slice(1)) + expect(rustFilesShareCargoTarget(config, paths[0]!, target)).toBe(false); + }); + + it('follows normal, nested, inline and unit-test modules', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod foo; #[cfg(test)] mod tests { mod helper; }', + 'src/foo.rs': 'mod nested;', + 'src/foo/nested.rs': '', + 'src/tests/helper.rs': '', + }); + const config = await loadRustCargoTargets(dir); + for (const file of ['src/foo.rs', 'src/foo/nested.rs', 'src/tests/helper.rs']) { + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', file)).toBe(true); + } + }); + + it('does not mistake an auto-target named target for a build artifact directory', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '', + 'tests/helper.rs': '', + 'src/bin/target/main.rs': + '#[path="../../lib.rs"] mod lib; #[path="../../../tests/helper.rs"] mod helper;', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + }); + + it('permits a tests/ file shared with the library using #[path]', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '#[path = "../tests/helper.rs"] mod helper;', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + }); + + it('a #[path] file owns its directory when loading its own submodules', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '#[path="../tests/helper.rs"] mod helper;', + 'tests/helper.rs': 'pub mod inner;', + 'tests/inner.rs': 'pub fn found() {}', + 'tests/helper/inner.rs': 'pub fn different() {}', + }); + const config = await loadRustCargoTargets(dir); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/inner.rs')).toBe(true); + expect( + rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/helper/inner.rs'), + ).toBeUndefined(); + }); + + it('supports raw-string paths and inline path bases in non-mod.rs files', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod foo;', + 'src/foo.rs': 'mod inner { #[path = r#"helper.rs"#] mod helper; }', + 'src/foo/inner/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget( + await loadRustCargoTargets(dir), + 'src/lib.rs', + 'src/foo/inner/helper.rs', + ), + ).toBe(true); + }); + + it('an inline module path override is relative to the source directory', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod foo;', + 'src/foo.rs': '#[path="thread_files"] mod thread { #[path="tls.rs"] mod local_data; }', + 'src/thread_files/tls.rs': '', + }); + expect( + rustFilesShareCargoTarget( + await loadRustCargoTargets(dir), + 'src/lib.rs', + 'src/thread_files/tls.rs', + ), + ).toBe(true); + }); + + it('does not treat derive or expression-position std macros as unknown expansion', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': + '#[derive(Debug)] struct S;\n#[test] #[should_panic] fn t() { println!("hi"); assert_eq!(1, 1); let _ = vec![1]; let _ = format!("{}", 1); }', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(false); + }); + + it('keeps a library module whose path segment is named target', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod target;', + 'src/target/mod.rs': '', + 'tests/helper.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'src/target/mod.rs')).toBe(true); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/helper.rs')).toBe(false); + }); + + it('keeps an explicit [lib] path under target/', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[lib]\npath = "target/entry.rs"\n`, + 'target/entry.rs': '', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget( + await loadRustCargoTargets(dir), + 'target/entry.rs', + 'tests/helper.rs', + ), + ).toBe(false); + }); + + it.each([ + 'include!("generated.rs");', + 'extern crate self as api;', + '#[cfg_attr(feature="x", path="elsewhere.rs")] mod helper;', + '#[custom_macro] mod helper;', + 'mod missing;', + 'mod broken {', + ])('returns unknown for incomplete module evidence: %s', async (source) => { + const dir = fixture({ 'Cargo.toml': PACKAGE, 'src/lib.rs': source, 'tests/helper.rs': '' }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBeUndefined(); + }); + + it('ignores module-like text in strings and comments', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '// mod missing;\nconst S: &str = "mod absent;";', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(false); + }); + + it('does not retain membership across changed source snapshots', async () => { + const dir = fixture({ 'Cargo.toml': PACKAGE, 'src/lib.rs': '', 'tests/helper.rs': '' }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(false); + fs.writeFileSync(path.join(dir, 'src/lib.rs'), '#[path="../tests/helper.rs"] mod helper;'); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + }); + + it('honors inherited workspace editions and package-local custom targets', async () => { + const dir = fixture({ + 'Cargo.toml': + '[workspace]\nmembers=["crates/a", "crates/b"]\n[workspace.package]\nedition="2021"\n', + 'crates/a/Cargo.toml': + '[package]\nname="a"\nedition.workspace=true\n[lib]\npath="library/entry.rs"\n', + 'crates/a/library/entry.rs': '', + 'crates/a/tests/helper.rs': '', + 'crates/b/Cargo.toml': PACKAGE, + 'crates/b/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect( + rustFilesShareCargoTarget(config, 'crates/a/library/entry.rs', 'crates/a/tests/helper.rs'), + ).toBe(false); + expect( + rustFilesShareCargoTarget(config, 'crates/a/library/entry.rs', 'crates/b/src/lib.rs'), + ).toBe(false); + }); + + it('a disabled integration target can still be a library module', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}autotests=false\n`, + 'src/lib.rs': '#[path="../tests/helper.rs"] mod helper;', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + fs.writeFileSync(path.join(dir, 'src/lib.rs'), ''); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBeUndefined(); + }); + + it('inspects external modules and expansion uncertainty in function bodies', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'fn local() { #[path="../tests/helper.rs"] mod helper; }', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + fs.writeFileSync(path.join(dir, 'src/lib.rs'), 'fn local() { include!("generated.rs"); }'); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBeUndefined(); + }); + + it('uses the safe parser for sources exceeding the native Windows string limit', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': `// ${'x'.repeat(40_000)}\nmod helper;`, + 'src/helper.rs': '', + 'tests/helper.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'src/helper.rs')).toBe(true); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/helper.rs')).toBe(false); + }); + + it('does not read through a module symlink outside the repository', async () => { + const outside = fixture({ 'helper.rs': 'pub fn helper() {}' }); + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod helper;', + 'tests/helper.rs': '', + }); + fs.symlinkSync(path.join(outside, 'helper.rs'), path.join(dir, 'src/helper.rs')); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBeUndefined(); + }); + + it('discards membership when a checked source is replaced before the read', async () => { + const dir = fixture({ 'Cargo.toml': PACKAGE, 'src/lib.rs': '', 'tests/helper.rs': '' }); + const source = path.join(dir, 'src/lib.rs'); + const originalStat = fs.statSync(source); + let replaced = false; + const replace = (stat: fs.Stats) => { + if (!replaced && stat.dev === originalStat.dev && stat.ino === originalStat.ino) { + replaced = true; + fs.renameSync(source, `${source}.old`); + fs.writeFileSync(source, 'pub fn replacement() {}'); + } + }; + // Exercise the same replacement against the old path-stat/read sequence + // and the descriptor-based reader. Neither may accept the unchecked file. + const pathStat = fs.promises.stat.bind(fs.promises); + vi.spyOn(fs.promises, 'stat').mockImplementation(async (...args) => { + const stat = await pathStat(...args); + replace(stat as fs.Stats); + return stat; + }); + const descriptorStat = fs.fstatSync.bind(fs); + vi.spyOn(fs, 'fstatSync').mockImplementation((...args) => { + const stat = descriptorStat(...args); + replace(stat as fs.Stats); + return stat; + }); + const config = await loadRustCargoTargets(dir); + expect(replaced).toBe(true); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/helper.rs')).toBeUndefined(); + }); +});