diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 946362748..bce386ee4 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -47,7 +47,7 @@ "_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5." }, "rust": { - "fingerprint": "7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689", + "fingerprint": "90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5", "scaling_budget": 1.5, "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", @@ -55,7 +55,8 @@ "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", "_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.", - "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical." + "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", + "_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it." }, "php": { "fingerprint": "4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd", diff --git a/gitnexus/src/core/ingestion/import-resolvers/rust.ts b/gitnexus/src/core/ingestion/import-resolvers/rust.ts index 2d1dd864a..41ac608cf 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/rust.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/rust.ts @@ -12,7 +12,7 @@ export function resolveRustImportInternal( currentFile: string, importPath: string, - allFiles: Set, + allFiles: ReadonlySet, ): string | null { let rustPath: string; @@ -63,7 +63,10 @@ export function resolveRustImportInternal( * Tries: path.rs, path/mod.rs, and with the last segment stripped * (last segment might be a symbol name, not a module). */ -export function tryRustModulePath(modulePath: string, allFiles: Set): string | null { +export function tryRustModulePath( + modulePath: string, + allFiles: ReadonlySet, +): string | null { // Try direct: path.rs if (allFiles.has(modulePath + '.rs')) return modulePath + '.rs'; // Try directory: path/mod.rs diff --git a/gitnexus/src/core/ingestion/languages/rust/method-owners.ts b/gitnexus/src/core/ingestion/languages/rust/method-owners.ts index 7633a132b..677eb1a8b 100644 --- a/gitnexus/src/core/ingestion/languages/rust/method-owners.ts +++ b/gitnexus/src/core/ingestion/languages/rust/method-owners.ts @@ -1,5 +1,9 @@ import type { ParsedFile } from 'gitnexus-shared'; -import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { + isClassLike, + populateClassOwnedMembers, + tagNamespacePrefixes, +} from '../../scope-resolution/scope/walkers.js'; /** * Populate `ownerId` on Rust method defs. @@ -21,6 +25,16 @@ import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/s export function populateRustOwners(parsed: ParsedFile): void { populateClassOwnedMembers(parsed); populateRustImplOwners(parsed); + // #2730: tag defs declared inside `mod` blocks with their enclosing module + // path (`inner`, `a.b`) on the `namespacePrefix` sidecar. Rust modules are + // Namespace scopes owning a Namespace def (see the `mod_item` capture), so + // the shared C++-era pass applies unchanged. Qualified call resolution reads + // this to tell `inner::dispatch` apart from a same-named crate-root `fn`. + // Rust `qualifiedName`s never encode the enclosing module, so the shared + // "already namespaced" guard must be off: with it on, `mod a { pub fn a() }` + // matched by coincidence and the member was left looking like it belonged to + // the parent module (#2741 review). + tagNamespacePrefixes(parsed, { qualifiedNamesCarryNamespace: false }); } function populateRustImplOwners(parsed: ParsedFile): void { diff --git a/gitnexus/src/core/ingestion/languages/rust/module-path.ts b/gitnexus/src/core/ingestion/languages/rust/module-path.ts new file mode 100644 index 000000000..9125392ca --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/module-path.ts @@ -0,0 +1,249 @@ +/** + * Rust module paths — the module tree, expressed over the scope model. + * + * rustc resolves `a::b::dispatch` against a MODULE TREE, not a file tree: `mod` + * is an item with its own `DefId`, and a path's leading segments name modules in + * the *type* namespace (which is why a same-named `fn` can never shadow them — + * functions live in the value namespace). GitNexus models the same tree in two + * halves, and this module joins them: + * + * - **Inline modules** (`mod inner { … }`) are `Namespace` scopes owning a + * `Namespace` def, so the shared `tagNamespacePrefixes` pass stamps members + * with `namespacePrefix = 'inner'` / `'outer.inner'`. + * - **File modules** (`mod tools;` loading `tools.rs` or `tools/mod.rs`) leave + * no in-file marker at all — the module path lives in the FILE PATH relative + * to the crate root. That half is reconstructed here. + * + * ## Module identity carries its crate + * + * A module is `{ crateRoot, segments }`, never bare segments. A cargo workspace + * routinely gives several members the same internal module name — `util`, + * `error`, `config`, `types` are near-universal — and identity by segments alone + * makes `crates/a/src/tools.rs` and `crates/b/src/tools.rs` the same module. That + * mis-binds a call across crates when only one defines the member, and (worse) + * ties the lookup when both do, so qualified resolution refuses and the call + * falls back to the lexical walk that #2730 exists to prevent. Rust has no + * implicit cross-crate paths: reaching another crate requires naming it, so two + * modules in different crates are never the same module. + * + * Everything here is pure path arithmetic over the workspace file set; no I/O. + */ + +/** Files that make their directory a crate root (`crate::` anchors here). */ +const CRATE_ROOT_FILES = new Set(['main.rs', 'lib.rs']); + +/** A module file whose name does NOT contribute a path segment. */ +const MODULE_DIR_FILE = 'mod.rs'; + +/** + * A module's full identity: the crate it belongs to, plus its `::`-path inside + * that crate. `segments` is empty for the crate-root module itself. + */ +export interface RustModule { + /** Crate-root directory (`src`, `crates/noob/src`, or `''` at repo root). */ + readonly crateRoot: string; + readonly segments: readonly string[]; +} + +export interface RustModuleIndex { + /** Crate-root directories, longest first, so nested crates win. */ + readonly crateRoots: readonly string[]; + /** + * Every module name that exists anywhere in the workspace, as a flat set of + * single segments. Used only as a fast negative filter: a qualifier whose head + * is not in here cannot name a workspace module, so type-qualified calls + * (`Vec::new()`, `String::from()`) are rejected before any candidate search. + */ + readonly moduleNames: ReadonlySet; + /** + * Entry files that are a crate root in their own right rather than a module of + * the surrounding crate — `src/bin/.rs` auto-discovered binary targets. + * Maps the entry file to the directory its own submodules live under. + */ + readonly standaloneRootFiles: ReadonlyMap; +} + +/** + * Index the workspace's crate roots: every directory holding a `main.rs` or + * `lib.rs`. A cargo workspace has one per member (`crates/noob/src`), a plain + * package exactly one (`src`). Longest-first ordering makes `moduleOfFile` pick + * the innermost enclosing crate for nested layouts. + * + * Cargo also auto-discovers a binary target per `src/bin/.rs`. Each is a + * separate crate with its own `crate::` root, and its submodules live under + * `src/bin//`. Treating those files as ordinary modules of the library + * invented the module path `bin::`, which made `crate::helper()` inside a + * binary resolve into the LIBRARY — downgrading an edge the lexical walk had + * previously got right (#2741 review). + */ +export function buildRustModuleIndex(allFilePaths: ReadonlySet): RustModuleIndex { + const roots = new Set(); + for (const filePath of allFilePaths) { + const slash = filePath.lastIndexOf('/'); + const base = slash === -1 ? filePath : filePath.slice(slash + 1); + if (!CRATE_ROOT_FILES.has(base)) continue; + roots.add(slash === -1 ? '' : filePath.slice(0, slash)); + } + + // Auto-discovered binary targets: `/bin/.rs`, and the + // directory form `/bin//main.rs` (already a root above). + const standaloneRootFiles = new Map(); + for (const filePath of allFilePaths) { + if (!filePath.endsWith('.rs')) continue; + const slash = filePath.lastIndexOf('/'); + if (slash === -1) continue; + const dir = filePath.slice(0, slash); + if (!dir.endsWith('/bin')) continue; + const parent = dir.slice(0, -'/bin'.length); + if (!roots.has(parent)) continue; + const own = filePath.slice(0, -'.rs'.length); + standaloneRootFiles.set(filePath, own); + roots.add(own); + } + + const crateRoots = [...roots].sort((a, b) => b.length - a.length); + + // Flat set of every module segment name in the workspace, for the negative + // filter. Built from the same single pass over file paths. + const moduleNames = new Set(); + const index: RustModuleIndex = { crateRoots, standaloneRootFiles, moduleNames: new Set() }; + for (const filePath of allFilePaths) { + const module = moduleOfFile(filePath, index); + if (module === undefined) continue; + for (const segment of module.segments) moduleNames.add(segment); + } + + return { crateRoots, standaloneRootFiles, moduleNames }; +} + +/** + * Could this qualifier name a module that exists in the workspace? + * + * A negative answer is authoritative and cheap: if the head segment matches no + * module name anywhere, no candidate channel can resolve it. Anchors keep their + * meaning (`crate::`/`self::`/`super::` are relative to the caller, so the head + * to test is the first non-anchor segment); an all-anchor qualifier (`self::f()`) + * names the caller's own module and is always worth trying. + */ +export function couldNameAModule(qualifier: readonly string[], index: RustModuleIndex): boolean { + for (const segment of qualifier) { + if (segment === 'crate' || segment === '$crate' || segment === 'self' || segment === 'super') { + continue; + } + return index.moduleNames.has(segment); + } + return true; +} + +/** + * Module identity of the file itself: its crate root plus the `::`-segments + * below it. + * + * src/main.rs → { src, [] } (crate root module) + * src/tools.rs → { src, ['tools'] } + * src/a/mod.rs → { src, ['a'] } + * src/a/b.rs → { src, ['a', 'b'] } + * crates/noob/src/tools/mod.rs → { crates/noob/src, ['tools'] } + * + * Returns `undefined` for a file under no known crate root — the caller then has + * no module identity to reason about and must refuse rather than guess. + */ +export function moduleOfFile(filePath: string, index: RustModuleIndex): RustModule | undefined { + // A `src/bin/.rs` entry file is its own crate root, not a module of the + // surrounding library. + const standalone = index.standaloneRootFiles.get(filePath); + if (standalone !== undefined) return { crateRoot: standalone, segments: [] }; + + for (const crateRoot of index.crateRoots) { + const prefix = crateRoot === '' ? '' : `${crateRoot}/`; + if (crateRoot !== '' && !filePath.startsWith(prefix)) continue; + const rel = filePath.slice(prefix.length); + if (!rel.includes('/') && CRATE_ROOT_FILES.has(rel)) return { crateRoot, segments: [] }; + const segments = rel.split('/'); + const last = segments.pop(); + if (last === undefined) return undefined; + if (last !== MODULE_DIR_FILE) { + if (!last.endsWith('.rs')) return undefined; + segments.push(last.slice(0, -'.rs'.length)); + } + return { crateRoot, segments }; + } + return undefined; +} + +/** Full module identity of a definition: its file's module plus any `mod` blocks around it. */ +export function moduleOfDef( + filePath: string, + namespacePrefix: string | undefined, + index: RustModuleIndex, +): RustModule | undefined { + const fileModule = moduleOfFile(filePath, index); + if (fileModule === undefined) return undefined; + if (namespacePrefix === undefined || namespacePrefix === '') return fileModule; + return { + crateRoot: fileModule.crateRoot, + segments: [...fileModule.segments, ...namespacePrefix.split('.')], + }; +} + +/** + * Resolve a written path's leading segments to a module, from the calling + * module. Mirrors rustc's anchor handling: + * + * crate::a::b → the caller's OWN crate, segments ['a','b'] + * self::a → callerModule ++ ['a'] + * super::a → callerModule[:-1] ++ ['a'] + * a::b → relative; the caller resolves this against the candidate + * channels (child module, `use` binding), so it is returned + * as-is for the caller to try in context. + * + * An anchored path always stays inside the caller's crate — `crate::` names the + * current crate, and `super::`/`self::` are relative to it — so the resolved + * module inherits `callerModule.crateRoot`. + * + * `super` chains (`super::super::x`) are consumed left to right. Returns + * `undefined` when the chain walks above the crate root — an invalid path that + * must not silently resolve to something else. + */ +export function resolveAnchoredModulePath( + qualifier: readonly string[], + callerModule: RustModule, +): { readonly module: RustModule; readonly anchored: boolean } | undefined { + if (qualifier.length === 0) return undefined; + const crateRoot = callerModule.crateRoot; + + const head = qualifier[0]; + if (head === 'crate' || head === '$crate') { + return { module: { crateRoot, segments: qualifier.slice(1) }, anchored: true }; + } + if (head === 'self') { + return { + module: { crateRoot, segments: [...callerModule.segments, ...qualifier.slice(1)] }, + anchored: true, + }; + } + if (head === 'super') { + let i = 0; + const base = [...callerModule.segments]; + while (i < qualifier.length && qualifier[i] === 'super') { + if (base.length === 0) return undefined; // above the crate root + base.pop(); + i++; + } + return { module: { crateRoot, segments: [...base, ...qualifier.slice(i)] }, anchored: true }; + } + return { module: { crateRoot, segments: [...qualifier] }, anchored: false }; +} + +/** + * Identity comparison for two modules. The crate root participates: two modules + * with the same internal path in DIFFERENT crates are different modules, and + * conflating them is what let a workspace mis-bind or refuse (#2730 review H1). + */ +export function sameModule(a: RustModule, b: RustModule): boolean { + return ( + a.crateRoot === b.crateRoot && + a.segments.length === b.segments.length && + a.segments.every((seg, i) => seg === b.segments[i]) + ); +} diff --git a/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts b/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts new file mode 100644 index 000000000..14a26cec0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts @@ -0,0 +1,445 @@ +/** + * Rust module-qualified call resolution (#2730) — path resolution over the + * module tree, the way rustc does it. + * + * `tools::dispatch(ctx, name)` is captured as a FREE call whose `name` is the + * tail identifier and whose written path rides along in `site.rawQualifiedName` + * (the same channel #1982 built for qualified inheritance bases). Without + * consulting that path, the shared scope-chain walk resolves the bare tail and + * binds it to whatever `dispatch` is lexically nearest — which, for the wrapper + * idiom `fn dispatch(..) { tools::dispatch(..) }`, is the wrapper itself. The + * real cross-module edge then does not exist and `impact` reports the callee as + * unreached. + * + * The fix follows rustc's actual rule rather than a filename heuristic: + * + * 1. A path's leading segments name MODULES, resolved in the type namespace. + * A same-named `fn` lives in the value namespace and therefore can never + * shadow them — which is exactly the shadowing this bug was about. + * 2. `crate::` / `self::` / `super::` are prefix transforms on the caller's + * own module path, not reasons to give up. + * 3. The final segment is a MEMBER of the resolved module — looked up in that + * module's binding table, so `pub use` re-exports resolve like any other + * binding. + * + * Module identity comes from `module-path.ts`: file path below the crate root, + * plus any enclosing `mod` blocks (carried on `namespacePrefix`, stamped by the + * shared `tagNamespacePrefixes` pass now that `mod_item` emits a Namespace def). + * + * Refuses — returns `undefined`, leaving the shared chain untouched — whenever + * the path names no known module, the module has no such member, or two + * candidates tie. A wrong CALLS edge is worse than a missing one: it is what + * made this issue dangerous in the first place. + */ + +import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { isOverloadableCallable } from '../../utils/callable-labels.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js'; +import { + buildRustModuleIndex, + couldNameAModule, + moduleOfDef, + moduleOfFile, + resolveAnchoredModulePath, + sameModule, + type RustModule, + type RustModuleIndex, +} from './module-path.js'; + +/** + * Per-run memo of the crate-root index, keyed by the file set it was built from. + * The hook is invoked per call site; rebuilding the index each time would make + * qualified-call resolution O(sites x files). + */ +const MODULE_INDEX_CACHE = new WeakMap, RustModuleIndex>(); + +function moduleIndexFor(allFilePaths: ReadonlySet): RustModuleIndex { + let index = MODULE_INDEX_CACHE.get(allFilePaths); + if (index === undefined) { + index = buildRustModuleIndex(allFilePaths); + MODULE_INDEX_CACHE.set(allFilePaths, index); + } + return index; +} + +export function resolveRustQualifiedFreeCall( + site: { readonly name: string; readonly rawQualifiedName?: string; readonly inScope: ScopeId }, + callerParsed: ParsedFile, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + allFilePaths: ReadonlySet, +): SymbolDefinition | undefined { + const raw = site.rawQualifiedName; + if (raw === undefined) return undefined; + + // A leading `::` anchors at the EXTERN PRELUDE — `::tools::dispatch()` names + // the crate `tools`, not a module of this one. Extern crates are outside the + // workspace module tree, so the honest answer is to refuse. Filtering the empty + // first segment out instead silently reinterpreted the path as relative and + // resolved it against a local module of the same name. + if (raw.trimStart().startsWith('::')) return undefined; + + const segments = raw + .split('::') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + // Everything before the callee name names modules. + const qualifier = segments.slice(0, -1); + if (qualifier.length === 0) return undefined; + + // Cheap rejection BEFORE any index work. The capture that carries + // `rawQualifiedName` matches every `scoped_identifier` callee, so this hook is + // reached by `Vec::new()`, `String::from()`, `Self::method()` and every other + // type-qualified call — the overwhelming majority of `::` calls in real Rust, + // none of which name a module. Letting those run the full candidate search + // meant each one paid a same-name-bucket scan plus a walk of every module + // scope in the workspace before returning undefined (#2741 review). + const index = moduleIndexFor(allFilePaths); + if (!couldNameAModule(qualifier, index)) return undefined; + + const callerModule = callerModuleOf(callerParsed, site.inScope, scopes, index); + if (callerModule === undefined) return undefined; + + const anchored = resolveAnchoredModulePath(qualifier, callerModule); + if (anchored === undefined) return undefined; + + for (const targetModule of candidateModules( + anchored, + qualifier, + callerModule, + callerParsed, + scopes, + workspaceIndex, + index, + )) { + const hit = + findMemberInModule(targetModule, site.name, scopes, workspaceIndex, index) ?? + findReexportedMember(targetModule, site.name, scopes, workspaceIndex, index); + if (hit !== undefined) return hit; + } + return undefined; +} + +/** + * The module the call site sits in: its file's module path plus any `mod` blocks + * around it. Walking the scope chain (rather than reading the file alone) is what + * makes `super::` correct from inside an inline module. + */ +function callerModuleOf( + callerParsed: ParsedFile, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + index: RustModuleIndex, +): RustModule | undefined { + const fileModule = moduleOfFile(callerParsed.filePath, index); + if (fileModule === undefined) return undefined; + + const inline: string[] = []; + let scopeId: ScopeId | null = inScope; + while (scopeId !== null) { + const scope = scopes.scopeTree.getScope(scopeId); + if (scope === undefined) break; + if (scope.kind === 'Namespace') { + const nsDef = scope.ownedDefs.find((d) => d.type === 'Namespace'); + const name = nsDef?.qualifiedName; + if (name !== undefined && name.length > 0) { + inline.unshift(name.slice(name.lastIndexOf('.') + 1)); + } + } + scopeId = scope.parent; + } + return { crateRoot: fileModule.crateRoot, segments: [...fileModule.segments, ...inline] }; +} + +/** + * Module paths the qualifier could name, in rustc's first-segment lookup order. + * An anchored path (`crate::`, `self::`, `super::`) names exactly one module and + * admits no alternatives. + */ +function* candidateModules( + anchored: { readonly module: RustModule; readonly anchored: boolean }, + qualifier: readonly string[], + callerModule: RustModule, + callerParsed: ParsedFile, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + index: RustModuleIndex, +): Generator { + if (anchored.anchored) { + yield anchored.module; + return; + } + + // 1. A submodule the caller actually DECLARES (`mod inner { … }` or + // `mod tools;`) — the in-scope type-namespace binding. + // + // This must be checked, not assumed. Yielding `callerModule ++ qualifier` + // unconditionally let file layout outrank a real `use` binding: with + // `use crate::b;` in `src/a/mod.rs` and an undeclared (or `cfg`-gated) + // `src/a/b.rs` sitting on disk, `b::f()` bound to the sibling file, where + // rustc resolves it to `crate::b` (#2741 review). + // + // A `mod` declaration — inline or file-backed — emits a `Namespace` def + // bound in the declaring scope, so the check is a binding lookup. + const head = qualifier[0]; + if (head !== undefined && declaresSubmodule(callerParsed, head, workspaceIndex)) { + yield { crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, ...qualifier] }; + } + + // 2. A `use` binding for the first segment. Finalize already resolved the + // import to a file, so the module path comes back through the same + // file → module mapping as everything else. Covers `use crate::tools;`, + // `use crate::tools::{self}` and `use crate::a::b as tools`. + // + // The binding must name a MODULE, not a symbol inside one. Import + // resolution deliberately strips a trailing symbol segment when probing for + // a file ("the last segment might be a symbol, not a module" — + // import-resolvers/rust.ts), so `use crate::client::ClientBuilder;` also + // lands on `client/mod.rs`. Taking that at face value made the imported + // TYPE look like the module `client`, and `ClientBuilder::new()` then + // resolved against `client`'s module members — binding an associated + // function to an unrelated module-level `new` (#2741 review H2). + for (const edge of scopes.imports.get(callerParsed.moduleScope) ?? []) { + if (edge.localName !== head || edge.targetFile === null) continue; + const importedModule = moduleOfFile(edge.targetFile, index); + if (importedModule === undefined) continue; + if (!importNamesModule(edge.targetExportedName, importedModule)) continue; + yield { + crateRoot: importedModule.crateRoot, + segments: [...importedModule.segments, ...qualifier.slice(1)], + }; + } + + // 3. Crate-root-relative (`a::b::f()` written from a nested module — 2015 + // edition style, and still what a single-file crate looks like). + if (callerModule.segments.length > 0) { + yield { crateRoot: callerModule.crateRoot, segments: [...qualifier] }; + } +} + +/** + * Does the calling file declare `name` as a submodule (`mod name;` or + * `mod name { … }`)? Both forms emit a `Namespace` def bound locally in the + * declaring scope, so this is a binding lookup rather than a filesystem probe. + */ +function declaresSubmodule( + callerParsed: ParsedFile, + name: string, + workspaceIndex: WorkspaceResolutionIndex, +): boolean { + const moduleScope = workspaceIndex.moduleScopeByFile.get(callerParsed.filePath); + if (moduleScope === undefined) return false; + for (const ref of moduleScope.bindings.get(name) ?? []) { + if (ref.origin === 'local' && ref.def.type === 'Namespace') return true; + } + return false; +} + +/** + * Does this `use` binding name the module it resolved to, rather than a symbol + * declared inside it? + * + * The edge's `targetExportedName` is the tail of the written path, so comparing + * it to the resolved module's own tail separates the two cases exactly: + * + * use crate::tools; tail `tools` module ['tools'] ✓ + * use crate::a::b as tools; tail `b` module ['a','b'] ✓ (alias) + * use crate::tools::{self, Ctx}; tail `tools` module ['tools'] ✓ + * use crate::client::ClientBuilder; tail `ClientBuilder` module ['client'] ✗ a type + * + * An import of the crate-root module itself has no tail segment to match; those + * are left to the anchored (`crate::`) channel rather than guessed at here. + */ +function importNamesModule(targetExportedName: string, module: RustModule): boolean { + const tail = module.segments[module.segments.length - 1]; + return tail !== undefined && tail === targetExportedName; +} + +/** + * Is `def` a MEMBER of its module, rather than merely a callable sitting in the + * same file? + * + * Module membership cannot be inferred from the file path: a `fn` nested inside + * another `fn` has the same `filePath`, the same bare `qualifiedName` and no + * owner, so a path-only test counts it as a second member of the module. That + * ties `findMemberInModule`, which then refuses and hands the site back to the + * lexical walk — reinstating the very self-loop #2730 fixes, from a module that + * merely happens to contain a local helper (#2741 review H3). + * + * The scope model already draws the line exactly: a module-level item is bound + * with `origin: 'local'` in its module's own scope, a function-local item binds + * in the enclosing Block, and an `impl`/trait method binds in the Class scope. + * So membership is a binding lookup, not a path comparison. + * + * Inline-`mod` members bind in their `Namespace` scope rather than the file's + * Module scope, and reaching that scope would mean walking every child scope + * (faulting them in from disk on the out-of-core path). They are instead + * identified by the `namespacePrefix` the shared tagging pass stamps on them, + * which a file-module member never carries. Residual: a `fn` nested inside a + * `fn` that is itself inside an inline `mod` inherits that prefix and is still + * counted — a strictly smaller hole than before, and one that only costs a + * refusal, never a wrong edge. + */ +function isModuleLevelMember( + def: SymbolDefinition, + name: string, + workspaceIndex: WorkspaceResolutionIndex, +): boolean { + if (def.namespacePrefix !== undefined && def.namespacePrefix !== '') return true; + const moduleScope = workspaceIndex.moduleScopeByFile.get(def.filePath); + if (moduleScope === undefined) return false; + for (const ref of moduleScope.bindings.get(name) ?? []) { + if (ref.origin === 'local' && ref.def.nodeId === def.nodeId) return true; + } + return false; +} + +/** A callable named `name` declared directly in `targetModule`. Refuses on a tie. */ +function findMemberInModule( + targetModule: RustModule, + name: string, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + index: RustModuleIndex, +): SymbolDefinition | undefined { + let unique: SymbolDefinition | undefined; + let count = 0; + for (const defId of scopes.qualifiedNames.get(name)) { + const def = scopes.defs.get(defId); + if (def === undefined || !isOverloadableCallable(def.type)) continue; + const defModule = moduleOfDef(def.filePath, def.namespacePrefix, index); + if (defModule === undefined || !sameModule(defModule, targetModule)) continue; + if (!isModuleLevelMember(def, name, workspaceIndex)) continue; + unique = def; + count++; + } + return count === 1 ? unique : undefined; +} + +/** + * A member the target module re-exports rather than declares (`pub use + * crate::tools::dispatch;`). rustc treats a re-export as an ordinary binding in + * the module's resolution table, so a call through the facade must land on the + * original definition. + * + * Finalize does NOT create a local binding for a re-export on the re-exporting + * module's own scope — a `pub use` is modelled as visibility granted to + * IMPORTERS, so the re-exporting file's `bindings` map is empty. The re-export + * survives as an `ImportEdge` on that module scope, which is what this reads. + * + * Known limitation: only FILE modules are reachable here. `moduleScopeByFile` + * holds one Module scope per file, so a re-export declared inside an inline + * `mod facade { pub use … }` has no entry and is not resolved. Reaching it would + * mean walking every child scope, which faults the whole scope tree back in from + * disk on the out-of-core path — the cost this index exists to avoid. A miss + * here falls through to the unchanged chain rather than guessing (#2741 review). + */ +function findReexportedMember( + targetModule: RustModule, + name: string, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + index: RustModuleIndex, +): SymbolDefinition | undefined { + let unique: SymbolDefinition | undefined; + let count = 0; + + for (const moduleScope of moduleScopesFor(targetModule, workspaceIndex, index)) { + for (const edge of scopes.imports.get(moduleScope.id) ?? []) { + if (edge.localName !== name) continue; + // Only a `pub use` re-exports. A private `use` (`named`) makes the name + // visible INSIDE the module and does not put it on the module's public + // surface, so treating one as a re-export resolved paths that do not + // compile. `alias` carries `pub use x::y as name`, which does re-export. + if (edge.kind !== 'reexport' && edge.kind !== 'alias') continue; + + const resolved = resolveReexportTarget(edge, name, scopes, workspaceIndex); + if (resolved === undefined) continue; + // Refuse on a tie rather than taking whichever file the pool happened to + // parse first: two `cfg`-exclusive facades re-exporting the same name are + // indistinguishable here, and picking one is a coin flip baked into the graph. + if (unique !== undefined && resolved.nodeId !== unique.nodeId) return undefined; + unique = resolved; + count++; + } + } + return count >= 1 ? unique : undefined; +} + +/** + * Module → the file-module scopes that realise it, built once per resolution + * pass instead of per candidate. + * + * The previous shape walked all of `workspaceIndex.moduleScopeByFile` for every + * candidate module of every qualified call, so total cost grew as + * `sites x files`. On the out-of-core scope index that walk is worse than CPU: + * `moduleScopeByFile` fetches through `scopeTree.getScope`, so a full sweep can + * fault every module scope back in from disk — the exact pattern + * `workspace-index.ts` added `exportedCallableByName` to avoid (#2741 review). + * + * Keyed on the `WorkspaceResolutionIndex` identity, which is rebuilt per pass. + */ +const MODULE_SCOPE_CACHE = new WeakMap< + WorkspaceResolutionIndex, + ReadonlyMap +>(); + +function moduleKey(module: RustModule): string { + return `${module.crateRoot}\u0000${module.segments.join('::')}`; +} + +function moduleScopesFor( + targetModule: RustModule, + workspaceIndex: WorkspaceResolutionIndex, + index: RustModuleIndex, +): readonly Scope[] { + let byModule = MODULE_SCOPE_CACHE.get(workspaceIndex); + if (byModule === undefined) { + const built = new Map(); + for (const [filePath, moduleScope] of workspaceIndex.moduleScopeByFile) { + const fileModule = moduleOfFile(filePath, index); + if (fileModule === undefined) continue; + const key = moduleKey(fileModule); + const bucket = built.get(key); + if (bucket === undefined) built.set(key, [moduleScope]); + else bucket.push(moduleScope); + } + byModule = built; + MODULE_SCOPE_CACHE.set(workspaceIndex, byModule); + } + return byModule.get(moduleKey(targetModule)) ?? EMPTY_SCOPES; +} + +const EMPTY_SCOPES: readonly Scope[] = Object.freeze([]); + +/** Follow one re-export edge to the definition it exposes. */ +function resolveReexportTarget( + edge: { readonly targetDefId?: string; readonly targetFile: string | null }, + name: string, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, +): SymbolDefinition | undefined { + const viaDefId = edge.targetDefId; + if (viaDefId !== undefined) { + const def = scopes.defs.get(viaDefId); + if (def !== undefined && isOverloadableCallable(def.type)) return def; + } + // No pre-resolved def id: fall back to the exporting file's own module. + if (edge.targetFile === null) return undefined; + return findExportedCallable(edge.targetFile, name, workspaceIndex); +} + +/** Module-scope callable declared locally by `targetFile`. */ +function findExportedCallable( + targetFile: string, + name: string, + workspaceIndex: WorkspaceResolutionIndex, +): SymbolDefinition | undefined { + const moduleScope = workspaceIndex.moduleScopeByFile.get(targetFile); + if (moduleScope === undefined) return undefined; + for (const ref of moduleScope.bindings.get(name) ?? []) { + if (ref.origin === 'local' && isOverloadableCallable(ref.def.type)) return ref.def; + } + return undefined; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/query.ts b/gitnexus/src/core/ingestion/languages/rust/query.ts index 2e92a0ca1..762efb336 100644 --- a/gitnexus/src/core/ingestion/languages/rust/query.ts +++ b/gitnexus/src/core/ingestion/languages/rust/query.ts @@ -44,6 +44,18 @@ const RUST_SCOPE_QUERY = ` (union_item name: (type_identifier) @declaration.name) @declaration.struct +;; Declarations — module (mod foo { ... } / mod foo;) +;; A Rust mod is an ITEM, not just a lexical region: rustc resolves the first +;; segment of a path (inner::dispatch) against the module tree in the TYPE +;; namespace, which is why a same-named fn can never shadow it. Capturing the +;; module as a named DEF (not only the @scope.namespace region above) is what +;; makes that tree addressable — it feeds the shared tagNamespacePrefixes pass +;; so members carry inner / a.b as their namespacePrefix, which qualified +;; call resolution then matches against the written path (#2730). Mirrors the +;; C++ namespace_definition capture. +(mod_item + name: (identifier) @declaration.name) @declaration.namespace + ;; Declarations — macro (macro_rules! foo { ... }) ;; Captured as @declaration.macro → Macro label. A macro invocation ;; (@reference.macro, below) resolves to this definition via MacroRegistry, @@ -150,10 +162,14 @@ const RUST_SCOPE_QUERY = ` value: (_) @reference.receiver field: (field_identifier) @reference.name)) @reference.call.member -;; References — scoped calls (Foo::bar()) +;; References — scoped calls (Foo::bar(), tools::dispatch()) +;; The call stays a FREE call (resolution is the lexical scope chain), but the +;; written path is carried along as @reference.qualified-name so a module- +;; qualified call can be resolved against the module the qualifier names before +;; the scope-chain walk binds it to a same-named local shadow (#2730). (call_expression function: (scoped_identifier - name: (identifier) @reference.name)) @reference.call.free + name: (identifier) @reference.name) @reference.qualified-name) @reference.call.free ;; References — constructor calls (struct literal) ;; tree-sitter-rust gives struct_expression.name one of three node types diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index 1e8db6c22..da9221420 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -5,6 +5,7 @@ import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolv import { rustProvider } from '../rust.js'; import { rustArityCompatibility, rustMergeBindings, resolveRustImportTarget } from './index.js'; import { populateRustOwners } from './method-owners.js'; +import { resolveRustQualifiedFreeCall } from './qualified-call.js'; import { populateRustRangeBindings } from './range-binding.js'; import { isClassLike, @@ -152,6 +153,9 @@ export const rustScopeResolver: ScopeResolver = { arityCompatibility: (callsite, def) => rustArityCompatibility(def, callsite), + resolveQualifiedFreeCall: (site, callerParsed, scopes, workspaceIndex, allFilePaths) => + resolveRustQualifiedFreeCall(site, callerParsed, scopes, workspaceIndex, allFilePaths), + buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup), emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) => diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index b1dd3292e..a12d69c0e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -285,6 +285,7 @@ import { LanguageProvider } from '../../language-provider.js'; import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { ConversionRankFn } from '../passes/overload-narrowing.js'; +import type { WorkspaceResolutionIndex } from '../workspace-index.js'; /** A LinearizeStrategy receives the full ancestor map so C3-style * algorithms (which need to merge each parent's MRO) can implement @@ -906,6 +907,46 @@ export interface ScopeResolver { parsedFiles: readonly ParsedFile[], ) => readonly SymbolDefinition[] | undefined; + /** + * Optional resolver for a module-qualified FREE call — a call written with + * an explicit path but no value receiver (Rust `tools::dispatch(...)`, where + * `tools` names a module, not a variable or a type). + * + * These sites are captured as free calls (`callForm === 'free'`) with the + * written path preserved in `site.rawQualifiedName`. Without this hook the + * qualifier is inert and the scope-chain walk resolves the bare tail name — + * which silently binds to a same-named definition in the CALLER's own file + * when one exists, producing a self-loop and dropping the real cross-module + * edge (#2730: `fn dispatch` in `sched.rs` calling `tools::dispatch` + * resolved to itself, so `impact` reported the central dispatcher as + * risk LOW with 0 affected processes). + * + * `emitFreeCallFallback` invokes this BEFORE the implicit-`this` and + * scope-chain lookups, so an explicit path outranks a lexical shadow. + * Returning `undefined` (unqualified call, unknown module, or no such + * member in the named module) falls through to the unchanged chain — the + * hook is strictly additive and never removes an edge the prior tiers + * would have produced. + * + * Languages whose qualified calls carry a value/type receiver (`x.foo()`, + * `Type::foo()`) are served by the receiver-bound-calls pass and leave + * this undefined. + */ + readonly resolveQualifiedFreeCall?: ( + site: { + readonly name: string; + readonly rawQualifiedName?: string; + /** Needed to locate the calling MODULE, not just the calling file — a + * relative anchor (`super::`) is resolved against the module the call + * sits in, which may be an inline `mod` block inside that file. */ + readonly inScope: ScopeId; + }, + callerParsed: ParsedFile, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + allFilePaths: ReadonlySet, + ) => SymbolDefinition | undefined; + /** * Optional resolver for qualified-receiver member calls where the * receiver is a namespace (not a class) and ordinary scope-chain / diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 4e32a4c4e..efbaba71d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -84,6 +84,9 @@ export function emitFreeCallFallback( scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[], ) => readonly SymbolDefinition[] | undefined; + /** Module-qualified free-call resolver (#2730) — see + * `ScopeResolver.resolveQualifiedFreeCall`. */ + readonly resolveQualifiedFreeCall?: ScopeResolver['resolveQualifiedFreeCall']; readonly conversionRankFn?: ConversionRankFn; readonly conversionOnlyArgTypePrefixes?: readonly string[]; /** Optional per-language constraint hook threaded into @@ -125,6 +128,12 @@ export function emitFreeCallFallback( // defs.byId.values() at every constructor call. Same simple-name keying // and class-like kind filter the previous per-site scan applied. const globalClassesBySimpleName = buildGlobalClassIndex(scopes); + // Workspace file-path set for `resolveQualifiedFreeCall` (a module path maps + // to a file by language convention). Built lazily and once: languages without + // the hook never pay for it. + let allFilePathsMemo: ReadonlySet | undefined; + const allFilePaths = (): ReadonlySet => + (allFilePathsMemo ??= new Set(parsedFiles.map((p) => p.filePath))); // Per-pass memo of pickUniqueGlobalCallable's post-filter candidate list, // keyed (simpleName, callerFilePath). Only created when no per-caller // visibility filter applies (the list is then a pure function of name+file — @@ -217,6 +226,26 @@ export function emitFreeCallFallback( } } } + // Module-qualified free call (`mod::fn()`): the source named the module + // explicitly, so that path outranks every lexical tier below — otherwise + // the scope-chain walk binds the bare tail to a same-named definition in + // the caller's own file and emits a self-loop instead of the real + // cross-module edge (#2730). Only fires when the language populated + // `rawQualifiedName` AND provides the hook; `undefined` falls through to + // the unchanged chain, so this tier is strictly additive. + if ( + fnDef === undefined && + options.resolveQualifiedFreeCall !== undefined && + site.rawQualifiedName !== undefined + ) { + fnDef = options.resolveQualifiedFreeCall( + site, + parsed, + scopes, + workspaceIndex, + allFilePaths(), + ); + } // Implicit-this overload narrowing: an unqualified call inside // a method body might be calling a sibling overload on the // enclosing class. When the workspace has multiple methods of diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 59553b75d..435f74354 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -840,6 +840,7 @@ export function runScopeResolution( freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true, isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, resolveAdlCandidates: provider.resolveAdlCandidates, + resolveQualifiedFreeCall: provider.resolveQualifiedFreeCall, conversionRankFn: provider.conversionRankFn, conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes, constraintCompatibility: provider.constraintCompatibility, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 6e8075f93..e250f88a5 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -1019,7 +1019,20 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void { * for nested namespaces regardless of whether the inner namespace's name is * stored simple or already dotted. Skips defs already carrying the prefix. */ -export function tagNamespacePrefixes(parsed: ParsedFile): void { +export function tagNamespacePrefixes( + parsed: ParsedFile, + options: { readonly qualifiedNamesCarryNamespace?: boolean } = {}, +): void { + // Whether a def's `qualifiedName` may ALREADY encode its enclosing namespace. + // Where it can (C++, C#), a name equal to — or prefixed by — the namespace path + // must not be tagged again. Where it cannot, that guard misreads a coincidence: + // a Rust `mod a { pub fn a() }` has `qualifiedName === 'a'` purely because the + // item and its module share a name, and skipping it leaves the member looking + // like it belongs to the PARENT module. + const alreadyQualified = + options.qualifiedNamesCarryNamespace === undefined + ? true + : options.qualifiedNamesCarryNamespace; const scopesById = new Map(); for (const scope of parsed.scopes) scopesById.set(scope.id, scope); @@ -1051,7 +1064,7 @@ export function tagNamespacePrefixes(parsed: ParsedFile): void { for (const def of scope.ownedDefs) { const q = def.qualifiedName; if (q === undefined || q.length === 0) continue; - if (q === prefix || q.startsWith(`${prefix}.`)) continue; // already namespaced + if (alreadyQualified && (q === prefix || q.startsWith(`${prefix}.`))) continue; def.namespacePrefix = prefix; } } @@ -1075,7 +1088,7 @@ export function tagNamespacePrefixes(parsed: ParsedFile): void { if (def.type === 'Namespace') continue; const q = def.qualifiedName; if (q === undefined || q.length === 0) continue; - if (q === fullPrefix || q.startsWith(`${fullPrefix}.`)) continue; // already namespaced + if (alreadyQualified && (q === fullPrefix || q.startsWith(`${fullPrefix}.`))) continue; if (def.namespacePrefix !== undefined) continue; def.namespacePrefix = fullPrefix; } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 78090b415..6ebc78e67 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -55,6 +55,12 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. +// v31: Rust `mod_item` gained `@declaration.namespace` and scoped call sites +// gained `@reference.qualified-name` (#2730). Both are PARSE-TIME captures, so a +// warm cache replays the old capture set verbatim: `rawQualifiedName` comes back +// undefined and no Namespace def exists to hang a module prefix on, which turns +// the whole module-qualified resolution tier into a no-op on unchanged files. +// Re-checked against origin/main at commit time per the v29 note below. // v29: closure-binding declaration rules for PHP/Rust/Kotlin/Ruby/Dart, a Rust // graph node for `let f = || …`, a Dart closure scope, and function-local VALUES // (Variable/Const/Property/Static) qualified by their enclosing callable plus @@ -119,7 +125,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // JLS 13.1 immediate-host chains (#2555). // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. // v16: direct callee identity. -const SCHEMA_BUMP = 30; +const SCHEMA_BUMP = 31; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index f10e679bf..b3b69feaa 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -542,8 +542,17 @@ export interface RepoMeta { * These change what is emitted for source whose CONTENT has not changed, so a v21 * index would keep serving the pre-fix graph for every unchanged CommonJS file — * the exact "Target not found" symptom #2723 reported. Force a full re-analyze. + * v23: Rust module-qualified calls resolve against the module tree (#2730). + * RUST_SCOPE_QUERY gained `@declaration.namespace` on `mod_item` and + * `@reference.qualified-name` on scoped call sites, and a new resolution tier + * binds `tools::dispatch(..)` to the module the path names instead of the + * lexically nearest same-named fn. Same v11/v12 contract: the incremental + * write set only covers CHANGED files, so a top-up against a pre-v23 index + * would keep the wrong self-loop — and keep reporting the callee as unreached + * — for every unchanged Rust file, which is exactly the symptom #2730 + * reported. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 22; +export const INCREMENTAL_SCHEMA_VERSION = 23; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/Cargo.toml b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/Cargo.toml new file mode 100644 index 000000000..e5096706e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/Cargo.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["crates/noob"] diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/Cargo.toml b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/Cargo.toml new file mode 100644 index 000000000..1b436c905 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "noob" +version = "0.1.0" diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/agent/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/agent/mod.rs new file mode 100644 index 000000000..b7cd049d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/agent/mod.rs @@ -0,0 +1 @@ +pub mod sched; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/agent/sched.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/agent/sched.rs new file mode 100644 index 000000000..7edf6246f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/agent/sched.rs @@ -0,0 +1,9 @@ +use crate::tools::{self, ToolCtx}; + +pub fn execute(ctx: &ToolCtx, name: &str) -> usize { + dispatch(ctx, name) +} + +fn dispatch(ctx: &ToolCtx, name: &str) -> usize { + tools::dispatch(ctx, name) +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/lib.rs new file mode 100644 index 000000000..268a1ef23 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/lib.rs @@ -0,0 +1,2 @@ +pub mod agent; +pub mod tools; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/tools/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/tools/mod.rs new file mode 100644 index 000000000..0a9ef13ed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-crate-layout/crates/noob/src/tools/mod.rs @@ -0,0 +1,7 @@ +pub struct ToolCtx { + pub depth: usize, +} + +pub fn dispatch(ctx: &ToolCtx, name: &str) -> usize { + ctx.depth + name.len() +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/a/b.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/a/b.rs new file mode 100644 index 000000000..449ac7871 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/a/b.rs @@ -0,0 +1,7 @@ +pub fn dispatch() -> usize { + 5 +} + +pub fn go() -> usize { + super::dispatch() +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/a/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/a/mod.rs new file mode 100644 index 000000000..88e28677d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/a/mod.rs @@ -0,0 +1,11 @@ +pub mod b; + +// Case 4: super:: path from a child module, with a local shadow present. +pub fn dispatch() -> usize { + 4 +} + +// Second definition: makes `helper` globally ambiguous on purpose. +pub fn helper() -> usize { + 8 +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/facade.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/facade.rs new file mode 100644 index 000000000..6ee51c1cd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/facade.rs @@ -0,0 +1 @@ +pub use crate::tools::dispatch; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/main.rs new file mode 100644 index 000000000..cfa9fc307 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/main.rs @@ -0,0 +1,44 @@ +mod a; +mod facade; +mod private_facade; +mod tools; + +// Case 1: inline module — rustc resolves `inner::dispatch` via the module tree. +// There is no `inner.rs` on disk. +mod inner { + pub fn dispatch() -> usize { + 1 + } +} + +fn dispatch() -> usize { + inner::dispatch() +} + +// Case 2: nested path a::b::dispatch() +fn nested() -> usize { + a::b::dispatch() +} + +// Case 3: through a `pub use` re-export facade +fn via_reexport() -> usize { + facade::dispatch() +} + +fn via_private() -> usize { + private_facade::helper() +} + +// A leading `::` names an EXTERN CRATE, not this crate's `tools` module. +// Must not resolve to the local module of the same name (#2741 review). +fn via_extern() -> usize { + ::tools::dispatch() +} + +fn main() { + dispatch(); + nested(); + via_reexport(); + via_private(); + via_extern(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/private_facade.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/private_facade.rs new file mode 100644 index 000000000..77babb1d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/private_facade.rs @@ -0,0 +1,7 @@ +// A PRIVATE use. It does not put `helper` on this module's public surface, so +// `private_facade::helper()` does not compile and must not resolve (#2741 review). +use crate::tools::helper; + +pub fn touch() -> usize { + helper() +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/tools.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/tools.rs new file mode 100644 index 000000000..a2552a5f0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-gaps/src/tools.rs @@ -0,0 +1,17 @@ +pub fn dispatch() -> usize { + 3 +} + +// A local helper that happens to share the name. It is NOT a module member: +// before #2741 review H3 it was counted as one, tying the lookup and sending +// every `tools::dispatch()` call back to the self-loop tier. +pub fn wrapper() -> usize { + fn dispatch() -> usize { + 99 + } + dispatch() +} + +pub fn helper() -> usize { + 7 +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/main.rs new file mode 100644 index 000000000..62a5c900b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/main.rs @@ -0,0 +1,16 @@ +mod sched; +mod tools; + +use crate::tools::ToolCtx; + +// `mod tools;` with no `use` binding for the module itself — the qualified +// call still has to reach tools.rs even though the local shadow exists here. +fn dispatch(ctx: &ToolCtx, name: &str) -> usize { + tools::dispatch(ctx, name) +} + +fn main() { + let ctx = ToolCtx { depth: 0 }; + sched::run(&ctx, "x"); + dispatch(&ctx, "y"); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/sched.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/sched.rs new file mode 100644 index 000000000..064749260 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/sched.rs @@ -0,0 +1,21 @@ +use crate::tools::{self, ToolCtx}; + +pub fn run(ctx: &ToolCtx, name: &str) -> usize { + dispatch(ctx, name) +} + +// Same-name wrapper: the qualified call below must bind to tools::dispatch, +// not to this function (#2730). +fn dispatch(ctx: &ToolCtx, name: &str) -> usize { + tools::dispatch(ctx, name) +} + +// Control: no local shadow, resolves today via the global-unique fallback. +fn wrapper(ctx: &ToolCtx, name: &str) -> usize { + tools::helper(ctx, name) +} + +// Control: fully-path-qualified form. +fn crate_qualified(ctx: &ToolCtx, name: &str) -> usize { + crate::tools::dispatch(ctx, name) +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/tools.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/tools.rs new file mode 100644 index 000000000..7fbdf7437 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-samename-wrapper/src/tools.rs @@ -0,0 +1,11 @@ +pub struct ToolCtx { + pub depth: usize, +} + +pub fn dispatch(ctx: &ToolCtx, name: &str) -> usize { + ctx.depth + name.len() +} + +pub fn helper(ctx: &ToolCtx, name: &str) -> usize { + ctx.depth + name.len() + 1 +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/client/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/client/mod.rs new file mode 100644 index 000000000..0da48143b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/client/mod.rs @@ -0,0 +1,14 @@ +pub struct ClientBuilder; + +impl ClientBuilder { + pub fn new() -> usize { + 1 + } +} + +// A module-level `new` living alongside the type. Before #2741 review H2 the +// imported TYPE was treated as the module `client`, so `ClientBuilder::new()` +// bound HERE instead of to the associated function. +pub fn new() -> usize { + 2 +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/driver.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/driver.rs new file mode 100644 index 000000000..455e6d1d5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/driver.rs @@ -0,0 +1,11 @@ +use crate::client::ClientBuilder; +use crate::client; + +pub fn build() -> usize { + ClientBuilder::new() +} + +// Control: a genuine module qualifier must still resolve. +pub fn via_module() -> usize { + client::new() +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/lib.rs new file mode 100644 index 000000000..4d3349ca4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-type-qualified/src/lib.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod driver; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/Cargo.toml b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/Cargo.toml new file mode 100644 index 000000000..be7d34db2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/Cargo.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["crates/alpha", "crates/beta"] diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/Cargo.toml b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/Cargo.toml new file mode 100644 index 000000000..9062c6ed5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "alpha" +version = "0.1.0" diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/lib.rs new file mode 100644 index 000000000..d0df1ec6c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/lib.rs @@ -0,0 +1,2 @@ +pub mod sched; +pub mod tools; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/sched.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/sched.rs new file mode 100644 index 000000000..dc405d2f8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/sched.rs @@ -0,0 +1,6 @@ +use crate::tools; + +// Same-name wrapper (#2730). Must bind to alpha's tools::dispatch, never beta's. +pub fn dispatch(name: &str) -> usize { + tools::dispatch(name) +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/tools.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/tools.rs new file mode 100644 index 000000000..08f430cd3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/alpha/src/tools.rs @@ -0,0 +1,3 @@ +pub fn dispatch(name: &str) -> usize { + name.len() +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/Cargo.toml b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/Cargo.toml new file mode 100644 index 000000000..650119ca6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "beta" +version = "0.1.0" diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/lib.rs new file mode 100644 index 000000000..d0df1ec6c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/lib.rs @@ -0,0 +1,2 @@ +pub mod sched; +pub mod tools; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/sched.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/sched.rs new file mode 100644 index 000000000..6ce1d1eff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/sched.rs @@ -0,0 +1,5 @@ +use crate::tools; + +pub fn dispatch(name: &str) -> usize { + tools::dispatch(name) +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/tools.rs b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/tools.rs new file mode 100644 index 000000000..ca8909702 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2730-workspace-crates/crates/beta/src/tools.rs @@ -0,0 +1,3 @@ +pub fn dispatch(name: &str) -> usize { + name.len() + 1 +} diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 3eff888cb..d9bc4901a 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -1,47 +1,135 @@ { + "rust-2730-crate-layout/crates/noob/src/agent/mod.rs": { + "captureGroups": 3, + "digest": "6f0b853796f272d7b082b5e77bdc5d550c0817d3d021638d666222f81d05d7ea" + }, + "rust-2730-crate-layout/crates/noob/src/agent/sched.rs": { + "captureGroups": 25, + "digest": "86c9c1fdc226b159083adb89441ff4af1b299067ca172f4868fb0c1900e385a8" + }, + "rust-2730-crate-layout/crates/noob/src/lib.rs": { + "captureGroups": 5, + "digest": "05bd68de498361a0b8b747ed6cc22be8cf5059368b66fbefe2ea9562ade58c46" + }, + "rust-2730-crate-layout/crates/noob/src/tools/mod.rs": { + "captureGroups": 15, + "digest": "0e40a25c99e388fc72cbdfcc68cc0b0fc1fda2299e3b757d6efe0694b6f6ed86" + }, + "rust-2730-gaps/src/a/b.rs": { + "captureGroups": 10, + "digest": "a20d02f246b5e35775050650c82a08d207a95d4da5ecdb81db46f786b2f717fc" + }, + "rust-2730-gaps/src/a/mod.rs": { + "captureGroups": 11, + "digest": "7be125d388e9759769b1533fd063f061b24d43c058226bf1b8555f7e5f533b96" + }, + "rust-2730-gaps/src/facade.rs": { + "captureGroups": 2, + "digest": "a367994c959ed693e53cad3ca2960b7a5cfb6c30c32034cd8151edfe8ac1b2c6" + }, + "rust-2730-gaps/src/main.rs": { + "captureGroups": 48, + "digest": "f9d211046e86d1a5d129d73fd89799acb6944bc7c5ef8372cafc0ad6873e160b" + }, + "rust-2730-gaps/src/private_facade.rs": { + "captureGroups": 7, + "digest": "8bbaa5956035779257ae970d2bbaa5175183b82ae8d27e1fe831519eed3eac8d" + }, + "rust-2730-gaps/src/tools.rs": { + "captureGroups": 18, + "digest": "988a4d373dfd7e7589486f628997d2a3e3ad2ae7a3e56ea981626da2e1e9f680" + }, + "rust-2730-samename-wrapper/src/main.rs": { + "captureGroups": 27, + "digest": "f0513d4697c066574ddeeb4e4811b5775c0a68d6a2bdfe606fe8c1a4d37b1ed7" + }, + "rust-2730-samename-wrapper/src/sched.rs": { + "captureGroups": 47, + "digest": "0a21e306764ee28d9521cd7aca488d29207a21b0d6a95fa2e5197b2a0a351d30" + }, + "rust-2730-samename-wrapper/src/tools.rs": { + "captureGroups": 26, + "digest": "d55cde148f74dbe323020ec696f949d18394684b8cc2c7c9babc1f80b2eba914" + }, + "rust-2730-type-qualified/src/client/mod.rs": { + "captureGroups": 13, + "digest": "d74618e1f3444b492ae7728f127981824e5f5abb4f846d36728fddee5a9d3d0d" + }, + "rust-2730-type-qualified/src/driver.rs": { + "captureGroups": 13, + "digest": "71689600fdf5cb039f486bf88b328caa822ada15ed7abd1c93cec9156f75c125" + }, + "rust-2730-type-qualified/src/lib.rs": { + "captureGroups": 5, + "digest": "8d94bd9dc806aef8ce3f610090548c7b344c1c21f7b59ce836a27689515a30bb" + }, + "rust-2730-workspace-crates/crates/alpha/src/lib.rs": { + "captureGroups": 5, + "digest": "9a0eafa33b6ded6c218fe85317e830731342a77e6e669b8561a3a0486d385539" + }, + "rust-2730-workspace-crates/crates/alpha/src/sched.rs": { + "captureGroups": 10, + "digest": "4c5c9bd4854f1667d2236cb701217c7c1c0eb73429f4cfb40a3e82b7f749ac2c" + }, + "rust-2730-workspace-crates/crates/alpha/src/tools.rs": { + "captureGroups": 9, + "digest": "0c618f7418939f9d66f5e5177c55091ff1e296fa4be0331879502bb0970a1aa7" + }, + "rust-2730-workspace-crates/crates/beta/src/lib.rs": { + "captureGroups": 5, + "digest": "9a0eafa33b6ded6c218fe85317e830731342a77e6e669b8561a3a0486d385539" + }, + "rust-2730-workspace-crates/crates/beta/src/sched.rs": { + "captureGroups": 10, + "digest": "ef9247afbd74aa506934347c2125e56e88727ea000f2d8bf31b47285d3de4fab" + }, + "rust-2730-workspace-crates/crates/beta/src/tools.rs": { + "captureGroups": 9, + "digest": "fdbdd68d6920a9d7861562c562acc4fbd4580836467cad1ce8428c57a11d0d19" + }, "rust-abstract-dispatch/src/lib.rs": { "captureGroups": 34, "digest": "973679363065ecd54c4e5128a9fab214ea27eca24f0f079c63a3c6f285e678b0" }, "rust-abstract-dispatch/src/main.rs": { - "captureGroups": 21, - "digest": "9e37c9806579acc6ad6c43e98b7c1320d411020fb0c0e88f5b2c8bbed2053d7d" + "captureGroups": 22, + "digest": "a9dd5493777b5d4ed6c1e658bb1c8e3800f5c76c88c36c7595173947d6233f4c" }, "rust-alias-imports/src/main.rs": { - "captureGroups": 19, - "digest": "ffa9d81433d8d4931f07ba0151b2349f0bbf876e7b327a5ffeeaf3ed18a43abf" + "captureGroups": 20, + "digest": "aec319b49b359ca1c9219bd61855aba837abea09b935b297654e1e0e426d2502" }, "rust-alias-imports/src/models.rs": { "captureGroups": 21, "digest": "fa89b58c454471da61f7e1585854f42e2f331517094420cf7f76fdf5b898795e" }, "rust-ambiguous/src/main.rs": { - "captureGroups": 12, - "digest": "7d29d164d6f64e2bcac4bba8d4b86d725f696a836341b5265a5dce9543b1691e" + "captureGroups": 15, + "digest": "6bdf56e7183e7a1585adaeeb05e166c7145f3148dcd2600b61b237a190384b69" }, "rust-ambiguous/src/models/handler.rs": { "captureGroups": 9, "digest": "5a1729f00b573f6abb214866ae618cd32fe4c166591327f4de782e262af39266" }, "rust-ambiguous/src/models/mod.rs": { - "captureGroups": 3, - "digest": "f83985431e11f24a46d28f6c5256dd2e7b6d17a1079dd51bf8c4fe4f9087be11" + "captureGroups": 4, + "digest": "4ccc48b1de50bc7453d9c7b78a8443a1259abb79b6d99a5aa26519e2495967a5" }, "rust-ambiguous/src/other/handler.rs": { "captureGroups": 9, "digest": "cbcf55909786187d156c6ce9e94e37602897a3b1515aa80bb15110014f31d048" }, "rust-ambiguous/src/other/mod.rs": { - "captureGroups": 3, - "digest": "f83985431e11f24a46d28f6c5256dd2e7b6d17a1079dd51bf8c4fe4f9087be11" + "captureGroups": 4, + "digest": "4ccc48b1de50bc7453d9c7b78a8443a1259abb79b6d99a5aa26519e2495967a5" }, "rust-ambiguous/src/services/mod.rs": { "captureGroups": 8, - "digest": "225b1a2de0f98873a99c669275ecce6a7f6f0d04f2b50f6a15f7546c06f2e295" + "digest": "db2a88326a1f8438fdddd3f5aaa47b09ba88ed31cf4faf8f330d8661358136a7" }, "rust-assignment-chain/src/main.rs": { - "captureGroups": 37, - "digest": "d9dc8c233465fb4d160c8547380fa05a0e2d1cdc4dae1205a33f6e96506f0df6" + "captureGroups": 39, + "digest": "5ebb5d5806ae96f00a9cb3b27e3f862627567002b16cc8c8678ea5a0b4686901" }, "rust-assignment-chain/src/repo.rs": { "captureGroups": 10, @@ -52,8 +140,8 @@ "digest": "a5545efb669e40adf8428af48057cd93bd31c226f7d0952d4dae76e1ee5228f7" }, "rust-async-binding/src/main.rs": { - "captureGroups": 33, - "digest": "4c8c0c21c2dffe6efaf91d26e92c1bafee95f940909fa4d5a73ad0c07b5aee2f" + "captureGroups": 35, + "digest": "004a83d1de5240719da2bc23774168bf1ef2a93b0273f3f59ab1031d5e3bf747" }, "rust-async-binding/src/repo.rs": { "captureGroups": 11, @@ -64,16 +152,16 @@ "digest": "60fc4ac44f58ae67d462e243655b0571392a18de85b9e200e9391b50c941a6c9" }, "rust-call-result-binding/src/main.rs": { - "captureGroups": 11, - "digest": "db75d1b522e5d3574037b692e51423f5f71b6bba011aa120e33d0c6fbc6c1033" + "captureGroups": 12, + "digest": "3e1fdfce3c731e9d6dd83cb8502453988d5fcc1912e1ec0c71513b4510d68392" }, "rust-call-result-binding/src/models.rs": { "captureGroups": 20, "digest": "768d02914dc9e5b495b091ae3f6a27f90ac7b043eeb54e1401c07db4126a5e36" }, "rust-calls/src/main.rs": { - "captureGroups": 9, - "digest": "2ec004ce1d77e3aee80ae1332bc0ab64fb366deffbac84f84ff1861286edc4e5" + "captureGroups": 11, + "digest": "f14b9c0b916d4f352fe6393d6883870f3de1a8ee8d69f088a94b9b099110a8f5" }, "rust-calls/src/onearg/mod.rs": { "captureGroups": 7, @@ -84,12 +172,12 @@ "digest": "aae68fe5023fa93180b05ce8034654bfa1d2d95bc7eea67625e2e3a3cff6ebee" }, "rust-chain-call/src/main.rs": { - "captureGroups": 24, - "digest": "eae71a411927bfc015e03800fda779c065c2513e6a6aa130211216d9bee61a35" + "captureGroups": 25, + "digest": "4b47bbd3de939f8692c1615442e233ad00440075fafc5b6a7f7db35851492a64" }, "rust-chain-call/src/models/mod.rs": { - "captureGroups": 3, - "digest": "c96e9c3df9991cd8e40612b0f1a5fc83a42013cf0870458209fe80c41dd5abd1" + "captureGroups": 5, + "digest": "ba2fb04d7ff79e394c88aaa6c81737fd084a5948b9615a49c89fd3336e7a73b7" }, "rust-chain-call/src/models/repo.rs": { "captureGroups": 11, @@ -104,16 +192,16 @@ "digest": "4af705aa98a718b54c1e8a2037989c747c3e4e6bb8ba833cdf6672a1f8983cec" }, "rust-child-extends-parent/src/main.rs": { - "captureGroups": 18, - "digest": "0c87c741f9b05ff5e01eaf6a27c285ba6ee76b753d6609fb43f8d78e5f66fc0a" + "captureGroups": 20, + "digest": "a08c648a571aa7d96ef573053f47ceea76411102c87075d60f19f84709b5d924" }, "rust-child-extends-parent/src/parent.rs": { "captureGroups": 7, "digest": "89bbfe22788bf85e8fda4ad0d1c8dc14f4ec983624e59b58a68852e093290b25" }, "rust-constructor-type-inference/src/main.rs": { - "captureGroups": 21, - "digest": "5cb66a46d7a52800325aab132297e002ccbda7da322ee84d9b95bd7367842c7c" + "captureGroups": 23, + "digest": "47a20c924b3a69fac902a4d1906b4b17eaa3af699aa1f66200b146d8804f754e" }, "rust-constructor-type-inference/src/repo.rs": { "captureGroups": 15, @@ -144,8 +232,8 @@ "digest": "45fbd21cf6ed58ca9501d26be034a4969388a730dfc6d36e3982fbc3aa56dcd2" }, "rust-cross-module-collision/src/main.rs": { - "captureGroups": 7, - "digest": "e0120e3f215282e68d83b4f8f5d8918945e0b3e7ce4e0128c6afd2aa43caa1c0" + "captureGroups": 10, + "digest": "bb44d63ebf56d2d7a6cddff45d6c9ebb5230cd01b67e610f6d7905867c02bcec" }, "rust-cross-module-collision/src/traits.rs": { "captureGroups": 5, @@ -160,8 +248,8 @@ "digest": "49d6a6bff4bfde6d29d8e63c2899f49688bee98273c9f743d9189dbfdf19ce2c" }, "rust-default-constructor/src/main.rs": { - "captureGroups": 34, - "digest": "6516c6b21d2cca74b18ee443ca0049228b4832efc551ca23e5cf3098c95d34f4" + "captureGroups": 36, + "digest": "9ec5470e4a76abc78e2507d0a58494354cbd64d6d55992236ba42cb53c04296c" }, "rust-default-constructor/src/repo.rs": { "captureGroups": 22, @@ -180,8 +268,8 @@ "digest": "cd02dd0f2b74d8e9495634f3a33477c2b20d5f2877612733b66402eae6fe8426" }, "rust-dup-fields-2/src/main.rs": { - "captureGroups": 16, - "digest": "e96013ac801f874a1ad902c7bd2be277202b38fbf12030c4ebbb46edd8c0fe79" + "captureGroups": 18, + "digest": "89459329c0f81b1b51a0e83fdf84ab61ca61075ce7a8ca4c3af5eeb18763fa4f" }, "rust-dup-fields-3/src/c_a.rs": { "captureGroups": 11, @@ -196,12 +284,12 @@ "digest": "a5263afa5bc9cb6b499d3c5394cc8b0a942d9a1fe388a2366a3baca963ead634" }, "rust-dup-fields-3/src/main.rs": { - "captureGroups": 17, - "digest": "fad21b046b2f34b8a6fd98ffc3719446f176f7207864b3fdd9ba2d9bb07d607f" + "captureGroups": 20, + "digest": "2a7b48f4ead88a794f87d30fb1a6ce3d3a8ea818132e370572a06b77f493118f" }, "rust-dup-return-2/src/main.rs": { - "captureGroups": 14, - "digest": "a4d637dc57a09e56dce75102c70ca6a45998f498990289855d4953bf4ed5461f" + "captureGroups": 16, + "digest": "18dda4ed5e9f24f3e75c037336072bf2489deb5e12d12daf1a8e93cfb965f3ce" }, "rust-dup-return-2/src/t_a.rs": { "captureGroups": 14, @@ -220,16 +308,16 @@ "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" }, "rust-dup-return-3-reordered/src/main.rs": { - "captureGroups": 15, - "digest": "0f68357dbffb22af2c36ca5a935c3eea4025c119afa1a7c1d258ab9a74fe96e8" + "captureGroups": 18, + "digest": "d4c83f0221ba70ff2a3a39e603a02f74cc2dc855775b604f9f02b80592f85bfc" }, "rust-dup-return-3-reordered/src/z_user.rs": { "captureGroups": 14, "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" }, "rust-dup-return-3/src/main.rs": { - "captureGroups": 15, - "digest": "a93ca0874eeaedd21dba987143fa389281d8b738612ca49c314ab91dd73e4065" + "captureGroups": 18, + "digest": "33e63c3ca60640583d0212d224d31d384870c2d3d485db130dfe5fc17ba7b30b" }, "rust-dup-return-3/src/t_a.rs": { "captureGroups": 14, @@ -252,8 +340,8 @@ "digest": "798c8e01c6e54792ba69e845248efc8abf0cba38fa3d16fb8e0d1f6dd2ad2b7e" }, "rust-err-unwrap/src/main.rs": { - "captureGroups": 26, - "digest": "d7da37b0af98c116dba470de505610c4fbb06f152078b684c0094b1e81aff7e4" + "captureGroups": 28, + "digest": "ea004c338da0a5e93e1e51d676fd32458606ba8a47efaf9b2bede6b72feedb6f" }, "rust-err-unwrap/src/repo.rs": { "captureGroups": 9, @@ -272,8 +360,8 @@ "digest": "5bba21d0f792eb98025ce0531c575dac215492027cd877df09b19877e80da4ad" }, "rust-for-call-expr/src/main.rs": { - "captureGroups": 24, - "digest": "292eba3d86490a2ee600ebe4d9e19434204b43706af459c25ae82c2b646da5f9" + "captureGroups": 26, + "digest": "9d462a7fcfe6ddd56e5d82d856adb54a7e16a1e1ddc0e0f25c4a7875f769b91c" }, "rust-for-call-expr/src/repo.rs": { "captureGroups": 14, @@ -284,8 +372,8 @@ "digest": "7331591b986aa2a9b0ee57020c1f0285ad0da10d5044c326b4beaaeb26019aa8" }, "rust-for-loop/src/main.rs": { - "captureGroups": 26, - "digest": "1f80bd8d194a6e0e843e0a2a76e910d25bab0712bd9208f69c045b6be25cbaed" + "captureGroups": 28, + "digest": "4a089fa88dfef98ecf474560c2fea94fe4b5e44617d8e89f3bca01660093fc8f" }, "rust-for-loop/src/repo.rs": { "captureGroups": 9, @@ -296,16 +384,16 @@ "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" }, "rust-generic-impl-same-method-name/lib.rs": { - "captureGroups": 19, - "digest": "494ee20e8d5ebff07a8358938b706e15a48c9a9fb21e0ce361c2a8c391080352" + "captureGroups": 21, + "digest": "b947e11ce51005eeba5cb7720f33be8829959aedbd344d7a2531f7b00d51ad37" }, "rust-grouped-imports/src/helpers/mod.rs": { "captureGroups": 16, "digest": "4363614087aacd43e98bef6c90e33a49edf7a4c4bee2a20a9e1dd8301c84310a" }, "rust-grouped-imports/src/main.rs": { - "captureGroups": 14, - "digest": "d0997acf33c23b27864423aebaa1b1f4341d57bf5374c6f40c7efa6a166b39e7" + "captureGroups": 15, + "digest": "9a29f609177db7bc35b8d153616493fb3850f9e59e4c4beec47d0174dc5beae9" }, "rust-if-let-unwrap/models/mod.rs": { "captureGroups": 1, @@ -320,8 +408,8 @@ "digest": "abf2026b72dacf36eca93895d2601c900424f30ff7f59f904f0dcf1fa377829c" }, "rust-if-let-unwrap/src/main.rs": { - "captureGroups": 16, - "digest": "fa486e6573e6f8372fdff4ffbce6c726e354f1961f27e4f91be5aa0ab49b0496" + "captureGroups": 18, + "digest": "8d2584ced7b2a0705ce62e0ac13287b8f590fcf7f7c2a862614a5af06f19aac9" }, "rust-if-let-unwrap/src/repo.rs": { "captureGroups": 9, @@ -332,16 +420,16 @@ "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" }, "rust-if-let/main.rs": { - "captureGroups": 32, - "digest": "dd9d7112782afac2c55cc9808ea3893551efba8ff4af0c83a55610d5533d24b7" + "captureGroups": 33, + "digest": "e306c807e89ddc8bb6f9d61a63731c49a9f28ab797e3aa19ed73d3bd5b0d3659" }, "rust-if-let/models.rs": { "captureGroups": 20, "digest": "d8c1eb57431b915dd5c9055d8451054a454e340c4842628e38e4d46f69471abd" }, "rust-import-alias-return/src/main.rs": { - "captureGroups": 16, - "digest": "c14fd2abf932f09fe19821951e73afdf03839bdb761a7d0ad347c9926a0d5542" + "captureGroups": 19, + "digest": "74a6ab2a5b5994c1cd63307a8ae93b8df91955374edc916a19d93574669bf7b1" }, "rust-import-alias-return/src/t_a.rs": { "captureGroups": 14, @@ -356,8 +444,8 @@ "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" }, "rust-import-dup-fields/src/main.rs": { - "captureGroups": 18, - "digest": "82b48d00fe2e4b5a2f52185d5d2501fdef408faf148b509fdc5f428171b9a784" + "captureGroups": 21, + "digest": "87c05f2fd9a846d75e469f406d1fc9d8b074f2aad3d1cd29465d59da3a77b7b6" }, "rust-import-dup-fields/src/t_a.rs": { "captureGroups": 11, @@ -372,8 +460,8 @@ "digest": "a5263afa5bc9cb6b499d3c5394cc8b0a942d9a1fe388a2366a3baca963ead634" }, "rust-import-dup-return/src/main.rs": { - "captureGroups": 16, - "digest": "e70780bcc2ccbfca9fe2099887e30187f70c48b68a11948592778997d1e2bd13" + "captureGroups": 19, + "digest": "a86f7bc84a4280d4aa7e600517849fb80582435343c496ecb7d20ae5626b6ca5" }, "rust-import-dup-return/src/t_a.rs": { "captureGroups": 14, @@ -388,8 +476,8 @@ "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" }, "rust-import-glob-ambiguous/src/main.rs": { - "captureGroups": 17, - "digest": "31f683937b9d9208e7f2c6bd2d1092c59bcf6ab16a6a6923b48bba6fcab2a1a6" + "captureGroups": 20, + "digest": "50b0655c9f717a3f740708cdc1134d9f7c67d4992eae5c4486807285b04501f8" }, "rust-import-glob-ambiguous/src/t_a.rs": { "captureGroups": 14, @@ -404,8 +492,8 @@ "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" }, "rust-import-glob-local-shadows/src/main.rs": { - "captureGroups": 28, - "digest": "5242d2ee3c9bdb8c679ddc55726e423fc48997db2bcfc4f79dca3645289d1149" + "captureGroups": 31, + "digest": "dc4df4a03dd1eab2c93c940486277780759814187f1bfa94e6d320fdf790cc29" }, "rust-import-glob-local-shadows/src/t_a.rs": { "captureGroups": 14, @@ -420,8 +508,8 @@ "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" }, "rust-import-glob-return/src/main.rs": { - "captureGroups": 16, - "digest": "a157af0c6d7b8f9861712c1822bb5bab8e7fadbcdd58c08d4d034343117053cb" + "captureGroups": 19, + "digest": "aa2bf2d9b094fba31372e001a0580335aef7e0d41695c41835e3abecfd0d483a" }, "rust-import-glob-return/src/t_a.rs": { "captureGroups": 14, @@ -436,8 +524,8 @@ "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" }, "rust-iter-for-loop/src/main.rs": { - "captureGroups": 30, - "digest": "529fda7f9f188814ce6044e2c99d6b005ec4a9b98835fe9609530897998b9f32" + "captureGroups": 32, + "digest": "17e5404ce443af29626d634c382d589998f07461e19d7fc90cf009e814c1111a" }, "rust-iter-for-loop/src/repo.rs": { "captureGroups": 9, @@ -448,8 +536,8 @@ "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" }, "rust-local-shadow/src/main.rs": { - "captureGroups": 17, - "digest": "fa630d884c95e92a2d9c9b372f88e752a24aa4feb8089c3e43726793f3d12188" + "captureGroups": 18, + "digest": "b5a4009f1b5640c303beae8717381c9ae5ec4b0e9bc1baf50ae44a810c17484d" }, "rust-local-shadow/src/utils.rs": { "captureGroups": 7, @@ -460,8 +548,8 @@ "digest": "19b650be99256aa211356edc5a3dde83aff21e5d763e2c079322a24045bf69c9" }, "rust-match-unwrap/src/main.rs": { - "captureGroups": 26, - "digest": "ffec7f4401caefc75df450004d4b0828b053799dcbf7247ff2b431ea6ac5a46b" + "captureGroups": 28, + "digest": "938f98b69f632575c614f683154aa86bc1fe2827511d4891c7c944e59ffd0e11" }, "rust-match-unwrap/src/repo.rs": { "captureGroups": 9, @@ -472,16 +560,16 @@ "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" }, "rust-member-calls/src/main.rs": { - "captureGroups": 15, - "digest": "2ef8b5f8ad187b1476af8ceff54051dcf9aa71236aa2b7485a28b3ff97e58e1e" + "captureGroups": 16, + "digest": "b647063e4383f8885e492b74e3225221c0da72a2669b407fdee9aa24cc13649a" }, "rust-member-calls/src/user.rs": { "captureGroups": 10, "digest": "a5545efb669e40adf8428af48057cd93bd31c226f7d0952d4dae76e1ee5228f7" }, "rust-method-chain-binding/src/main.rs": { - "captureGroups": 18, - "digest": "b4767bb9c5e6856309d04e0a3dff90940d391a64b7a6f7efa9109d419af6891a" + "captureGroups": 19, + "digest": "88b8b2352e0901eea5922f550e964d3e1896d3f2bceb243f44034dada05e25af" }, "rust-method-chain-binding/src/models.rs": { "captureGroups": 34, @@ -492,20 +580,20 @@ "digest": "7465fc5519b3f643f367ff8c0ae3769a0cb2dbd10e2ea7582bbcff8127a139eb" }, "rust-method-enrichment/src/main.rs": { - "captureGroups": 18, - "digest": "3326eb4f82b1559b6afec497dc52cab734e6f3209501a4bd982bf5eab9ec6dba" + "captureGroups": 19, + "digest": "f1250e83772cc93ca4f010c68774bd15b18fd507863bc2e9ca804608efaeb992" }, "rust-nested-tail-collision-generic/lib.rs": { - "captureGroups": 29, - "digest": "1bfdaaf207a83924fc25d2eec0a47e5807754bbd0de499b81adea48342c6e687" + "captureGroups": 33, + "digest": "ab9d8bdfc674adac69965aa9b214fe6c5028184a98a504db7219af7bc9835265" }, "rust-nested-tail-collision/lib.rs": { - "captureGroups": 17, - "digest": "2fc1fe1eb4e8727a89ab283ae34a0ae8df0c421551a7bd5e6e7ffb9d4aa54189" + "captureGroups": 19, + "digest": "c9670e084d501e3af186bf7b890480c1e364ee18447d39cfcc3d43599eedaadd" }, "rust-nullable-receiver/src/main.rs": { - "captureGroups": 39, - "digest": "2fdecb2bce41738daf30b86b8b145b4ca3438e72387ab25e5b3ba7a5af2c1090" + "captureGroups": 41, + "digest": "811410fc6afe4722331ff1edfc79ce38a94c241ae52b86c10511d65d8603ef19" }, "rust-nullable-receiver/src/repo.rs": { "captureGroups": 10, @@ -516,8 +604,8 @@ "digest": "a5545efb669e40adf8428af48057cd93bd31c226f7d0952d4dae76e1ee5228f7" }, "rust-option-receiver/src/main.rs": { - "captureGroups": 27, - "digest": "497145ee37b17cf2eacb3fab98b71b3a04162d011d1e887c588fb9f77d4283be" + "captureGroups": 29, + "digest": "ab7a69f652524d51534b66defb8f754ceb69c4929668e4e8cfde11d277c6026a" }, "rust-option-receiver/src/repo.rs": { "captureGroups": 8, @@ -528,8 +616,8 @@ "digest": "7e1cca87f7cec8a11d3f96a561639a65813d5dc202cf91f515ed65afc5b8df8b" }, "rust-parent-resolution/src/lib.rs": { - "captureGroups": 3, - "digest": "141388068614e16d96f27cfdf18ac9001b9e202ce832fe10f38dab990637b3ab" + "captureGroups": 5, + "digest": "d2b4f82c6096f14ce68304deb98920a0f9b22010f97ac8950d53fba3d26f2c7b" }, "rust-parent-resolution/src/serializable.rs": { "captureGroups": 5, @@ -537,11 +625,11 @@ }, "rust-parent-resolution/src/user.rs": { "captureGroups": 13, - "digest": "00d29171a1c471087eb3f06cca66c275611e626e6e8f82b8f8fa9b2f6dfb6125" + "digest": "84d0e3980d4f725f8e0831b753a2570ddace613ebf6847ee1a5e86b9fcd4148b" }, "rust-qualified-trait/src/main.rs": { - "captureGroups": 6, - "digest": "bc8946d31db81b85d780633608fdaa7565258cd788285fa00cd6dcb0de3dd16c" + "captureGroups": 8, + "digest": "8c71cad628449f8c770fffe66f0aa1c33fc84c82de86fdb770887e00a937adcd" }, "rust-qualified-trait/src/traits.rs": { "captureGroups": 9, @@ -552,8 +640,8 @@ "digest": "ee34385539f7e9398123c056738c6a662a80dac41fc038db96fab0da5c84c8ac" }, "rust-receiver-resolution/src/main.rs": { - "captureGroups": 23, - "digest": "89d03ee72c223fde12b407ff50ae512dedb1a63aa4239e6f5dd9a54949566786" + "captureGroups": 25, + "digest": "61192da77580c3d846d9648df22e91643fb76d1b0f0edac589211cba8e4432ea" }, "rust-receiver-resolution/src/repo.rs": { "captureGroups": 10, @@ -564,44 +652,44 @@ "digest": "a5545efb669e40adf8428af48057cd93bd31c226f7d0952d4dae76e1ee5228f7" }, "rust-reexport-chain/src/main.rs": { - "captureGroups": 12, - "digest": "d4ddd026d2159710adb889629e17104bb36d2855f15a37b39dadd1dc4cd6b993" + "captureGroups": 13, + "digest": "b21d08ceb71c980b618e39e7f01f055c6f5d424c11dda3d9c709070f68a82614" }, "rust-reexport-chain/src/models/handler.rs": { "captureGroups": 11, "digest": "04091a9f859230d384307beffab59331e960dad13953ef3919ab6b3cfbb585e7" }, "rust-reexport-chain/src/models/mod.rs": { - "captureGroups": 3, - "digest": "f83985431e11f24a46d28f6c5256dd2e7b6d17a1079dd51bf8c4fe4f9087be11" + "captureGroups": 4, + "digest": "4ccc48b1de50bc7453d9c7b78a8443a1259abb79b6d99a5aa26519e2495967a5" }, "rust-return-type-inference/src/main.rs": { - "captureGroups": 32, - "digest": "0f47930a3070f27b80e89065c29dfee3ce4c1923d5e66053553d7536bb521a23" + "captureGroups": 33, + "digest": "7b09dc582e5af3640c7b81b743bc44d415effbf51121a70962a23c6ff3df1671" }, "rust-return-type-inference/src/models.rs": { "captureGroups": 21, "digest": "465517463a6f648955d92b6d179eb9f581a5431077c1eb22c4048d6283387458" }, "rust-return-type/src/main.rs": { - "captureGroups": 11, - "digest": "2ab4961fb869cf40db597b422b24c7ba7a7b63d97487cea7013e065a32e497f3" + "captureGroups": 12, + "digest": "eda00e4ff23254e5f08f7248571630b9a522955a0943dc793fdcef53e80e245f" }, "rust-return-type/src/models.rs": { "captureGroups": 18, "digest": "c589e22d34215100a8e69f0c0a3a801814203edfb28d46a0342d1b1f813af06b" }, "rust-scoped-impl/lib.rs": { - "captureGroups": 17, - "digest": "7bae61e3bde8ce20eade29ce06b13f0a57b7da631d81604218bd07b881a7b754" + "captureGroups": 19, + "digest": "a58a78608812023fb6298c1d74e3216a7582da513f8f7bb7ccb7d51e46ddef4f" }, "rust-scoped-multi-file/src/main.rs": { - "captureGroups": 17, - "digest": "cbb0ad90a6a6ddcb71afb98a98a172bede7f5c06b5c1d31484a11315a264b311" + "captureGroups": 18, + "digest": "9964f6acd248a3bb83d4861a5a4b5f899f257e7372373fc554f3c10260e963d5" }, "rust-scoped-multi-file/src/models/mod.rs": { - "captureGroups": 5, - "digest": "7346b2cf62e4946b261ed0eb46f2623fb1882f46d832abdc2068012917c7b16a" + "captureGroups": 7, + "digest": "ebf5a058cc30c3409521a3432421ffc44c595b1d6ef8e3235a8d896ea2360be0" }, "rust-scoped-multi-file/src/models/repo.rs": { "captureGroups": 20, @@ -612,12 +700,12 @@ "digest": "bbc80d3aab883aa959627a8915ed18f1e90ea1061d65bc41eaf967a85ba91f05" }, "rust-self-struct-literal/main.rs": { - "captureGroups": 11, - "digest": "3c0beb60f1487a63c60329e0843c1913b6a3854bb1ed3df84e4a07bc16dbd82d" + "captureGroups": 12, + "digest": "fcf43627f34d4b17b807f368b40da41da559ea51c1f74835eee5e6ddadb0ae97" }, "rust-self-struct-literal/models.rs": { "captureGroups": 32, - "digest": "d4d831b113acf0a809b8aeec9a8ff02a2ce763fd649c16ceb5792c0d1a270047" + "digest": "8928de22daa88f6f311a407e0051d1dbd3e52c81503f4b295eecb3a3c86b23cf" }, "rust-self-this-resolution/src/repo.rs": { "captureGroups": 10, @@ -628,8 +716,8 @@ "digest": "9354dad2a222a66b46f00ceede6777ba8c0634294c41313f393bc9ebcbbb6aa7" }, "rust-struct-destructuring/main.rs": { - "captureGroups": 17, - "digest": "79ce4f3fa933214532c96161865231c51c5505773794e0c87d5c995a2684cd4b" + "captureGroups": 19, + "digest": "fb1912a7068bd6666472ee5e4cdaecb5afe4b247a44cb70a93ceb654ff2e74b8" }, "rust-struct-destructuring/point.rs": { "captureGroups": 6, @@ -640,16 +728,16 @@ "digest": "ff25512c9a7c4c58d9e06e8ec55727ba13ca20361e7758b560bf0f53b6aa3a93" }, "rust-struct-literal-inference/main.rs": { - "captureGroups": 19, - "digest": "e71269ff626cd0b0655591ee08e48d90e9a1421f3be8d9e8ac23262388a3f7ad" + "captureGroups": 20, + "digest": "89bb1fd5ce51a13418dc9b35def89b8711df27db996636491b477fce0c94ba91" }, "rust-struct-literal-inference/models.rs": { "captureGroups": 27, "digest": "dedd93d6214ff00ee0ee267140918403b7f59e0ab010cb9058af40cb3b08bb81" }, "rust-struct-literals/app.rs": { - "captureGroups": 13, - "digest": "2a1a44788e40cf855c235849c932b4e82f6551a7b5e747e1f6f91b250ead6ec4" + "captureGroups": 14, + "digest": "7b88f81b3b25be95ea5a4a128c07408f936c526dcc8f218d93e7c0cb8d398d7c" }, "rust-struct-literals/user.rs": { "captureGroups": 11, @@ -660,8 +748,8 @@ "digest": "30b0e4e92385e7f7eb83ad97e78ed65b0eced81bbcb20bf52a846924c75ab5bb" }, "rust-traits/src/main.rs": { - "captureGroups": 11, - "digest": "f1b9f72d74467be55a8b7679215b49bcabb4d0fced6080f752672070b32ed93d" + "captureGroups": 13, + "digest": "0f78c887c5a94ea2b03e38d2060046253b1e09f096356b5ae21cf9c6992ed83a" }, "rust-traits/src/traits/clickable.rs": { "captureGroups": 7, @@ -676,8 +764,8 @@ "digest": "e2a6fb9eab259b8c7104f1530b96b8c1f42ab32fe1d71d6bdca04d68263507f2" }, "rust-unique-return/src/main.rs": { - "captureGroups": 13, - "digest": "72cc5728b40f51fae75359c2443986ce73f5d5450ebf8a367a2d860c87c84ed0" + "captureGroups": 14, + "digest": "adc8d4faef631517f4be281c973c61c46325b359fbf482d895fbdfb16feb0931" }, "rust-unique-return/src/t_a.rs": { "captureGroups": 14, @@ -689,7 +777,7 @@ }, "rust-write-access/service.rs": { "captureGroups": 17, - "digest": "4c5055277bdbb762af4f58623c3aba323c9618e900ca731ead19171d4a3a9495" + "digest": "a2c105b1a0efa7f1b303c349bcedc2cebde5592456c53c882fc61711f188901d" }, "synthetic:dao-20": { "captureGroups": 421, diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 2e4a400c4..cb730d9f6 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -2559,3 +2559,232 @@ describe('Rust import-disambiguated duplicate resolution (#2514 follow-up)', () }); }); }); + +// --------------------------------------------------------------------------- +// #2730 — a module-qualified call must not bind to a same-named local fn +// +// `tools::dispatch(...)` is captured as a FREE call named `dispatch`. Before +// the fix the qualifier was discarded, so the scope-chain walk bound the bare +// tail to the ENCLOSING same-named wrapper and emitted a self-loop — the real +// cross-module edge never existed, and `impact` on the callee reported the +// production caller as absent (risk LOW, 0 affected processes) while still +// labelling itself `epistemic: "exact"`. +// --------------------------------------------------------------------------- + +describe('Rust module-qualified free calls (#2730)', () => { + describe('flat src/ layout', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-2730-samename-wrapper'), + () => {}, + ); + }, 60000); + + it('binds tools::dispatch to tools.rs, not to the same-named wrapper (use ::{self})', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/sched.rs' && c.source === 'dispatch', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ + source: 'dispatch', + target: 'dispatch', + targetFilePath: 'src/tools.rs', + }); + expect(edges[0].rel.reason).toBe('import-resolved'); + }); + + it('binds tools::dispatch through a bare `mod tools;` with no use binding', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/main.rs' && c.source === 'dispatch', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/tools.rs' }); + }); + + it('binds a fully path-qualified crate::tools::dispatch to tools.rs', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'crate_qualified'); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/tools.rs' }); + }); + + it('leaves genuinely unqualified calls on the lexical scope chain', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'run'); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/sched.rs' }); + expect(edges[0].rel.reason).toBe('local-call'); + }); + }); + + describe('cargo workspace crates//src layout', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-crate-layout'), () => {}); + }, 60000); + + it('resolves crate::tools through the use edge when no sibling file matches the path', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'crates/noob/src/agent/sched.rs' && c.source === 'dispatch', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ + target: 'dispatch', + targetFilePath: 'crates/noob/src/tools/mod.rs', + }); + }); + + it('keeps the unqualified sibling call local', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'execute'); + expect(edges.length).toBe(1); + expect(edges[0].targetFilePath).toBe('crates/noob/src/agent/sched.rs'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// #2730 — path resolution over the module tree (rustc semantics) +// +// The leading segments of a path name MODULES, resolved in the type namespace, +// so a same-named `fn` (value namespace) can never shadow them. `crate::`, +// `self::` and `super::` are prefix transforms on the calling module, and the +// final segment is a member of the resolved module — including members it only +// re-exports. +// --------------------------------------------------------------------------- + +describe('Rust qualified paths resolve against the module tree (#2730)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-gaps'), () => {}); + }, 60000); + + it('resolves a multi-segment path a::b::dispatch() past a same-named local fn', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'nested'); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/a/b.rs' }); + }); + + it('resolves super::dispatch() to the parent module, not the caller file', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'go'); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/a/mod.rs' }); + }); + + it('ignores a function-local fn of the same name in the target module (H3)', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_reexport'); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/tools.rs' }); + }); + + it('follows a `pub use` re-export through to the original definition', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_reexport'); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/tools.rs' }); + }); + + it('does not bind a leading :: path into the local module of the same name', () => { + // `::tools::dispatch()` names an EXTERN crate. GitNexus does not model extern + // crates, so the qualified tier must refuse; whatever the unchanged lexical + // tier then does is out of scope here. What must NOT happen is this binding + // to the local `tools` module as though the `::` were absent. + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_extern'); + expect(edges.filter((c) => c.targetFilePath === 'src/tools.rs')).toEqual([]); + }); + + it('does not treat a private `use` as a re-export', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_private'); + expect(edges).toEqual([]); + }); + + it('keeps every qualified target distinct from the crate-root fn of the same name', () => { + const targets = getRelationships(result, 'CALLS') + .filter((c) => ['nested', 'go', 'via_reexport'].includes(c.source)) + .map((c) => c.targetFilePath) + .sort(); + expect(targets).toEqual(['src/a/b.rs', 'src/a/mod.rs', 'src/tools.rs']); + }); +}); + +// --------------------------------------------------------------------------- +// #2730 review H1 — module identity carries the crate. +// +// A cargo workspace routinely gives several members the same internal module +// name. With identity by path segments alone, `crates/alpha/src/tools.rs` and +// `crates/beta/src/tools.rs` were the SAME module: where only one defined the +// member the call bound across crates, and where both did the lookup tied and +// refused — handing the site back to the lexical walk that reinstates the very +// self-loop #2730 is about. Rust has no implicit cross-crate paths, so two +// modules in different crates are never the same module. +// --------------------------------------------------------------------------- + +describe('Rust qualified calls stay inside their own crate (#2730 review H1)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-workspace-crates'), () => {}); + }, 60000); + + it('binds alpha::sched::dispatch to alpha tools, not beta', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'crates/alpha/src/sched.rs' && c.source === 'dispatch', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ + target: 'dispatch', + targetFilePath: 'crates/alpha/src/tools.rs', + }); + }); + + it('binds beta::sched::dispatch to beta tools, not alpha', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'crates/beta/src/sched.rs' && c.source === 'dispatch', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ + target: 'dispatch', + targetFilePath: 'crates/beta/src/tools.rs', + }); + }); + + it('emits no self-loop in either crate', () => { + const selfLoops = getRelationships(result, 'CALLS').filter( + (c) => + c.source === 'dispatch' && c.target === 'dispatch' && c.sourceFilePath === c.targetFilePath, + ); + expect(selfLoops).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// #2730 review H2 — a `use` binding must name a MODULE, not a type. +// +// Import resolution deliberately strips a trailing symbol segment when probing +// for a file, so `use crate::client::ClientBuilder;` also resolves to +// `client/mod.rs`. Taking that at face value made the imported TYPE look like +// the module `client`, and `ClientBuilder::new()` bound to an unrelated +// module-level `new` instead of the associated function. +// --------------------------------------------------------------------------- + +describe('Rust type-qualified calls are not treated as module paths (#2730 review H2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-type-qualified'), () => {}); + }, 60000); + + it('does not bind ClientBuilder::new() to the module-level new', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'build' && c.target === 'new', + ); + const moduleLevel = edges.filter((c) => c.targetLabel === 'Function'); + expect(moduleLevel).toEqual([]); + }); + + it('still resolves a genuine module qualifier', () => { + const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_module'); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ target: 'new', targetFilePath: 'src/client/mod.rs' }); + }); +}); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 65b48dc6f..0099ffda9 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 22 (CommonJS export indexing, #2723)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 23 (Rust module-qualified calls, #2730)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(22); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(23); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -168,11 +168,14 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // collapsed onto one node and asserted a CALLS edge present nowhere in the // source. expect(passesReuseGate(20)).toBe(false); - // A current-version stamp passes the gate (incremental top-up eligible). // A pre-v22 (v21) index predates CommonJS export indexing (#2723): every // unchanged CJS file would keep its pre-fix graph → must NOT reuse. expect(passesReuseGate(21)).toBe(false); - // The current stamp passes. - expect(passesReuseGate(22)).toBe(true); + // A pre-v23 (v22) index predates Rust module-qualified call resolution + // (#2730): every unchanged Rust file would keep the same-name self-loop and + // keep reporting the real callee as unreached → must NOT reuse. + expect(passesReuseGate(22)).toBe(false); + // The current stamp passes (incremental top-up eligible). + expect(passesReuseGate(23)).toBe(true); }); }); diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-module-path.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-module-path.test.ts new file mode 100644 index 000000000..1429559ba --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/rust/rust-module-path.test.ts @@ -0,0 +1,203 @@ +/** + * Unit tests for the Rust module-path model (#2730 review). + * + * These pin the identity rules the qualified-call resolver depends on. The + * integration tests exercise resolution end to end; these cover the arithmetic + * directly, including the branches a fixture cannot reach (a file under no crate + * root, `super::` walking above the crate root). + */ +import { describe, it, expect } from 'vitest'; +import { + buildRustModuleIndex, + moduleOfDef, + moduleOfFile, + resolveAnchoredModulePath, + sameModule, +} from '../../../../src/core/ingestion/languages/rust/module-path.js'; + +const WORKSPACE = buildRustModuleIndex( + new Set([ + 'crates/alpha/src/lib.rs', + 'crates/alpha/src/tools.rs', + 'crates/alpha/src/a/mod.rs', + 'crates/alpha/src/a/b.rs', + 'crates/beta/src/lib.rs', + 'crates/beta/src/tools.rs', + ]), +); + +const SINGLE = buildRustModuleIndex(new Set(['src/main.rs', 'src/tools.rs', 'src/a/mod.rs'])); + +describe('buildRustModuleIndex', () => { + it('finds one crate root per member, longest first', () => { + expect(WORKSPACE.crateRoots).toEqual(['crates/alpha/src', 'crates/beta/src']); + }); + + it('finds a single crate root for a plain package', () => { + expect(SINGLE.crateRoots).toEqual(['src']); + }); +}); + +describe('moduleOfFile', () => { + it('maps a crate root file to the crate-root module', () => { + expect(moduleOfFile('src/main.rs', SINGLE)).toMatchObject({ crateRoot: 'src', segments: [] }); + }); + + it('maps a plain module file to its own segment', () => { + expect(moduleOfFile('src/tools.rs', SINGLE)).toMatchObject({ + crateRoot: 'src', + segments: ['tools'], + }); + }); + + it('does not let mod.rs contribute a segment', () => { + expect(moduleOfFile('src/a/mod.rs', SINGLE)).toMatchObject({ + crateRoot: 'src', + segments: ['a'], + }); + }); + + it('maps a nested module file to the full path', () => { + expect(moduleOfFile('crates/alpha/src/a/b.rs', WORKSPACE)).toMatchObject({ + crateRoot: 'crates/alpha/src', + segments: ['a', 'b'], + }); + }); + + it('returns undefined for a file under no crate root', () => { + expect(moduleOfFile('scripts/helper.rs', SINGLE)).toBeUndefined(); + }); +}); + +describe('module identity carries the crate (#2730 review H1)', () => { + it('gives two crates the same internal segments', () => { + const alpha = moduleOfFile('crates/alpha/src/tools.rs', WORKSPACE); + const beta = moduleOfFile('crates/beta/src/tools.rs', WORKSPACE); + expect(alpha).toMatchObject({ segments: ['tools'] }); + expect(beta).toMatchObject({ segments: ['tools'] }); + }); + + it('but does NOT treat them as the same module', () => { + const alpha = moduleOfFile('crates/alpha/src/tools.rs', WORKSPACE)!; + const beta = moduleOfFile('crates/beta/src/tools.rs', WORKSPACE)!; + expect(sameModule(alpha, beta)).toBe(false); + }); + + it('treats a module as equal to itself', () => { + const one = moduleOfFile('crates/alpha/src/tools.rs', WORKSPACE)!; + const two = moduleOfFile('crates/alpha/src/tools.rs', WORKSPACE)!; + expect(sameModule(one, two)).toBe(true); + }); +}); + +describe('moduleOfDef', () => { + it('appends an inline mod prefix to the file module', () => { + expect(moduleOfDef('src/tools.rs', 'inner', SINGLE)).toMatchObject({ + crateRoot: 'src', + segments: ['tools', 'inner'], + }); + }); + + it('splits a nested inline prefix', () => { + expect(moduleOfDef('src/main.rs', 'outer.inner', SINGLE)).toMatchObject({ + segments: ['outer', 'inner'], + }); + }); + + it('leaves the file module alone when there is no prefix', () => { + expect(moduleOfDef('src/tools.rs', undefined, SINGLE)).toMatchObject({ segments: ['tools'] }); + }); +}); + +describe('resolveAnchoredModulePath', () => { + const caller = { crateRoot: 'src', segments: ['a', 'b'] }; + + it('anchors crate:: at the caller crate root', () => { + expect(resolveAnchoredModulePath(['crate', 'tools'], caller)).toMatchObject({ + anchored: true, + module: { crateRoot: 'src', segments: ['tools'] }, + }); + }); + + it('anchors self:: at the calling module', () => { + expect(resolveAnchoredModulePath(['self', 'inner'], caller)).toMatchObject({ + anchored: true, + module: { segments: ['a', 'b', 'inner'] }, + }); + }); + + it('pops one segment per super', () => { + expect(resolveAnchoredModulePath(['super', 'sibling'], caller)).toMatchObject({ + anchored: true, + module: { segments: ['a', 'sibling'] }, + }); + }); + + it('consumes a super chain left to right', () => { + expect(resolveAnchoredModulePath(['super', 'super', 'top'], caller)).toMatchObject({ + anchored: true, + module: { segments: ['top'] }, + }); + }); + + it('refuses a super chain that walks above the crate root', () => { + expect(resolveAnchoredModulePath(['super', 'super', 'super', 'x'], caller)).toBeUndefined(); + }); + + it('keeps a bare path relative for the caller to try in context', () => { + expect(resolveAnchoredModulePath(['tools'], caller)).toMatchObject({ + anchored: false, + module: { crateRoot: 'src', segments: ['tools'] }, + }); + }); + + it('keeps an anchored path inside the caller crate', () => { + const inBeta = { crateRoot: 'crates/beta/src', segments: [] }; + expect(resolveAnchoredModulePath(['crate', 'tools'], inBeta)).toMatchObject({ + module: { crateRoot: 'crates/beta/src' }, + }); + }); +}); + +// --------------------------------------------------------------------------- +// #2741 review — `src/bin/.rs` is its own crate, not a library module. +// --------------------------------------------------------------------------- + +describe('auto-discovered binary targets', () => { + const WITH_BIN = buildRustModuleIndex( + new Set([ + 'src/lib.rs', + 'src/helper.rs', + 'src/bin/tool.rs', + 'src/bin/tool/helper.rs', + 'src/bin/other/main.rs', + ]), + ); + + it('treats a src/bin entry file as its own crate root, not module bin::tool', () => { + expect(moduleOfFile('src/bin/tool.rs', WITH_BIN)).toMatchObject({ + crateRoot: 'src/bin/tool', + segments: [], + }); + }); + + it('places a binary submodule under the binary, not the library', () => { + expect(moduleOfFile('src/bin/tool/helper.rs', WITH_BIN)).toMatchObject({ + crateRoot: 'src/bin/tool', + segments: ['helper'], + }); + }); + + it('keeps the library module separate from the same-named binary module', () => { + const lib = moduleOfFile('src/helper.rs', WITH_BIN)!; + const bin = moduleOfFile('src/bin/tool/helper.rs', WITH_BIN)!; + expect(sameModule(lib, bin)).toBe(false); + }); + + it('handles the src/bin//main.rs directory form', () => { + expect(moduleOfFile('src/bin/other/main.rs', WITH_BIN)).toMatchObject({ + crateRoot: 'src/bin/other', + segments: [], + }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-namespace-prefix.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-namespace-prefix.test.ts new file mode 100644 index 000000000..8b1a84046 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/rust/rust-namespace-prefix.test.ts @@ -0,0 +1,56 @@ +/** + * Rust opts out of the shared "already namespaced" guard (#2741 review). + * + * `tagNamespacePrefixes` skips a def whose `qualifiedName` already equals, or is + * prefixed by, its enclosing namespace path — correct for C++/C#, where the + * qualified name really does carry the namespace. Rust qualified names never do, + * so that guard fired on a coincidence: in `mod a { pub fn a() }` the member's + * name equals its module's name, the prefix was skipped, and the member was left + * looking like it belonged to the PARENT module. + */ +import { describe, it, expect } from 'vitest'; +import { emitRustScopeCaptures } from '../../../../src/core/ingestion/languages/rust/index.js'; +import { extract } from '../../../../src/core/ingestion/scope-extractor.js'; +import { rustProvider } from '../../../../src/core/ingestion/languages/rust.js'; +import { populateRustOwners } from '../../../../src/core/ingestion/languages/rust/method-owners.js'; + +function defsFor(source: string) { + const parsed = extract(emitRustScopeCaptures(source, 'src/lib.rs'), 'src/lib.rs', rustProvider); + populateRustOwners(parsed); + return parsed.localDefs; +} + +describe('Rust namespace-prefix tagging', () => { + it('tags a member whose name matches its own module', () => { + const fn = defsFor('pub mod a { pub fn a() -> usize { 1 } }\n').find( + (d) => d.type === 'Function', + ); + expect(fn).toMatchObject({ qualifiedName: 'a', namespacePrefix: 'a' }); + }); + + it('tags a member whose name differs from its module', () => { + const fn = defsFor('pub mod tools { pub fn dispatch() -> usize { 1 } }\n').find( + (d) => d.type === 'Function', + ); + expect(fn).toMatchObject({ qualifiedName: 'dispatch', namespacePrefix: 'tools' }); + }); + + it('composes nested module prefixes', () => { + const fn = defsFor('pub mod outer { pub mod inner { pub fn f() -> usize { 1 } } }\n').find( + (d) => d.type === 'Function', + ); + expect(fn).toMatchObject({ namespacePrefix: 'outer.inner' }); + }); + + it('leaves a crate-root item untagged', () => { + const fn = defsFor('pub fn f() -> usize { 1 }\n').find((d) => d.type === 'Function'); + expect(fn?.namespacePrefix).toBeUndefined(); + }); + + it('does not tag the module def itself', () => { + const ns = defsFor('pub mod a { pub fn a() -> usize { 1 } }\n').find( + (d) => d.type === 'Namespace', + ); + expect(ns?.namespacePrefix).toBeUndefined(); + }); +});