Skip to content

fix(core): scope Rust impl methods to their type, dedup class-scoping walk - #72

Open
pradeepmouli wants to merge 1 commit into
intuit:mainfrom
pradeepmouli:upstream-pr/rust-impl-parent-scoping
Open

fix(core): scope Rust impl methods to their type, dedup class-scoping walk#72
pradeepmouli wants to merge 1 commit into
intuit:mainfrom
pradeepmouli:upstream-pr/rust-impl-parent-scoping

Conversation

@pradeepmouli

Copy link
Copy Markdown
Contributor

Summary

tree-sitter-rust's impl_item node has no name field — only body, trait, type, and type_parameters (verified against the crate's real node-types.json, not assumed). find_parent_class's generic child_by_field_name("name") walk silently returns None for it, so every Rust impl method — inherent or trait — fell back to a flat, unscoped file::method id instead of file::Type::method.

Concretely, this meant any two types in the same file with a same-named method (e.g. both implementing Display, or both having a new()) silently collapsed into a single graph symbol, with the second one dropped entirely by store_parquet.rs's sym_seen dedup guard — not just mis-scoped, actually missing from the graph.

Reproduced empirically before fixing: indexing a fixture with struct Alpha (impl Alpha { fn hello(&self) -> String }) and struct Beta (impl Greet for Beta { fn hello(&self) -> String }, identical signature) produced only one hello symbol in the graph — Beta::hello was silently absent.

Fix

  • rust/entities.scm's impl-method pattern now also captures the impl's own type: field as @method.parent (the Self type, e.g. Bar in impl Foo for Bar).
  • entities.rs consumes @method.parent through the same decompose-query mechanism (inherit_decompose_query) that relation extraction already uses for compound INHERITS-edge bases (generic/qualified impl targets) — reused, not duplicated. Falls back to the existing ancestor walk when the capture is absent, following the same capture-with-fallback idiom already established for @func.params/@func.return_type.
  • Along the way, consolidated find_parent_class (entities.rs) and find_enclosing_class (relations.rs), which had drifted into two independently-buggy copies of the same class-scoping walk (one had struct_specifier and the C++ out-of-line-method branch, the other had Elixir's defmodule and Pascal's declClass/declIntf) — now one shared function in extract/mod.rs, used by both.

No schema change; purely additive to entities.scm/entities.rs. Does not touch relations.scm.

Known residual gap (not fixed by this PR)

A single type with two same-named methods from different sources — e.g. an inherent impl and a trait impl of the same type (impl Bar { fn x() {} } + impl SomeTrait for Bar { fn x() {} }) — still collide, since both resolve @method.parent to the same type name. That's a genuine overload-disambiguation problem, not a scoping bug, and needs separate work (tracked in my fork, not part of this PR's scope). The added test test_rust_same_type_inherent_and_trait_impl_method_still_collide documents this explicitly as a known, tracked gap rather than letting it pass silently as "fixed."

