Procházet zdrojové kódy

feat(impact): extract Rocket route-registration macros (routes![]/catchers![])

Rocket mounts handlers via `routes![a::b::handler, …]` / `catchers![…]` macros.
Tree-sitter leaves the macro body as a raw token tree, so the handler paths were
invisible — each handler looked uncalled (Rocket mounts it at runtime, not from
in-repo code) and its file showed no dependents. Walk the token tree,
reconstruct each comma-separated path, and emit a `references` edge; the Rust
path resolver links it to the handler fn. The handler names are explicit in
source, so this is precise static extraction, not a heuristic — resolution still
validates each path, so no false edges.

Rocket realworld 68.8% -> 93.8% (all 4 route-handler modules now covered; only
the crate-root lib.rs remains, a see-through root). No regression on
axum/actix/ripgrep/tokio; cross-family false edges still 0; node count unchanged
(edges only). Regression test fails without the extractor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry před 2 týdny
rodič
revize
6d214cd
3 změnil soubory, kde provedl 90 přidání a 0 odebrání
  1. 1 0
      CHANGELOG.md
  2. 28 0
      __tests__/extraction.test.ts
  3. 61 0
      src/extraction/tree-sitter.ts

+ 1 - 0
CHANGELOG.md

@@ -27,6 +27,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - 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. 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)
+- Rocket route handlers now connect to where they're mounted. A handler registered in a `routes![a::b::handler, …]` or `catchers![…]` macro used to be invisible — the macro body is a raw token tree, so the handler looked like it had no caller (Rocket mounts it at runtime) and its file showed no dependents. The handler paths are now read out of the macro and linked to the `mount`/`register` call, so editing a Rocket handler surfaces its route registration and a routes module is no longer reported as unused. (Rust, Rocket)
 - 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)

+ 28 - 0
__tests__/extraction.test.ts

@@ -4287,6 +4287,34 @@ describe('Rust module-path call resolution', () => {
     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);
   });
+
+  it('Rocket `routes![…]` / `catchers![…]` macros link the mount to the handler fns', async () => {
+    // Tree-sitter leaves the macro body as a raw token tree, so the handler
+    // paths inside `routes![a::b::handler, …]` are invisible to the call walker
+    // and the handlers — mounted by Rocket at runtime, not called in-repo — look
+    // like they have no caller. The route-macro extractor reconstructs each path
+    // and emits a reference, which the Rust path resolver links to the handler.
+    const routes = path.join(tempDir, 'src/routes');
+    fs.mkdirSync(routes, { recursive: true });
+    fs.writeFileSync(path.join(tempDir, 'src/lib.rs'),
+      `mod routes;\nfn not_found() {}\npub fn rocket() {\n` +
+      `    rocket::build()\n` +
+      `        .mount("/api", routes![routes::users::post_users, routes::users::get_user])\n` +
+      `        .register("/", catchers![not_found]);\n}\n`);
+    fs.writeFileSync(path.join(routes, 'mod.rs'), `pub mod users;\n`);
+    fs.writeFileSync(path.join(routes, 'users.rs'), `pub fn post_users() {}\npub fn get_user() {}\n`);
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const handlers = cg.getNodesByKind('function').filter((n) => n.filePath.endsWith('routes/users.rs'));
+    expect(handlers.length, 'both handler fns indexed').toBe(2);
+    for (const h of handlers) {
+      const deps = [...cg.getImpactRadius(h.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+      expect(deps.some((p) => p.endsWith('lib.rs')), `routes![] links ${h.name} to its mount in lib.rs`).toBe(true);
+    }
+  });
 });
 
 describe('Objective-C messages, class receivers, and #import', () => {

+ 61 - 0
src/extraction/tree-sitter.ts

@@ -2746,12 +2746,73 @@ export class TreeSitterExtractor {
    *      tree-sitter to interpret the namespace block as a function_definition,
    *      hiding real class/struct/enum nodes inside the "function body".
    */
+  /**
+   * Rocket route-registration macros — `routes![a::b::handler, c::d::other]`
+   * and `catchers![not_found]`. Tree-sitter leaves a macro body as a flat
+   * `token_tree` of raw tokens (`identifier`, `::`, `,`), so the handler paths
+   * are never seen as references and each handler fn looks like it has no caller
+   * — it's mounted by Rocket at runtime, not called by in-repo code, so its file
+   * shows 0 dependents. Walk the token tree, reconstruct each comma-separated
+   * path, and emit a `references` edge; the Rust path resolver
+   * (`resolveRustPathReference`) then links it to the handler fn. The handler
+   * names are explicit in source, so this is precise static extraction, not a
+   * heuristic — no false edges (resolution still validates each path).
+   */
+  private extractRustRouteMacro(node: SyntaxNode): void {
+    if (this.language !== 'rust') return;
+    const macroName = node.namedChild(0);
+    if (!macroName) return;
+    const name = getNodeText(macroName, this.source);
+    if (name !== 'routes' && name !== 'catchers') return;
+    const tokenTree = node.namedChildren.find((c: SyntaxNode) => c.type === 'token_tree');
+    if (!tokenTree) return;
+    const fromId = this.nodeStack[this.nodeStack.length - 1];
+    if (!fromId) return;
+
+    // The token tree is a flat stream: `[ id :: id :: id , id … ]`. Group runs
+    // of `identifier` tokens (the `::` joiners are anonymous) into one path; a
+    // `,` (or the closing `]`) ends a path.
+    let parts: string[] = [];
+    let line = 0;
+    let column = 0;
+    const flush = (): void => {
+      if (parts.length > 0) {
+        this.unresolvedReferences.push({
+          fromNodeId: fromId,
+          referenceName: parts.join('::'),
+          referenceKind: 'references',
+          line,
+          column,
+        });
+        parts = [];
+      }
+    };
+    for (let i = 0; i < tokenTree.childCount; i++) {
+      const t = tokenTree.child(i);
+      if (!t) continue;
+      if (t.type === 'identifier') {
+        if (parts.length === 0) {
+          line = t.startPosition.row + 1;
+          column = t.startPosition.column;
+        }
+        parts.push(getNodeText(t, this.source));
+      } else if (t.type === ',') {
+        flush();
+      }
+    }
+    flush();
+  }
+
   private visitFunctionBody(body: SyntaxNode, _functionId: string): void {
     if (!this.extractor) return;
 
     const visitForCallsAndStructure = (node: SyntaxNode): void => {
       const nodeType = node.type;
 
+      // Rocket route-registration macros (`routes![…]` / `catchers![…]`): the
+      // handler paths live in a raw token tree the call walker can't see.
+      if (nodeType === 'macro_invocation') this.extractRustRouteMacro(node);
+
       if (this.extractor!.callTypes.includes(nodeType)) {
         this.extractCall(node);
       } else if (INSTANTIATION_KINDS.has(nodeType)) {