Explorar el Código

feat(impact): resolve multi-segment Rust module-path calls (a::b::c())

The reference-resolver pre-filter (hasAnyPossibleMatch) dropped any `a::b::c`
call whose leaf it never checked: it tested the first segment and the `b::c`
remainder, neither of which names a symbol, so a 3+-segment scoped call was
discarded before reaching the Rust path resolver. 2-segment calls survived
(their `b::c` remainder IS the leaf), which masked the gap. Added a last-`::`
leaf check, mirroring the existing dotted-name branch.

Rocket realworld 62.5% -> 68.8% (database/profiles.rs — reached only via
`database::profiles::find()` inside a `db.run(|c| ...)` closure — now resolves);
additive on axum/ripgrep/tokio (held, no regression); cross-family false edges
still 0. Pure resolution change, node counts unchanged. Regression test fails
without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry hace 2 semanas
padre
commit
7bb958b215
Se han modificado 3 ficheros con 41 adiciones y 1 borrados
  1. 1 1
      CHANGELOG.md
  2. 30 0
      __tests__/extraction.test.ts
  3. 10 0
      src/resolution/index.ts

+ 1 - 1
CHANGELOG.md

@@ -26,7 +26,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript)
 - The same completeness fix now applies to **Python**: a name brought in with `from module import X` is recorded as a dependency on that module even when `X` is only stored in a list/dict, passed as an argument, used as a decorator, or re-exported through an `__init__.py`. Previously Python linked only imports that were called or instantiated, so a module consumed purely by value — or only re-exported — looked like nothing depended on it.
 - Rust impact and `codegraph affected` now connect far more of the module graph. Struct literals (`Widget { n: 1 }`) are recorded as instantiations; a `use` / `pub use` brings its item into the dependency graph — so a `pub use` re-export hub (a `mod.rs` re-exporting its submodules) depends on the modules it re-exports — resolved by Rust module path (`crate::`/`self::`/`super::`), so a re-export of a common name like `read` links to the right module instead of a same-named symbol elsewhere; and trait dispatch reaches implementations — a struct whose methods cover a trait's is treated as implementing it, and a call through `&dyn Trait` resolves to the concrete method. Previously a Rust type linked only when called or used in a type position, so structs built by literal, modules surfaced only through `pub use`, and trait-only implementations looked like they had no dependents. (#584 for Rust traits)
-- Rust cross-module function calls now resolve to the right file. A call to a sibling submodule's function — `users::router()`, the common router-assembly / handler-registration pattern where `mod users;` makes `users` a child of the current module — is now resolved relative to the current module, not only the crate root. Previously such a call resolved to nothing, so a module reached only as `submodule::function()` looked like it had no dependents; a web app wired this way (Axum and similar) now surfaces its handler modules' real callers. (Rust)
+- Rust cross-module function calls now resolve to the right file. A call to a sibling submodule's function — `users::router()`, the common router-assembly / handler-registration pattern where `mod users;` makes `users` a child of the current module — is now resolved relative to the current module, not only the crate root. Deeper module-path calls (`database::profiles::find()` — the `db.run(|c| …)` data-access shape) now resolve too; these were being discarded before resolution even ran, because the path's leaf function name was never checked. Previously such a call linked to nothing, so a module reached only as `module::path::function()` looked like it had no dependents; a web app wired this way (Axum, Rocket, and similar) now surfaces its handler and data-access modules' real callers. (Rust)
 - Swift property wrappers and attributes are now connected. A `@Argument` / `@Published` / `@State` / custom `@propertyWrapper` on a property — and attributes on types, methods, and functions (`@objc`, `@MainActor`, …) — now record a dependency on the wrapper/attribute type. Previously these were dropped entirely (Swift attributes parse differently from other languages, and stored properties weren't being inspected), so the wrapper type looked unused and the file using it depended on nothing — a big gap for SwiftUI and argument-parser-style code.
 - Java annotations are now connected. Annotation definitions (`@interface Foo`) are indexed as types, and every `@Foo` usage on a class, method, or field is recorded as a dependency on it. Previously neither side was captured — annotation usages were dropped (they live inside the declaration's modifiers) and `@interface` types weren't indexed at all — so annotation-driven code (Spring `@GetMapping`, JPA `@Entity`, Gson `@SerializedName`, …) showed the annotation as having no users and the annotated class as not depending on it.
 - Kotlin Multiplatform `expect`/`actual` declarations are now connected. A platform implementation — `actual fun`, `actual class`, or an `actual typealias` in a `jvm` / `native` / `js` / `wasm` source set — is linked to the common `expect` declaration it fulfills (including the common case of an `expect class` fulfilled by an `actual typealias`). Previously a caller in common code resolved to the `expect` declaration, so every platform `actual` looked like it had no dependents and editing one showed an empty blast radius; now changing a platform implementation surfaces the common API and everything that uses it. (Kotlin)

+ 30 - 0
__tests__/extraction.test.ts

@@ -4257,6 +4257,36 @@ describe('Rust module-path call resolution', () => {
     expect(usersDeps.some((p) => p.endsWith('http/mod.rs')), 'users::router() lands on users.rs').toBe(true);
     expect(profilesDeps.some((p) => p.endsWith('http/mod.rs')), 'profiles::router() lands on profiles.rs').toBe(true);
   });
+
+  it('a 3-segment module-path call (`database::profiles::find()`) resolves to the leaf fn', async () => {
+    // A 2-level module path — the common `db.run(move |c| database::profiles::find(c))`
+    // / `crate::a::b::func()` shape. The reference-resolver pre-filter used to drop any
+    // `a::b::c` whose leaf it never checked (it tested only the first segment and the
+    // `b::c` remainder, neither of which names a symbol), so the call never reached the
+    // Rust path resolver and the leaf module looked dependent-less.
+    const routes = path.join(tempDir, 'src/routes');
+    const database = path.join(tempDir, 'src/database');
+    fs.mkdirSync(routes, { recursive: true });
+    fs.mkdirSync(database, { recursive: true });
+    fs.writeFileSync(path.join(tempDir, 'src/lib.rs'), `pub mod routes;\npub mod database;\n`);
+    fs.writeFileSync(path.join(database, 'mod.rs'), `pub mod profiles;\n`);
+    fs.writeFileSync(path.join(database, 'profiles.rs'), `pub fn find(id: i32) -> i32 { id }\n`);
+    fs.writeFileSync(
+      path.join(routes, 'mod.rs'),
+      `use crate::database;\npub fn get_profile(id: i32) -> i32 {\n    database::profiles::find(id)\n}\n`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const find = cg
+      .getNodesByKind('function')
+      .find((n) => n.name === 'find' && n.filePath.endsWith('database/profiles.rs'));
+    expect(find, 'database/profiles.rs find fn').toBeDefined();
+    const deps = [...cg.getImpactRadius(find!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(deps.some((p) => p.endsWith('routes/mod.rs')), 'database::profiles::find() resolves to the leaf fn').toBe(true);
+  });
 });
 
 describe('Objective-C messages, class receivers, and #import', () => {

+ 10 - 0
src/resolution/index.ts

@@ -564,6 +564,16 @@ export class ReferenceResolver {
       const receiver = name.substring(0, colonIdx);
       const member = name.substring(colonIdx + 2);
       if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
+      // Multi-segment path `a::b::c` (a Rust/C++ module call like
+      // `database::profiles::find`) — the only segment that names a symbol is
+      // the last (`c`); `member` above is `b::c`, which never matches a node
+      // name, so without this the pre-filter drops the ref before the Rust path
+      // resolver ever sees it. Mirror the dotted-name leaf check above.
+      const lastColon = name.lastIndexOf('::');
+      if (lastColon > colonIdx) {
+        const tail = name.substring(lastColon + 2);
+        if (tail && this.knownNames.has(tail)) return true;
+      }
     }
 
     // For path-like references (e.g., "snippets/drawer-menu.liquid"), check the filename