Quellcode durchsuchen

feat(impact): Rust self-relative submodule call resolution (X::router())

A bare Rust path call `submodule::fn()` — the standard router-assembly /
handler-registration pattern (Axum's `users::router()`, etc.) — was resolved
crate-relative only, looking for `src/submodule.rs`. `mod users;` makes `users`
a child of the CURRENT module (2018 `self::`-relative), so a call to a sibling
submodule found nothing and the handler module looked dependent-less. The
resolver now tries self-module-relative FIRST, then falls back to crate-relative;
external-crate paths miss both and fall through to name-matching, unchanged.

realworld-axum-sqlx 72.7% -> 100% fair coverage. Validated no regression on
controls (ripgrep neutral 66/79; tokio +3 -> 260/321); cross-family false edges
still 0. Regression test fails without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry vor 2 Wochen
Ursprung
Commit
a3f59fbd08
3 geänderte Dateien mit 87 neuen und 28 gelöschten Zeilen
  1. 1 0
      CHANGELOG.md
  2. 49 0
      __tests__/extraction.test.ts
  3. 37 28
      src/resolution/import-resolver.ts

+ 1 - 0
CHANGELOG.md

@@ -26,6 +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)
 - 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)

+ 49 - 0
__tests__/extraction.test.ts

@@ -4210,6 +4210,55 @@ describe('Same-directory include + KMP import resolution', () => {
   });
 });
 
+describe('Rust module-path call resolution', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('a bare submodule call (`users::router()`) resolves self-relative to the submodule fn', async () => {
+    // The canonical Axum router-assembly pattern: a parent module calls each
+    // submodule's `router()`. `users::` / `profiles::` are SELF-relative
+    // submodule prefixes (2018 edition) — `mod users;` makes `users` a child of
+    // the CURRENT module, NOT `crate::users`. Before the fix the bare prefix was
+    // resolved crate-relative only (looking for `src/users.rs`), so it found
+    // nothing and the handler modules looked dependent-less.
+    const http = path.join(tempDir, 'src/http');
+    fs.mkdirSync(http, { recursive: true });
+    fs.writeFileSync(path.join(tempDir, 'src/lib.rs'), `pub mod http;\n`);
+    fs.writeFileSync(
+      path.join(http, 'mod.rs'),
+      `mod users;\nmod profiles;\npub fn api_router() {\n    users::router();\n    profiles::router();\n}\n`
+    );
+    fs.writeFileSync(path.join(http, 'users.rs'), `pub fn router() -> i32 { 1 }\n`);
+    fs.writeFileSync(path.join(http, 'profiles.rs'), `pub fn router() -> i32 { 2 }\n`);
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    // Each submodule's same-named `router` fn must get mod.rs as a dependent —
+    // proving the bare prefix resolved self-relative AND disambiguated the
+    // colliding `router` name to the correct file (not an arbitrary one).
+    const routers = cg.getNodesByKind('function').filter((n) => n.name === 'router');
+    const usersRouter = routers.find((n) => n.filePath.endsWith('http/users.rs'));
+    const profilesRouter = routers.find((n) => n.filePath.endsWith('http/profiles.rs'));
+    expect(usersRouter, 'users.rs router fn').toBeDefined();
+    expect(profilesRouter, 'profiles.rs router fn').toBeDefined();
+    const usersDeps = [...cg.getImpactRadius(usersRouter!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    const profilesDeps = [...cg.getImpactRadius(profilesRouter!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    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);
+  });
+});
+
 describe('Objective-C messages, class receivers, and #import', () => {
   let tempDir: string;
   let cg: CodeGraph;

+ 37 - 28
src/resolution/import-resolver.ts

@@ -1485,39 +1485,48 @@ function resolveRustModuleFile(
   const fromAbs = path.join(projectRoot, fromFile);
   const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
 
-  let dir: string | null;
-  let rest: string[];
+  // Walk a sequence of module segments down from `startDir`, mapping each to a
+  // `<seg>.rs` or `<seg>/mod.rs` file. Returns the leaf module's file, or null
+  // if `startDir` is null or any segment has no file on disk.
+  const resolveUnder = (startDir: string | null, rest: string[]): string | null => {
+    if (!startDir) return null;
+    let dir = startDir;
+    let targetFile: string | null = null;
+    for (const seg of rest) {
+      if (seg === 'self' || seg === 'crate' || seg === 'super') continue;
+      const asFile = toRel(path.join(dir, seg + '.rs'));
+      const asMod = toRel(path.join(dir, seg, 'mod.rs'));
+      if (context.fileExists(asFile)) targetFile = asFile;
+      else if (context.fileExists(asMod)) targetFile = asMod;
+      else return null;
+      dir = path.join(dir, seg);
+    }
+    return targetFile;
+  };
+
   const first = segments[0]!;
   if (first === 'crate') {
-    dir = rustCrateRootDir(fromAbs, context);
-    rest = segments.slice(1);
-  } else if (first === 'self') {
-    dir = rustSelfModuleDir(fromAbs);
-    rest = segments.slice(1);
-  } else if (first === 'super') {
+    return resolveUnder(rustCrateRootDir(fromAbs, context), segments.slice(1));
+  }
+  if (first === 'self') {
+    return resolveUnder(rustSelfModuleDir(fromAbs), segments.slice(1));
+  }
+  if (first === 'super') {
     let supers = 0;
     while (segments[supers] === 'super') supers++;
-    dir = rustSelfModuleDir(fromAbs);
-    for (let s = 0; s < supers; s++) dir = path.dirname(dir);
-    rest = segments.slice(supers);
-  } else {
-    // Bare path (2018 edition treats it as crate-relative).
-    dir = rustCrateRootDir(fromAbs, context);
-    rest = segments;
+    let dir: string | null = rustSelfModuleDir(fromAbs);
+    for (let s = 0; s < supers && dir; s++) dir = path.dirname(dir);
+    return resolveUnder(dir, segments.slice(supers));
   }
-  if (!dir) return null;
-
-  let targetFile: string | null = null;
-  for (const seg of rest) {
-    if (seg === 'self' || seg === 'crate' || seg === 'super') continue;
-    const asFile = toRel(path.join(dir, seg + '.rs'));
-    const asMod = toRel(path.join(dir, seg, 'mod.rs'));
-    if (context.fileExists(asFile)) targetFile = asFile;
-    else if (context.fileExists(asMod)) targetFile = asMod;
-    else return null;
-    dir = path.join(dir, seg);
-  }
-  return targetFile;
+  // Bare path. In expression position (`submodule::item()` — the router-assembly
+  // and general cross-module-call pattern) the prefix is a SUBMODULE of the
+  // current module, i.e. 2018 `self::`-relative — so try self-relative FIRST.
+  // Fall back to crate-relative for 2015-edition / crate-root items. External
+  // crate paths (`serde::de::Error`) miss both and fall through to name-matching.
+  return (
+    resolveUnder(rustSelfModuleDir(fromAbs), segments) ??
+    resolveUnder(rustCrateRootDir(fromAbs, context), segments)
+  );
 }
 
 /**