Testing

  • Two new regression tests in entities.rs's test module: one confirms the fix (Alpha::hello/Beta::hello now survive as distinct, correctly-scoped symbols), one explicitly documents the known residual gap above.
  • Added tree-sitter-rust as an infigraph-core dev-dependency to hand-build the test's Query directly (mirroring the existing Kotlin/Dart test pattern) — using infigraph_languages::bundled_registry() from inside infigraph-core's own inline tests hits a dev-dependency-cycle type mismatch (two distinct compiled instances of ParserBackend), confirmed via compiler error, not guessed around.
  • Verified independently on a fresh worktree off upstream/main (not reusing my fork's branch results): cargo build --release -p infigraph-cli -p infigraph-mcp succeeds, full workspace cargo test --workspace --lib passes (738 tests, 0 failures), cargo fmt --all -- --check clean.
  • Note on clippy: cargo clippy --all-targets -- -D warnings on bare upstream/main (before this PR's changes) currently fails on an unrelated pre-existing lint in crates/infigraph-core/src/embed/mod.rs (chunks_exact_to_as_chunks) — confirmed this file isn't touched by this PR's diff at all, so it's local-toolchain-vs-CI drift (per this repo's own CLAUDE.md: dtolnay/rust-toolchain@stable floats, so a newer local clippy can surface lints CI's pinned version doesn't), not something this PR introduces or is responsible for fixing.

🤖 Generated with Claude Code

… walk

tree-sitter-rust's impl_item has no "name" field (only body/trait/type/
type_parameters, verified against its real node-types.json), so every
Rust impl method -- inherent or trait -- fell back to a flat, unscoped
file::method id. Two types in the same file with a same-named method
(e.g. both implementing Display, or both having new()) silently
collapsed into one symbol, with the second dropped entirely by
store_parquet.rs's sym_seen dedup guard.

Fix: rust/entities.scm now captures the impl's own `type:` field as
@method.parent; entities.rs consumes it via the same decompose-query
mechanism already used for compound INHERITS-edge bases (generic/
qualified impl targets), falling back to the existing ancestor walk
when absent -- same capture-with-fallback idiom as @func.params/
@func.return_type.

Also consolidates find_parent_class (entities.rs) and
find_enclosing_class (relations.rs), which had drifted into two
independently-buggy copies of the same class-scoping walk (one had
struct_specifier and the C++ out-of-line-method branch, the other had
Elixir's defmodule and Pascal's declClass/declIntf) -- now one shared
function in extract/mod.rs, used by both.

Known residual gap, not fixed here (tracked as
#125): a single type with two same-named methods
from different sources (e.g. an inherent impl and a trait impl of the
same type) still collide, since both resolve to the same parent name.
That needs the separate disambiguation work in #126.

Part of the symbol-identity-and-scoping-hardening spec's Phase 1
(docs/superpowers/specs/2026-08-30-symbol-identity-and-scoping-hardening-design.md).

@murari316 murari316 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review (Fable, adversarially verified). Recommendation: request-changes.

The PR fixes a real bug — Rust impl methods were file-scoped (file::method) because tree-sitter-rust's impl_item has no "name" field, so same-named methods on different types collapsed into one symbol. It adds an explicit @method.parent capture on the impl's type: field, threads a decompose_query through extract_entities to reduce compound types (generics/scoped paths) to a base identifier, and consolidates two independently-drifted walk helpers (find_parent_class/find_enclosing_class) plus resolve_inherit_text into shared extract/mod.rs functions. The core fix and the dedup are sound and none of the repo's DB/watcher/ignore invariants are touched, but the fix introduces an untested ghost-symbol regression: the unanchored standalone (function_item) @func.def pattern still matches impl methods with a flat id, and the new type-scoped id no longer collides with it, so dedup no longer collapses the pair.


[MAJOR] correctness — crates/infigraph-languages/languages/rust/entities.scm:4

The PR introduces a ghost duplicate symbol for Rust impl methods. The standalone, unanchored pattern (function_item name: (identifier) @func.name) @func.def (unchanged by this PR) also matches every function inside an impl block's declaration_list. That match carries no @method.parent capture, so extract_entities falls back to find_parent_class, which walks to impl_item, finds no "name" field (impl_item only has body/trait/type/type_parameters), and returns None — yielding a flat file::method Function symbol. Pre-PR, the @method.def match produced the same flat id and the HashMap dedup (keyed on Symbol.id, entities.rs) collapsed the pair into one symbol; post-PR the @method.def match gets the new scoped file::Type::method id, the ids diverge, and both survive. Net effect: one ghost flat Function symbol per unique impl-method name per file, alongside the correctly scoped Method symbols — polluting search results, get_symbols_in_file, dead-code detection (the ghost has no incoming edges), and name-based call resolution which may bind to the flat id. Suggested fix (two halves, both needed): (1) in the consolidated find_parent_class (extract/mod.rs), resolve impl_item via child_by_field_name("type") (run through resolve_compound_node_text or take the base identifier) so the @func.def match gets the same scoped id — this also fixes Rust self. receiver resolution in relations.rs for free; (2) add a Function→Method kind upgrade to the dedup's and_modify (mirroring the existing Function→Test rule), otherwise the merged symbol keeps whichever kind was inserted first and may stay Function. Do NOT fix by anchoring the func pattern to source_file — that would stop matching mod-nested free functions.

Failure scenario: Index any Rust repo (including infigraph itself) with the PR applied. For impl Alpha { fn hello() {} } and impl Greet for Beta { fn hello() {} } in src/main.rs, extraction now emits THREE symbols named hello: src/main.rs::Alpha::hello (Method), src/main.rs::Beta::hello (Method), and a ghost src/main.rs::hello (Function, spanning one of the two method bodies). detect_dead_code flags the ghost as unreferenced; symbol counts and search results inflate for every impl method in every indexed Rust file.

[MAJOR] test-coverage — crates/infigraph-core/src/extract/entities.rs:1836

The regression test structurally cannot catch the ghost-duplicate bug above, because rust_entity_query() hand-builds a query containing ONLY the impl-method pattern — the shipped rust/entities.scm's standalone (function_item) @func.def pattern (whose interaction with the new scoped ids causes the regression) is absent, so the test filters on kind==Method and sees exactly the 2 symbols it expects. It also drifts silently if the shipped .scm changes. The helper's own doc comment cites a dev-dependency cycle as the reason, but the .scm files are plain text: include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../infigraph-languages/languages/rust/entities.scm")) (and same for inherit_decompose.scm) loads the real shipped queries with no crate dependency at all. Fix: build the Query from the include_str! text, and add an assertion that NO flat src/main.rs::hello symbol exists (any kind). Additionally, the test's outer doc comment contradicts the code — it claims "Uses the real bundled Rust language pack (entities.scm + inherit_decompose_query), not a hand-rolled query" while the helper comment two lines later says "this test hand-builds the query rather than loading the registry"; the first claim is false and should be corrected regardless.

Failure scenario: CI passes (both new tests green) while every real Rust indexing run through extract_file — which compiles the full entities.scm including the standalone function pattern — emits the ghost flat Function symbols described in finding 1. The exact scenario the PR fixes is only half-tested: distinct scoped ids are asserted, absence of the old flat id is not.

[NIT] documentation — crates/infigraph-core/src/extract/entities.rs:221

The comment above the parent resolution says "Prefer an explicit @method.parent/@func.parent capture", but the capture match arm only handles "method.parent" — a grammar author who adds @func.parent per the comment gets it silently ignored (falls through the _ => {} arm) and their functions fall back to the ancestor walk. Either handle "func.parent" in the match (one extra arm setting the same parent_capture) or drop "@func.parent" from the comment and the module doc.

Failure scenario: A future language pack adds @func.parent to its entities.scm following the doc comment; the capture is ignored, functions stay unscoped, and nothing warns — the same silent-file-scoping failure mode this PR fixes for Rust methods.

Generated with Claude Code (Fable). Findings verified by an adversarial refute pass; false positives removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants