mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
fix(rust): respect Cargo target boundaries in name fallback (#3294)
* fix(rust): respect Cargo target boundaries in name fallback * fix(rust): read Cargo sources through validated file descriptors * fix(rust): require matching imports for crate-root guesses * fix(rust): require Cargo root identity for cross-target imports * fix(rust): enforce Cargo identity across fallback imports * fix(rust): keep Cargo membership on typical derive/macros (#3294) Abort only genuine unknown expansion, ignore Cargo artifact layouts instead of every `target` path segment, and require a covering import for unanswered crate-root name fallback so #3253 still holds on ordinary Rust sources. Co-authored-by: Cursor <cursoragent@cursor.com> * test(rust): gate Cargo target membership in CI (#3253) Add a parse-dispatch-rounds-style bench so derive/macro abort, target-path globs, and superlinear membership walks fail in CI instead of staying graph-invisible. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(rust): verify Cargo macro and re-export evidence * style(bench): format Cargo membership benchmark * fix(rust): require imports for cross-file root guesses --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ebbcd5b0b4
commit
0ede6ae501
23 changed files with 2688 additions and 32 deletions
10
.github/workflows/ci-tests.yml
vendored
10
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
|
|||
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
|
||||
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
|
|
|||
31
gitnexus/bench/rust-cargo-targets/baselines.json
Normal file
31
gitnexus/bench/rust-cargo-targets/baselines.json
Normal file
|
|
@ -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."
|
||||
}
|
||||
309
gitnexus/bench/rust-cargo-targets/measure.mjs
Normal file
309
gitnexus/bench/rust-cargo-targets/measure.mjs
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
13
gitnexus/package-lock.json
generated
13
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
340
gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts
Normal file
340
gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts
Normal file
|
|
@ -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<string> {
|
||||
const uses = new Set<string>();
|
||||
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<string>,
|
||||
missingFiles?: Set<string>,
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
513
gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts
Normal file
513
gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string>,
|
||||
workspaceEditions?: ReadonlyMap<string, string>,
|
||||
): 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<string>();
|
||||
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<string, string>();
|
||||
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<string>();
|
||||
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<string, ReadonlySet<string>>,
|
||||
readonly rootImports: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>,
|
||||
readonly publicUsesByFile: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
) {}
|
||||
}
|
||||
|
||||
/** 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<string, Table>,
|
||||
targets: ReadonlyMap<string, readonly string[]>,
|
||||
): Map<string, Map<string, Set<string>>> {
|
||||
const libraries = new Map<string, { root: string; name: string }>();
|
||||
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<string, Map<string, Set<string>>>();
|
||||
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<unknown> {
|
||||
try {
|
||||
const root = await fs.realpath(repoPath);
|
||||
const files = new Set<string>();
|
||||
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<string> => {
|
||||
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<string, string>();
|
||||
const manifestData = new Map<string, Table>();
|
||||
const workspaceEditions = new Map<string, string>();
|
||||
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<string>): Promise<void> => {
|
||||
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<string>();
|
||||
const targetsByManifest = new Map<string, readonly string[]>();
|
||||
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<string, Set<string>>();
|
||||
const publicUsesByFile = new Map<string, ReadonlySet<string>>();
|
||||
// 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<string, NonNullable<ReturnType<typeof rustModuleFiles>>>();
|
||||
let visits = 0;
|
||||
for (const target of roots) {
|
||||
const pending = [{ file: target, ownsDirectory: true }];
|
||||
const visited = new Set<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> = 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<string> = new Set(['crate', '$crate']);
|
||||
const RUST_TYPE_NAMESPACE_KINDS: ReadonlySet<string> = 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<ScopeId, string>();
|
||||
const moduleScopes = new Set<ScopeId>();
|
||||
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<ScopeId> | 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<string>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, string>, 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
636
gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts
Normal file
636
gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts
Normal file
|
|
@ -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, string>): 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue