diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 387f94012f..ab2f933b1f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -396,6 +396,7 @@ Single interface a language implements to plug into the pipeline. Contract fully | `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions | | `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in | | `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) | +| `resolveQualifiedCallee?` | `(calleeText, inScope, …) → callable \| undefined` — resolve a qualified callee only the language can read (`rp::make` in `x = rp::make()` / `rp::make().m()`) so chain-follow types the call from its return annotation; default off | | `resolveQualifiedTypeSpellings?` | A receiver's declared or return type written QUALIFIED (`rp::C x`, `f(a::Config c)`) that binds nothing through the scope chain resolves through `resolveQualifiedInheritanceBase` — qualifier matched against the candidate's namespace, refuse on a tie — instead of by its tail, which binds the nearer same-named class. Runs after every existing lookup and after the template-argument match; default off, C++ opts in | | `splitQualifiedCallee?` | `(callee, callerScope?, scopes?, position?) → { receiver, member } \| undefined` — map a qualified callee spelling (`rp::make`) onto a receiver/member pair that retains the full written namespace qualifier, so a factory result (`auto x = rp::make(); x.m()`, `rp::make().m()`) is typed from the definition `resolveQualifiedReceiverMember` returns and that definition's own declared return type. Caller scope, indexes and source position let a provider reject shadowed or aliased namespace qualifiers only when visible at the factory expression. Shared code never splits on a separator; default off, C++ implements it | diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 7b0e0eb8c0..0eb05b3302 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,30 @@ # Receiver-resolution baseline +## Rust module-qualified factory results (#3498) + +The provider resolves module-qualified factory callees such as `rp::make`, +`modx::make` and `crate::modx::make` before reading their own return annotations. +Bound and inline receivers share that lookup. Untyped factory results retain +an unresolved-receiver drop. Type-qualified construction remains separate. + +The merged corpus contains 5,351 resolution records, compared with main +`f6e42092`'s 5,319: 32 records added, none removed, all in Rust. The additions include the +new factory fixture and `rust-ambiguous`'s `models::Handler.handle` receiver. +Shape states are unchanged. The fixture's two untyped receivers add two call +drops and two callee-read drops: calls 135 → 137, reads 27 → 29, all kinds +182 → 186; writes remain 20. The final benchmark check verifies this baseline. + +Qualified candidates retain Unicode names and accept uppercase module names. +Lexical type claims distinguish type-qualified construction from module calls, +including lowercase types and derived `Default`. A resolved type import or local +type claim cannot fall back to a same-named module. Const generic values remain +in Rust's value namespace. Local type aliases are not expanded by this change. + +The merge retains main's Zig capture behavior. Filename casing cannot establish +whether an imported file is a namespace; restoring main preserves the lowercase +`config.zig` file-struct edge. Python root-package imports and method-return +ownership use main's declaration checks. A cold Rust disk-scope regression and +a repeated-member call regression cover the shared producer and drop paths. ## Zig receiver gaps from the #3498 drop triage Measured against main `2b6542737`, the four Zig fixes clear 18 @@ -982,6 +1007,9 @@ Shape states remain unchanged; counts are 133 call drops and 160 drops of all kinds (27 reads, 0 writes). The counts include both main and this branch's fixes. +### Rust qualified factories after #3653 + +Main `2b4456553` is merged normally. The receiver-drop guard preserves both qualified-factory evidence and the module-export exclusion. Fresh build, both TypeScript configurations and all 28 Rust boundary, capture-golden and CommonJS checks pass. The measured baseline has 135 call drops, 164 total drops, 29 reads and 0 writes. ### C++ qualified receivers after #3653 Main `2b4456553` is merged normally. The field lookup retains both the declared namespace purpose and provider-qualified type handling. Build and both TypeScript configurations pass, and all 59 focused C++ and CommonJS merge checks pass. The measured receiver baseline has 133 call drops, 160 total drops, 27 reads and 0 writes. @@ -989,8 +1017,15 @@ Main `2b4456553` is merged normally. The field lookup retains both the declared Main `2b4456553` is merged normally. The measured baseline has 130 call drops and 156 across all kinds, with 26 reads and zero writes. Main has 133 calls, 27 reads and zero writes (160 total). Every shape state matches main. The fresh build and both TypeScript configurations pass, as do all 68 namespace-boundary, capture, hook and CommonJS merge checks. +### Rust qualified factories after #3655 + +Main `caacc3bac` is merged normally. The C# capture baseline retains main's fixture additions, and receiver counts are regenerated on the combined source: 132 call drops, 160 total, 28 reads and zero writes. All shape states match the preceding Rust baseline. Fresh build, both TypeScript configurations and 23 Rust factory, capture and CommonJS checks pass. Final hosted CI remains required. ### C++ qualified receivers after #3655 Main `caacc3bac` is merged normally. The regenerated combined baseline has 130 call drops,156 total,26 reads and zero writes; all shape states match. These counts include the C# namespace fix from main. The broad C++ resolver run was interrupted for the prioritized focused merge checks and is not reported as a pass. Fresh exact-head CI remains required. The prioritized merge validation passes a fresh package build, both TypeScript configurations and 56 tests across six C++, CommonJS and C# namespace-boundary files. + +### Rust qualified factories after #3656 + +Main `a09f76be3` is merged normally. The shared receiver pass keeps Rust's provider-qualified callee hook and C++'s position-aware qualified-member lookup. Fresh package build, both TypeScript configurations and 302 tests across 20 Rust, C++, Go, CommonJS and sealed-disk-scope files pass. All 16 capture scaling checks pass without changing their budgets. The regenerated receiver check passes with 132 call drops, 160 across all kinds, 28 reads and zero writes; shape states are unchanged. The corpus adds 32 Rust resolution records to main's 5,392, removes none, and totals 5,424. Exact-head hosted CI remains required. diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 1378a427b5..a70499aa11 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -199,11 +199,11 @@ } }, "countArm": { - "callDrops": 130, - "totalDropsAllKinds": 156, + "callDrops": 132, + "totalDropsAllKinds": 160, "bySiteKind": { - "call": 130, - "read": 26 + "call": 132, + "read": 28 }, "callDropsByExtension": { ".java": 49, @@ -211,10 +211,10 @@ ".ts": 16, ".zig": 10, ".tsx": 6, + ".rs": 6, ".cs": 5, ".go": 4, ".php": 4, - ".rs": 4, ".kt": 4, ".dart": 2, ".js": 1, @@ -222,13 +222,13 @@ }, "callDropsByShape": { "chain-field": 54, - "no-chain": 31, + "no-chain": 33, "chain-call": 31, "<>": 13, "chain-unwrap": 1 }, "callDropsByOrigin": { - "in-program": 53, + "in-program": 55, "external": 44, "unknown": 31, "<>": 2 diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index d0873ef323..8e0892c671 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -94,7 +94,7 @@ }, "rust": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", - "fingerprint": "7d314a184cba1e5c47260d0a5018107146437f56a2bd183ae0670f0d77a3fbd2", + "fingerprint": "19045ed3d4ee9fcea36e949284b385f8d0a24e471cb05f77d8b35d8a1a8dd7fa", "scaling_budget": 1.5, "_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches — the same matches are minted, carrying one more field — so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.", "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", @@ -110,9 +110,11 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.", "capture_groups_small": 5507, "capture_groups_large": 17607, - "capture_groups_fp": 3556, - "fixture_count": 202, - "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 28 existing field-read matches gain receiver-chain tags; removing only these added tags restores the complete main capture set. No group is added or removed. Fixture entries 204; changed entries 6; capture groups 3568 -> 3568. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." + "capture_groups_fp": 3699, + "fixture_count": 207, + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 28 existing field-read matches gain receiver-chain tags; removing only these added tags restores the complete main capture set. No group is added or removed. Fixture entries 204; changed entries 6; capture groups 3568 -> 3568. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged.", + "_rebaselined_3498_qualified_factory": "Rust module-qualified factory fixture adds four files. Provider-qualified callee and return bindings retain the complete module path. All other language fingerprints equal merged main. Measured on the combined main merge; native boundary tests cover untyped and disk-sealed calls.", + "_rebaselined_3498_qualified_factory_review": "Review follow-up: fixture comment distinguishes module factories from type-qualified/untyped controls. Current measured Rust corpus: 207 files, 3699 capture groups, synthetic 5507/17607; fingerprint 19045ed3d4ee9fcea36e949284b385f8d0a24e471cb05f77d8b35d8a1a8dd7fa. Constructor inference retains Unicode/uppercase qualified candidates until lexical type ownership is available. Other 15 fingerprints unchanged; all 16 scaling ratios below unchanged 1.5 budgets (Rust 1.092)." }, "php": { "fingerprint": "310d2c1cddf3b36264f988a78486f5c287a1094f8539dd165e8b58a5258febd5", diff --git a/gitnexus/src/core/ingestion/languages/rust/interpret.ts b/gitnexus/src/core/ingestion/languages/rust/interpret.ts index 547114eaca..4fdcd5f01c 100644 --- a/gitnexus/src/core/ingestion/languages/rust/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/rust/interpret.ts @@ -158,8 +158,16 @@ function findTopLevelComma(text: string): number { return -1; } +// Preserve possible module-function paths until finalized type bindings can +// distinguish module factories from associated functions. Casing is a lint. +const RUST_MODULE_FN_PATH_RE = + /^(?:(?:r#)?[_\p{XID_Start}][\p{XID_Continue}]*::)+(?:r#)?[_\p{XID_Start}][\p{XID_Continue}]*$/u; + function normalizeRustCallReturnType(text: string): string { - let t = text.trim(); + let t = text.trim().replace(/\s*::\s*/g, '::'); + // Kept whole so `resolveQualifiedCallee` can read the function's return + // annotation; the head alone would name the module, not a type (#3498). + if (!t.startsWith('Self::') && RUST_MODULE_FN_PATH_RE.test(t)) return t; // For scoped calls like `Foo::new()`, extract the type part before `::` const scopeIdx = t.indexOf('::'); if (scopeIdx !== -1) { diff --git a/gitnexus/src/core/ingestion/languages/rust/lexical-bindings.ts b/gitnexus/src/core/ingestion/languages/rust/lexical-bindings.ts index 5fada0b77e..6c741921e1 100644 --- a/gitnexus/src/core/ingestion/languages/rust/lexical-bindings.ts +++ b/gitnexus/src/core/ingestion/languages/rust/lexical-bindings.ts @@ -20,6 +20,7 @@ const SCOPE_TYPES = new Set([ 'impl_item', 'enum_item', 'union_item', + 'type_item', ]); /** Pattern identifiers own values even when no useful target/type was indexed. */ @@ -89,6 +90,10 @@ export function collectRustNameClaims(root: SyntaxNode): ReadonlyMap): RustModuleIndex => buildRustModuleIndex(allFilePaths), ); +/** What path resolution reads off the calling file. */ +type RustCaller = Pick; + +/** The callee name ending a `::` path (`make` in `crate::modx::make`). */ +const RUST_PATH_TAIL_RE = /::\s*((?:r#)?[_\p{XID_Start}][\p{XID_Continue}]*)\s*$/u; + +/** + * `ScopeResolver.resolveQualifiedCallee`: the function a callee path names + * (`rp::make` in `let x = rp::make()` or `rp::make().m()`), resolved over the + * module tree exactly like the free call itself, so the call's type and its + * CALLS edge cannot disagree (#3498). A type-qualified path (`Foo::new`) + * names no module and is refused. + */ +export function resolveRustQualifiedCallee( + calleeText: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + allFilePaths: ReadonlySet, +): SymbolDefinition | undefined { + const name = RUST_PATH_TAIL_RE.exec(calleeText)?.[1]; + if (name === undefined) return undefined; + const filePath = scopes.scopeTree.getScope(inScope)?.filePath; + if (filePath === undefined) return undefined; + const moduleScope = workspaceIndex.moduleScopeByFile.get(filePath); + if (moduleScope === undefined) return undefined; + return resolveRustQualifiedFreeCall( + { name, rawQualifiedName: calleeText, inScope }, + { filePath, moduleScope: moduleScope.id }, + scopes, + workspaceIndex, + allFilePaths, + ); +} + export function resolveRustQualifiedFreeCall( site: { readonly name: string; readonly rawQualifiedName?: string; readonly inScope: ScopeId }, - callerParsed: ParsedFile, + callerParsed: RustCaller, scopes: ScopeResolutionIndexes, workspaceIndex: WorkspaceResolutionIndex, allFilePaths: ReadonlySet, @@ -99,7 +138,13 @@ export function resolveRustQualifiedFreeCall( // which is the part that scales with the workspace. const index = moduleIndexFor(allFilePaths); const pass = passIndexFor(workspaceIndex, index, scopes); - if (!couldNameAModule(qualifier, pass.knownModuleNames)) { + // A `use crate::modx as rp;` alias is a module name only in this file, so the + // workspace-wide filter cannot know it; candidate 2 resolves it. + if ( + !couldNameAModule(qualifier, pass.knownModuleNames) && + importedModulesNamed(qualifier[0], scopes, workspaceIndex, index, site.inScope).next().done === + true + ) { return undefined; } @@ -117,6 +162,7 @@ export function resolveRustQualifiedFreeCall( scopes, workspaceIndex, index, + site.inScope, )) { const hit = findMemberInModule(targetModule, site.name, scopes, workspaceIndex, index) ?? @@ -132,7 +178,7 @@ export function resolveRustQualifiedFreeCall( * makes `super::` correct from inside an inline module. */ function callerModuleOf( - callerParsed: ParsedFile, + callerParsed: RustCaller, inScope: ScopeId, scopes: ScopeResolutionIndexes, index: RustModuleIndex, @@ -166,10 +212,11 @@ function* candidateModules( anchored: { readonly module: RustModule; readonly anchored: boolean }, qualifier: readonly string[], callerModule: RustModule, - callerParsed: ParsedFile, + callerParsed: RustCaller, scopes: ScopeResolutionIndexes, workspaceIndex: WorkspaceResolutionIndex, index: RustModuleIndex, + inScope: ScopeId, ): Generator { if (anchored.anchored) { yield anchored.module; @@ -188,34 +235,59 @@ function* candidateModules( // 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) return; + const claim = lookupNameClaim(inScope, head, scopes, { purpose: 'type' }); + if (claim.status !== 'absent' && claim.bindings.length === 0 && claim.imports.length === 0) + return; if ( - head !== undefined && - declaresSubmodule(callerParsed, callerModule, head, workspaceIndex, index, scopes) - ) { - yield { crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, ...qualifier] }; + claim.bindings.some( + (binding) => + isClassLike(binding.def.type) || + binding.def.type === 'Typedef' || + binding.def.type === 'TypeAlias', + ) + ) + return; + if (claim.imports.length > 0) { + for (const importedModule of importedModulesNamed( + head, + scopes, + workspaceIndex, + index, + inScope, + )) { + yield { + crateRoot: importedModule.crateRoot, + segments: [...importedModule.segments, ...qualifier.slice(1)], + }; + } + return; } - - // 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, 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)], - }; + const namespaces = claim.bindings.filter((binding) => binding.def.type === 'Namespace'); + if (namespaces.length > 0) { + const modules = new Map(); + for (const { def } of namespaces) { + const target = moduleOfImport( + { targetFile: def.filePath, targetExportedName: head, targetDefId: def.nodeId }, + scopes, + workspaceIndex, + index, + ); + if (target !== undefined) + modules.set(`${target.crateRoot}\0${target.segments.join('::')}`, target); + } + if (modules.size === 1) { + const selected = modules.values().next().value!; + yield { + crateRoot: selected.crateRoot, + segments: [...selected.segments, ...qualifier.slice(1)], + }; + } + if (modules.size > 0) return; + } + if (declaresSubmodule(callerParsed, callerModule, head, workspaceIndex, index, scopes)) { + yield { crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, ...qualifier] }; + return; } // 3. Crate-root-relative (`a::b::f()` written from a nested module — 2015 @@ -238,11 +310,69 @@ function* candidateModules( // callee the source never names. The base emitted no edge at all, and per the // doctrine quoted in `ids.ts` that is the correct failure direction: a missing // edge is recoverable, a fabricated caller silently misleads `impact`. - if (callerModule.segments.length > 0 && !headBoundLocally(callerParsed, head, scopes)) { + if ( + claim.status === 'absent' && + callerModule.segments.length > 0 && + !headBoundLocally(callerParsed, head, scopes) + ) { yield { crateRoot: callerModule.crateRoot, segments: [...qualifier] }; } } +/** Modules selected by the nearest lexical type-namespace import. */ +function* importedModulesNamed( + name: string | undefined, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + index: RustModuleIndex, + inScope: ScopeId, +): Generator { + if (name === undefined) return; + for (const edge of lookupNameClaim(inScope, name, scopes, { purpose: 'type' }).imports) { + if (edge.localName !== name) continue; + const importedModule = moduleOfImport(edge, scopes, workspaceIndex, index); + if (importedModule !== undefined) yield importedModule; + } +} + +/** + * The module a `use` binding names, or `undefined` when it names a symbol. + * + * Path-based import resolution lands on the module's own file + * (`use crate::tools;` → `tools.rs`). Cargo-aware resolution instead binds the + * module's DECLARATION — the `Namespace` def `mod tools;` leaves in its parent's + * file — so the module is the parent's path plus the def's name. Only a + * declaration bound at its file's top level (or carrying its enclosing path in + * `namespacePrefix`) is read this way; a nested inline `mod` def records no + * enclosing path, and guessing one could name the wrong module. + */ +function moduleOfImport( + edge: { + readonly targetFile: string | null; + readonly targetExportedName: string; + readonly targetDefId?: string; + }, + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + index: RustModuleIndex, +): RustModule | undefined { + const declaration = + edge.targetDefId === undefined ? undefined : scopes.defs.get(edge.targetDefId); + if (declaration !== undefined && declaration.type !== 'Namespace') return undefined; + if (declaration?.type === 'Namespace' && declaration.qualifiedName !== undefined) { + const name = tailSegment(declaration.qualifiedName); + if (!isModuleLevelMember(declaration, name, workspaceIndex)) return undefined; + const parent = moduleOfDef(declaration.filePath, declaration.namespacePrefix, index); + return parent === undefined + ? undefined + : { crateRoot: parent.crateRoot, segments: [...parent.segments, name] }; + } + if (edge.targetFile === null) return undefined; + const importedModule = moduleOfFile(edge.targetFile, index); + if (importedModule === undefined) return undefined; + return importNamesModule(edge.targetExportedName, importedModule) ? importedModule : undefined; +} + /** * Is `name` bound in the caller's own module to anything that is NOT a module? * @@ -252,7 +382,7 @@ function* candidateModules( * case, which candidate 2 already handles, is not double-counted here. */ function headBoundLocally( - callerParsed: ParsedFile, + callerParsed: RustCaller, name: string | undefined, scopes: ScopeResolutionIndexes, ): boolean { @@ -289,7 +419,7 @@ function tailSegment(qualifiedName: string): string { * contributes nothing and cannot outrank a real `use` binding (#2741 review). */ function declaresSubmodule( - callerParsed: ParsedFile, + callerParsed: RustCaller, callerModule: RustModule, name: string, workspaceIndex: WorkspaceResolutionIndex, diff --git a/gitnexus/src/core/ingestion/languages/rust/query.ts b/gitnexus/src/core/ingestion/languages/rust/query.ts index 02b9dde2a3..d57fe0208f 100644 --- a/gitnexus/src/core/ingestion/languages/rust/query.ts +++ b/gitnexus/src/core/ingestion/languages/rust/query.ts @@ -13,6 +13,7 @@ const RUST_SCOPE_QUERY = ` (function_signature_item) @scope.function (closure_expression) @scope.function (block) @scope.block +(type_item) @scope.block (if_expression) @scope.block (match_expression) @scope.block (match_arm) @scope.block diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index f1cab711eb..d90cce9d5c 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -1,4 +1,4 @@ -import type { ParsedFile } from 'gitnexus-shared'; +import type { ParsedFile, TypeRef } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; @@ -6,7 +6,7 @@ import { indexOnlyElementType } from '../../type-extractors/shared.js'; 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 { resolveRustQualifiedCallee, resolveRustQualifiedFreeCall } from './qualified-call.js'; import { populateRustRangeBindings } from './range-binding.js'; import { isClassLike, @@ -170,6 +170,33 @@ export const rustScopeResolver: ScopeResolver = { resolveQualifiedFreeCall: (site, callerParsed, scopes, workspaceIndex, allFilePaths) => resolveRustQualifiedFreeCall(site, callerParsed, scopes, workspaceIndex, allFilePaths), + resolveQualifiedCallee: resolveRustQualifiedCallee, + + // A qualified initializer may name either a module factory or an associated + // function. Restore the constructor's type only after lexical type ownership + // is known, before imported-return propagation follows the remaining paths. + populateNamespaceSiblings: (parsedFiles, scopes) => { + for (const parsed of parsedFiles) { + for (const scope of parsed.scopes) { + for (const [name, ref] of scope.typeBindings) { + if (ref.source !== 'constructor-inferred') continue; + const separator = ref.rawName.lastIndexOf('::'); + if (separator < 1) continue; + const owner = ref.rawName.slice(0, separator); + if ( + findClassBindingInScope(ref.declaredAtScope, owner, scopes, undefined, { + uniqueQualifiedNameFallback: false, + purpose: 'type', + position: ref.lookupPosition ?? ref.bindingRange, + }) !== undefined + ) { + (scope.typeBindings as Map).set(name, { ...ref, rawName: owner }); + } + } + } + } + }, + buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup), emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes, recordTypeArguments) => 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 7006774ae6..22d447a555 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -1469,6 +1469,39 @@ export interface ScopeResolver { allFilePaths: ReadonlySet, ) => SymbolDefinition | undefined; + /** + * Optional resolver for a qualified CALLEE TEXT whose path syntax only the + * language can read — Rust `rp::make` in `let x = rp::make(); x.m()` and in + * the inline receiver `rp::make().m()`, where `rp` names a module rather than + * a value or a type (#3498). + * + * The shared walkers never split this text. They hand it over verbatim, with + * the scope the call sits in, and only after their own lookups found nothing: + * - `followChainPostFinalize`, for a typeBinding rawName no scope binds (so + * the provider's call-return binding must keep the whole callee path); + * - the compound-receiver call branch, for an undotted callee no scope binds; + * - the receiver-bound drop recorder, so a receiver bound to an in-program + * callable with no return annotation records a `receiver-unresolved` drop + * instead of passing as an exact miss. + * + * Return the callable the path names, or `undefined` when the text is not a + * qualified path in this language, names nothing in the workspace, or is + * ambiguous. The shared side reads that callable's `return-annotation` type + * fact by definition identity, so a callable without one types nothing. + * Refusal must be cheap: the hook is asked about every name these sites fail + * to resolve. + * + * Languages whose qualified callees are dotted member expressions covered by + * namespace imports (`ns.make()`) leave this undefined. + */ + readonly resolveQualifiedCallee?: ( + calleeText: string, + inScope: ScopeId, + 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 / @@ -1706,8 +1739,8 @@ export interface ScopeResolver { ) => ArityVerdict; /** - * Optional post-finalize hook to inject cross-file bindings that - * aren't modeled via explicit imports. Runs after + * Optional post-finalize hook to complete provider-owned namespace and type + * facts that need finalized lexical ownership. Runs after * `buildWorkspaceResolutionIndex` and before * `propagateImportedReturnTypes`. * @@ -1715,8 +1748,10 @@ export interface ScopeResolver { * makes names visible across files without a syntactic import — * for example a shared-namespace convention where types declared * in the same namespace see each other without a `using` / `import` - * statement. Languages that require explicit imports for cross-file - * visibility leave this undefined. + * statement. The hook may also refine existing scope type bindings before + * return propagation, such as distinguishing a qualified constructor from a + * namespace factory. Add visible bindings through bindingAugmentations; + * finalized indexes.bindings remains immutable. */ readonly populateNamespaceSiblings?: ( parsedFiles: readonly ParsedFile[], diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index 03ea9b4415..7d36f7c379 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -49,6 +49,7 @@ import type { } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; +import type { ScopeResolver } from '../contract/scope-resolver.js'; import { definitionIdPosition } from '../utils/definition-id.js'; import { followedStep } from '../../utils/type-ref-chain.js'; import { @@ -241,6 +242,48 @@ export function namespaceCallReturnType( : undefined; } +/** `ScopeResolver.resolveQualifiedCallee` bound to one resolution pass. */ +export type QualifiedCalleeResolver = ( + calleeText: string, + inScope: ScopeId, +) => SymbolDefinition | undefined; + +/** Bind the provider's qualified-callee hook to this pass's indexes, or + * `undefined` when the language has none. Reuse the pipeline's file-path set + * when supplied; standalone callers build one lazily. */ +export function bindQualifiedCalleeResolver( + hook: ScopeResolver['resolveQualifiedCallee'], + scopes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, + parsedFiles: readonly ParsedFile[], + filePaths?: ReadonlySet, +): QualifiedCalleeResolver | undefined { + if (hook === undefined) return undefined; + let allFilePaths = filePaths; + return (calleeText, inScope) => { + allFilePaths ??= new Set(parsedFiles.map((parsed) => parsed.filePath)); + return hook(calleeText, inScope, scopes, workspaceIndex, allFilePaths); + }; +} + +/** + * A callee only the provider can read (`rp::make`, #3498): the return + * annotation of the callable it resolves to, read by definition identity like + * the producer lookup in {@link followChainPostFinalize}. `undefined` when the + * provider resolves nothing or the callable declares no return type. + */ +export function qualifiedCalleeReturnType( + calleeText: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + resolveCallee: QualifiedCalleeResolver | undefined, +): TypeRef | undefined { + const callee = resolveCallee?.(calleeText, inScope); + if (callee === undefined) return undefined; + const returned = producerTypeRef(callee.nodeId, scopes); + return returned?.source === 'return-annotation' ? returned : undefined; +} + /** Walk `ref.rawName` through the scope chain's typeBindings looking * for a terminal class-like rawName. Mirrors the in-extractor * `followChainedRef` but operates on post-finalize Scope objects so @@ -250,6 +293,7 @@ export function followChainPostFinalize( start: TypeRef, fromScopeId: ScopeId, scopes: ScopeResolutionIndexes, + resolveCallee?: QualifiedCalleeResolver, ): TypeRef { let current = start; const visited = new Set(); @@ -318,6 +362,10 @@ export function followChainPostFinalize( const ws = scopes.workspaceTypeBindings?.get(current.rawName); if (ws !== undefined && ws !== current) next = ws; } + // Last, a callee path no scope binds (`rp::make`): the provider names the + // callable, and its return annotation continues the chain (#3498). + if (next === undefined && claim.status === 'absent') + next = qualifiedCalleeReturnType(current.rawName, lookupScope, scopes, resolveCallee); if (next === undefined) return current; if (visited.has(next.rawName)) return current; visited.add(next.rawName); @@ -346,8 +394,17 @@ export function propagateImportedReturnTypes( parsedFiles: readonly ParsedFile[], indexes: ScopeResolutionIndexes, index: WorkspaceResolutionIndex, + resolveQualifiedCallee?: ScopeResolver['resolveQualifiedCallee'], + allFilePaths?: ReadonlySet, ): void { const moduleScopeByFile = index.moduleScopeByFile; + const resolveCallee = bindQualifiedCalleeResolver( + resolveQualifiedCallee, + indexes, + index, + parsedFiles, + allFilePaths, + ); const scopesByFile = new Map(parsedFiles.map((file) => [file.filePath, file.scopes])); // Walk SCCs in reverse-topological order (`indexes.sccs` is leaves- @@ -401,7 +458,12 @@ export function propagateImportedReturnTypes( // Chain-follow inside the source module so we mirror the // terminal type, not an intermediate intra-source reference. - const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes); + const terminal = followChainPostFinalize( + sourceTypeRef, + sourceModule.id, + indexes, + resolveCallee, + ); // Mutating typeBindings is safe because draftToScope // produced a non-frozen Map (Contract Invariant I3/I8). @@ -423,7 +485,7 @@ export function propagateImportedReturnTypes( // later (closer-to-root) SCC will see x's terminal type rather // than an intra-module call ref. for (const [name, ref] of importerModule.typeBindings) { - const resolved = followChainPostFinalize(ref, importerModule.id, indexes); + const resolved = followChainPostFinalize(ref, importerModule.id, indexes, resolveCallee); if (resolved !== ref) { (importerModule.typeBindings as Map).set(name, resolved); } @@ -439,7 +501,7 @@ export function propagateImportedReturnTypes( for (const scope of parsed.scopes) { if (scope.id === moduleScopeId) continue; for (const [name, ref] of scope.typeBindings) { - const resolved = followChainPostFinalize(ref, scope.id, indexes); + const resolved = followChainPostFinalize(ref, scope.id, indexes, resolveCallee); if (resolved !== ref) { (scope.typeBindings as Map).set(name, resolved); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index aa3d37899b..a8c9ce2fef 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -98,6 +98,7 @@ import { type CalleeIdCaptureCtx, } from '../graph-bridge/edges.js'; import { constructionSiteReason } from './free-call-fallback.js'; +import { bindQualifiedCalleeResolver } from './imported-return-types.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; import { classOfQualifiedTypeSpelling, @@ -215,6 +216,7 @@ type ReceiverBoundProviderSubset = Pick< | 'isBareClassReceiverCandidate' | 'importsBindAtLexicalScope' | 'resolveNamespaceChains' + | 'resolveQualifiedCallee' | 'resolveQualifiedTypeSpellings' | 'splitQualifiedCallee' >; @@ -417,6 +419,8 @@ export function emitReceiverBoundCalls( index: WorkspaceResolutionIndex, model: SemanticModel, options: { + /** Stable pass identity for provider indexes shared with return propagation. */ + readonly allFilePaths?: ReadonlySet; readonly recordResolutionOutcome?: ResolutionOutcomeRecorder; readonly recordSourceBinding?: SourceBindingRecorder; /** Resolved-callee-id capture sink (#2227 U2). Threaded in under `--pdg` @@ -510,6 +514,17 @@ export function emitReceiverBoundCalls( return def !== undefined && CALLABLE_TYPES.has(def.type); }); }; + const resolveProviderQualifiedCallee = bindQualifiedCalleeResolver( + provider.resolveQualifiedCallee, + scopes, + index, + parsedFiles, + options.allFilePaths, + ); + const bindsQualifiedCallable = (typeRef: TypeRef): boolean => { + const definition = resolveProviderQualifiedCallee?.(typeRef.rawName, typeRef.declaredAtScope); + return definition !== undefined && CALLABLE_TYPES.has(definition.type); + }; // A class-like def NESTED in `owner` (`A.Item` inside `A`): its qualified // name is the owner's plus the segment — the identity the structure phase // and `populateClassOwnedMembers` agree on — so the qualified-name index @@ -653,7 +668,8 @@ export function emitReceiverBoundCalls( const splitQualifiedCallee = provider.splitQualifiedCallee; const resolveQualifiedReceiverMember = provider.resolveQualifiedReceiverMember; const resolveQualifiedCallee = - splitQualifiedCallee === undefined || resolveQualifiedReceiverMember === undefined + resolveProviderQualifiedCallee ?? + (splitQualifiedCallee === undefined || resolveQualifiedReceiverMember === undefined ? undefined : ( callee: string, @@ -670,7 +686,7 @@ export function emitReceiverBoundCalls( parsedFiles, ); return def === 'ambiguous' ? undefined : def; - }; + }); const compoundOpts = { fieldFallback, elementTypeOf: provider.elementTypeOf, @@ -3245,7 +3261,8 @@ export function emitReceiverBoundCalls( // could not type (`x = ns.untyped()`) is a drop too, not an exact miss. (typeRef !== undefined && BARE_TYPE_NAME_RE.test(receiverName) && - bindsNamespaceCallable(typeRef, namespaceTargets))) && + (bindsNamespaceCallable(typeRef, namespaceTargets) || + bindsQualifiedCallable(typeRef)))) && provider.isModuleExportAssignment?.(site, scopes, parsed) !== true ) { // Decoded once: both the shape census and the origin classifier read the diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 71e51c3b8d..6f574720f6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -1021,11 +1021,9 @@ export function runScopeResolution( }); logHeapProbe('sr-post-workspaceIndex', `lang=${provider.language}`); - // Cross-file implicit-namespace visibility (C#). Must run before - // propagateImportedReturnTypes so the latter pass sees siblings' - // class bindings when chasing return-type chains across files. - // The hook writes to `bindingAugmentations` only; finalized - // `indexes.bindings` remains immutable post-finalize (I8). + // Complete implicit namespace visibility and refine provider-owned type facts + // before return propagation. New bindings use bindingAugmentations; existing + // scope typeBindings may be refined. indexes.bindings stays immutable (I8). if (provider.populateNamespaceSiblings !== undefined) { provider.populateNamespaceSiblings(parsedFiles, indexes, { fileContents: getFileContents(), @@ -1049,7 +1047,13 @@ export function runScopeResolution( // with chain-follow per importer; quadratic regressions show up // here, not in finalize). if (provider.propagatesReturnTypesAcrossImports !== false) { - propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex); + propagateImportedReturnTypes( + parsedFiles, + indexes, + workspaceIndex, + provider.resolveQualifiedCallee, + allFilePaths, + ); } const tRangeBindStart = PROF ? process.hrtime.bigint() : 0n; @@ -1245,6 +1249,7 @@ export function runScopeResolution( { recordResolutionOutcome, recordSourceBinding, + allFilePaths, calleeIdSink: calleeIdAccumulator, // The pass's only source of positive EXTERNAL evidence for a dropped // receiver (`console.log`, `fetch(...)`). Same hook, same spelling as @@ -1349,6 +1354,7 @@ export function runScopeResolution( calleeIdSink: calleeIdAccumulator, recordSourceBinding, isBuiltInName: provider.languageProvider.isBuiltInName, + allFilePaths, heritageTypeArguments, unresolvedInheritanceByClass, }, diff --git a/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/Cargo.toml b/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/Cargo.toml new file mode 100644 index 0000000000..4d0c1980b1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "rust-qualified-factory-return" +version = "0.1.0" +edition = "2021" diff --git a/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/main.rs new file mode 100644 index 0000000000..37efd9ac49 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/main.rs @@ -0,0 +1,51 @@ +mod modx; +mod shadow; + +use crate::modx as rp; +use crate::modx::Other; + +// Module-factory bindings below are `C`; type-qualified and untyped cases are controls. +fn alias_call() -> i32 { + let x = rp::make(); + x.m() +} + +fn module_call() -> i32 { + let x = modx::make(); + x.m() +} + +fn crate_call() -> i32 { + let x = crate::modx::make(); + x.m() +} + +fn inline_alias() -> i32 { + rp::make().m() +} + +fn inline_module() -> i32 { + modx::make().m() +} + +fn inline_crate() -> i32 { + crate::modx::make().m() +} + +// A type-qualified call keeps naming the type: `Other::make()` is `Other`'s +// associated function, not the module-level `make`. +fn type_qualified() -> i32 { + let x = Other::make(); + x.m() +} + +fn untyped_alias() -> i32 { + let x = rp::untyped(); + x.m() +} + +fn untyped_inline() -> i32 { + modx::untyped().m() +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/modx.rs b/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/modx.rs new file mode 100644 index 0000000000..0f7c9faa60 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/modx.rs @@ -0,0 +1,27 @@ +pub struct C; + +impl C { + pub fn m(&self) -> i32 { + 1 + } +} + +pub struct Other; + +impl Other { + // Shares its name with the module-level `make` below, but returns `Other`. + pub fn make() -> Other { + Other + } + + pub fn m(&self) -> i32 { + 2 + } +} + +pub fn make() -> C { + C +} + +// No return annotation: nothing in the program says what a call returns. +pub fn untyped() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/shadow.rs b/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/shadow.rs new file mode 100644 index 0000000000..b9a9eaa362 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/shadow.rs @@ -0,0 +1,11 @@ +use crate::modx; +use crate::modx::Other; + +// A path's leading segment names a module in the type namespace, so a `let` +// value named `modx` does not shadow it: `x` is still a `C`, while +// `modx.m()` dispatches on the local `Other`. +pub fn local_shadow() -> i32 { + let modx = Other; + let x = modx::make(); + x.m() + modx.m() +} diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index c4a0506f0a..2154bd4b85 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -651,6 +651,18 @@ "captureGroups": 13, "digest": "84d0e3980d4f725f8e0831b753a2570ddace613ebf6847ee1a5e86b9fcd4148b" }, + "rust-qualified-factory-return/src/main.rs": { + "captureGroups": 83, + "digest": "9983071279163d36e0a14bbf913db27942d6e4a634b44b24c5943b4f3fea26b5" + }, + "rust-qualified-factory-return/src/modx.rs": { + "captureGroups": 31, + "digest": "f83d9262f14ed04035419b9ff1a1f4fb6e7189b528474ab9d72a0ebcf09e5845" + }, + "rust-qualified-factory-return/src/shadow.rs": { + "captureGroups": 17, + "digest": "60d8b27fe58f6e18f736a1d66c2a092fe48aaeab1dfdbc689fdb5bb555fb95cc" + }, "rust-qualified-trait/src/main.rs": { "captureGroups": 8, "digest": "8c71cad628449f8c770fffe66f0aa1c33fc84c82de86fdb770887e00a937adcd" diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 7ad1b5e779..f6d5f68dd0 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -9,6 +9,7 @@ import { getRelationships, getNodesByLabel, getNodesByLabelFull, + getResolutionOutcomes, findDanglingEdges, edgeSet, runPipelineFromRepo, @@ -217,6 +218,15 @@ describe('Rust ambiguous symbol resolution', () => { expect(imp.targetFilePath).not.toMatch(/src\/other\//); } }); + + it('types h from services::create_handler() as the imported models::Handler (#3498)', () => { + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'main' && edge.target === 'handle', + ); + expect(calls.map((edge) => edge.rel.targetId)).toEqual([ + 'Function:src/models/handler.rs:Handler.handle#0', + ]); + }); }); describe('Rust call resolution with arity filtering', () => { @@ -3019,3 +3029,83 @@ describe('Rust type-qualified calls outrank a same-named module (#2745 review)', expect(ids).toContain('Function:src/lib.rs:Buffer.with_capacity'); }); }); + +// --------------------------------------------------------------------------- +// Module-qualified factory calls type their result (#3498 follow-up). +// +// `let x = rp::make(); x.m()` bound `x` to `rp`: the call-return rule kept only +// the text before the first `::` (the `Foo::new()` idiom), so the function name +// was gone and `make`'s declared `-> C` was never read. The aliased module path +// `rp::make()` also failed to resolve as a call, because `rp` names no module +// file. +// --------------------------------------------------------------------------- + +describe('Rust module-qualified factory result types (#3498 follow-up)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-qualified-factory-return'), + () => {}, + ); + }, 60000); + + const callsFrom = (source: string, target: string) => + getRelationships(result, 'CALLS') + .filter((edge) => edge.source === source && edge.target === target) + .map((edge) => edge.rel.targetId) + .sort(); + + it.each([ + 'alias_call', + 'module_call', + 'crate_call', + 'inline_alias', + 'inline_module', + 'inline_crate', + ])("types %s from make's return annotation", (source) => { + expect(callsFrom(source, 'm')).toEqual(['Function:src/modx.rs:C.m#0']); + }); + + it('does not let a let-bound value shadow the module segment of a path', () => { + expect(callsFrom('local_shadow', 'm')).toEqual([ + 'Function:src/modx.rs:C.m#0', + 'Function:src/modx.rs:Other.m#0', + ]); + }); + + it('keeps reading a type-qualified call as the type', () => { + expect(callsFrom('type_qualified', 'm')).toEqual(['Function:src/modx.rs:Other.m#0']); + }); + + it.each(['untyped_alias', 'untyped_inline'])( + 'does not guess a receiver type for %s', + (source) => { + expect(callsFrom(source, 'm')).toEqual([]); + }, + ); + + it.each([ + ['alias_call', 'make'], + ['inline_alias', 'make'], + ['untyped_alias', 'untyped'], + ])('resolves the aliased module path in %s to modx::%s', (source, target) => { + const edges = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === source && edge.target === target, + ); + expect(edges.map((edge) => [edge.rel.targetId, edge.rel.reason])).toEqual([ + [`Function:src/modx.rs:${target}`, 'import-resolved'], + ]); + }); + + it('records the untyped receivers as dropped sites instead of an exact miss', () => { + const dropped = getResolutionOutcomes(result) + .filter((outcome) => outcome.kind === 'suppressed') + .filter( + (outcome) => outcome.reason === 'receiver-unresolved' && outcome.filePath === 'src/main.rs', + ) + .map((outcome) => `${outcome.range.startLine}:${outcome.siteKind}:${outcome.name}`) + .sort(); + expect(dropped).toEqual(['44:call:m', '44:read:m', '48:call:m', '48:read:m']); + }); +}); diff --git a/gitnexus/test/integration/rust-qualified-factory-boundaries.test.ts b/gitnexus/test/integration/rust-qualified-factory-boundaries.test.ts new file mode 100644 index 0000000000..b08b141d52 --- /dev/null +++ b/gitnexus/test/integration/rust-qualified-factory-boundaries.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { TransitionalScopeTree } from '../../src/storage/scope-index-store.js'; + +describe('Rust qualified factory boundaries', () => { + it('keeps an alias type parameter out of the enclosing module', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-alias-parameter-')); + try { + await fs.writeFile( + path.join(root, 'main.rs'), + 'mod T { pub struct Product; impl Product { pub fn m(&self) {} } pub fn make() -> Product { Product } }\ntype Alias = Option;\nfn caller() { let x = T::make(); x.m(); }\n', + ); + const result = await runPipelineFromRepo(root, () => {}, {}); + const calls = [...result.graph.iterRelationships()].filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === 'caller', + ); + expect( + calls.map((edge) => result.graph.getNode(edge.targetId)?.properties.name).sort(), + ).toEqual(['m', 'make']); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, 120000); + it('retains the outer unresolved call when its member repeats the inner callee', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-qualified-repeat-')); + try { + await fs.writeFile( + path.join(root, 'main.rs'), + 'mod factory { pub fn untyped() {} }\nfn caller() { factory::untyped().untyped(); }\n', + ); + const result = await runPipelineFromRepo(root, () => {}, {}); + const drops = result.resolutionOutcomes.filter( + (outcome) => + outcome.kind === 'suppressed' && + outcome.reason === 'receiver-unresolved' && + outcome.siteKind === 'call' && + outcome.name === 'untyped', + ); + expect(drops).toHaveLength(1); + const calls = [...result.graph.iterRelationships()].filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === 'caller' && + result.graph.getNode(edge.targetId)?.properties.name === 'untyped', + ); + expect(calls).toHaveLength(1); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, 120000); + + it('resolves a cold inline-only module after sealing disk scopes', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-qualified-disk-')); + const store = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-qualified-store-')); + const previous = process.env.GITNEXUS_DISK_SCOPE_INDEX; + const original = TransitionalScopeTree.prototype.seal; + let seals = 0; + TransitionalScopeTree.prototype.seal = function (...args: Parameters) { + seals++; + return original.apply(this, args); + }; + try { + process.env.GITNEXUS_DISK_SCOPE_INDEX = '1'; + await fs.writeFile( + path.join(root, 'main.rs'), + 'mod factory { pub fn untyped() {} }\nfn caller() { factory::untyped().m(); }\n', + ); + const result = await runPipelineFromRepo(root, () => {}, { parsedFileStorePath: store }); + expect(seals).toBe(1); + expect( + [...result.graph.iterRelationships()].filter( + (edge) => + edge.type === 'CALLS' && result.graph.getNode(edge.targetId)?.properties.name === 'm', + ), + ).toEqual([]); + expect( + result.resolutionOutcomes.some( + (outcome) => + outcome.kind === 'suppressed' && + outcome.reason === 'receiver-unresolved' && + outcome.siteKind === 'call' && + outcome.name === 'm', + ), + ).toBe(true); + } finally { + TransitionalScopeTree.prototype.seal = original; + if (previous === undefined) delete process.env.GITNEXUS_DISK_SCOPE_INDEX; + else process.env.GITNEXUS_DISK_SCOPE_INDEX = previous; + await fs.rm(root, { recursive: true, force: true }); + await fs.rm(store, { recursive: true, force: true }); + } + }, 120000); +}); diff --git a/gitnexus/test/integration/rust-qualified-factory-collisions.test.ts b/gitnexus/test/integration/rust-qualified-factory-collisions.test.ts new file mode 100644 index 0000000000..3a0a64dd03 --- /dev/null +++ b/gitnexus/test/integration/rust-qualified-factory-collisions.test.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +describe('Rust qualified factory lexical and declaration identity', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-factory-collisions-')); + await fs.mkdir(path.join(root, 'src')); + await fs.writeFile( + path.join(root, 'Cargo.toml'), + '[package]\nname="collisions"\nversion="0.1.0"\nedition="2021"\n', + ); + for (const [file, name] of [ + ['a', 'A'], + ['b', 'B'], + ]) + await fs.writeFile( + path.join(root, `src/${file}.rs`), + `pub struct ${name}; impl ${name} { pub fn m(&self) {} } pub fn make() -> ${name} { ${name} }`, + ); + await fs.writeFile( + path.join(root, 'src/model.rs'), + `#[allow(non_camel_case_types)] pub struct model; impl model { pub fn make() -> Self { Self } pub fn m(&self) {} } pub struct Wrong; impl Wrong { pub fn m(&self) {} } pub fn make() -> Wrong { Wrong }`, + ); + await fs.writeFile( + path.join(root, 'src/main.rs'), + ` +mod a; mod b; mod model; +use crate::a as factory; +use crate::model::model as Maker; +fn scoped_import() { use crate::b as factory; factory::make().m(); } +fn type_import() { Maker::make().m(); } +`, + ); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + const targets = (caller: string, name: string) => + [...result.graph.iterRelationships()] + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + result.graph.getNode(edge.targetId)?.properties.name === name, + ) + .map((edge) => edge.targetId); + it('uses the nearest module import', () => { + expect(targets('scoped_import', 'm')).toEqual(['Function:src/b.rs:B.m#0']); + }); + it('does not reinterpret a resolved type import as its module', () => { + expect(targets('type_import', 'm')).not.toContain('Function:src/model.rs:Wrong.m#0'); + expect(targets('type_import', 'make')).not.toContain('Function:src/model.rs:make'); + }); +}); diff --git a/gitnexus/test/integration/rust-qualified-factory-spellings.test.ts b/gitnexus/test/integration/rust-qualified-factory-spellings.test.ts new file mode 100644 index 0000000000..14afe393e2 --- /dev/null +++ b/gitnexus/test/integration/rust-qualified-factory-spellings.test.ts @@ -0,0 +1,65 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +describe('Rust qualified factory spelling follows declarations', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-factory-spellings-')); + await fs.mkdir(path.join(root, 'src')); + await fs.writeFile( + path.join(root, 'Cargo.toml'), + '[package]\nname="spellings"\nversion="0.1.0"\nedition="2021"\n', + ); + await fs.writeFile( + path.join(root, 'src/Factory.rs'), + 'pub struct Upper; impl Upper { pub fn m(&self) {} } pub fn make() -> Upper { Upper }', + ); + await fs.writeFile( + path.join(root, 'src/módulo.rs'), + 'pub struct Unicode; impl Unicode { pub fn m(&self) {} } pub fn hacer() -> Unicode { Unicode } pub fn créer() -> Unicode { Unicode }', + ); + await fs.writeFile( + path.join(root, 'src/main.rs'), + ` +mod Factory; +mod módulo; +#[allow(non_camel_case_types)] struct lowercase_type; +impl lowercase_type { fn new() -> Self { Self } fn m(&self) {} } +#[derive(Default)] #[allow(non_camel_case_types)] struct derived_type; +impl derived_type { fn m(&self) {} } +struct Decoy; impl Decoy { fn m(&self) {} } +fn lower_type() { let x = lowercase_type::new(); x.m(); } +fn derived_default() { let x = derived_type::default(); x.m(); } +fn upper_module() { let x = Factory::make(); x.m(); } +fn unicode_module() { let x = módulo::hacer(); x.m(); } +fn unicode_function() { let x = módulo::créer(); x.m(); } +`, + ); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + it.each([ + ['lower_type', 'Function:src/main.rs:lowercase_type.m#0'], + ['derived_default', 'Function:src/main.rs:derived_type.m#0'], + ['upper_module', 'Function:src/Factory.rs:Upper.m#0'], + ['unicode_module', 'Function:src/módulo.rs:Unicode.m#0'], + ['unicode_function', 'Function:src/módulo.rs:Unicode.m#0'], + ])('%s resolves its declared receiver', (caller, target) => { + expect( + [...result.graph.iterRelationships()] + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + result.graph.getNode(edge.targetId)?.properties.name === 'm', + ) + .map((edge) => edge.targetId), + ).toEqual([target]); + }); +}); diff --git a/gitnexus/test/integration/rust-qualified-type-shadowing.test.ts b/gitnexus/test/integration/rust-qualified-type-shadowing.test.ts new file mode 100644 index 0000000000..3c7a2abba6 --- /dev/null +++ b/gitnexus/test/integration/rust-qualified-type-shadowing.test.ts @@ -0,0 +1,55 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +describe('Rust qualified factory type shadows', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-factory-type-shadow-')); + await fs.writeFile( + path.join(root, 'main.rs'), + ` +mod factory { pub struct Wrong; impl Wrong { pub fn m(&self) {} } pub fn make() -> Wrong { Wrong } } +mod módulo { pub struct UnicodeWrong; impl UnicodeWrong { pub fn m(&self) {} } pub fn make() -> UnicodeWrong { UnicodeWrong } } +struct Other; impl Other { fn make() -> Self { Self } fn m(&self) {} } +fn type_alias() { type factory = Other; let x = factory::make(); x.m(); } +fn type_parameter() { factory::make().m(); } +fn const_parameter() { factory::make().m(); } +fn unicode_type_parameter() { módulo::make().m(); } +struct ConstHolder; +impl ConstHolder { fn struct_const_parameter() { factory::make().m(); } } +`, + ); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + const targets = (caller: string) => + [...result.graph.iterRelationships()] + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + result.graph.getNode(edge.targetId)?.properties.name === 'm', + ) + .map((edge) => edge.targetId); + it('does not borrow a module through a local type alias', () => { + expect(targets('type_alias')).toEqual([]); + }); + it('does not borrow a module through a type parameter', () => { + expect(targets('type_parameter')).toEqual([]); + }); + it('keeps a module path visible beside a const generic value', () => { + expect(targets('const_parameter')).toEqual(['Function:main.rs:factory.Wrong.m#0']); + }); + it('does not borrow a module through a Unicode type parameter', () => { + expect(targets('unicode_type_parameter')).toEqual([]); + }); + it('keeps a module visible beside an impl const generic', () => { + expect(targets('struct_const_parameter')).toEqual(['Function:main.rs:factory.Wrong.m#0']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-call-return-path.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-call-return-path.test.ts new file mode 100644 index 0000000000..f9dbcb8d22 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/rust/rust-call-return-path.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import type { CaptureMatch } from 'gitnexus-shared'; +import { interpretRustTypeBinding } from '../../../../src/core/ingestion/languages/rust/interpret.js'; + +const RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 }; + +/** A minimal `let x = ()` call-return CaptureMatch. */ +function callReturnBinding(callee: string): CaptureMatch { + return { + '@type-binding.name': { name: '@type-binding.name', range: RANGE, text: 'x' }, + '@type-binding.type': { name: '@type-binding.type', range: RANGE, text: callee }, + '@type-binding.call-return': { name: '@type-binding.call-return', range: RANGE, text: '' }, + }; +} + +/** + * Preserve qualified callees until finalized scope ownership distinguishes a + * module factory from an associated function. Self and generic constructors + * keep their existing type spelling (#3498 follow-up). + */ +describe('Rust call-return binding of a qualified callee', () => { + it.each([ + ['Foo::new', 'Foo::new'], + ['Self::new', 'Self'], + ['Vec::::new', 'Vec'], + ['make', 'make'], + ])('reads %s as %s', (callee, expected) => { + expect(interpretRustTypeBinding(callReturnBinding(callee))?.rawTypeName).toBe(expected); + }); + + it.each(['rp::make', 'crate::modx::make', 'self::make', 'super::modx::make', 'a::b::make'])( + 'keeps the module path %s', + (callee) => { + expect(interpretRustTypeBinding(callReturnBinding(callee))?.rawTypeName).toBe(callee); + }, + ); +});