Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/callgraph-miss-sweep/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ zod (269 edges) split into:
| `unmapped_dst` on Java interface / abstract methods | links to bodyless Java declarations | extend #3757's `def_shape` = `signature` beyond TS/JS | #3836 (fixed, java) |
| `wrong` / `external`: `field.m()` inside a class that also has `m` | the explicit receiver is ignored and the caller's own class wins | resolver: an explicit non-`this` receiver whose `file` answer is the caller's own class becomes an ambiguous `receiver` when another visible class defines `m`; java `->` is a lambda arrow, not a qualifier separator (Kotlin/Groovy/Scala left out: unmeasured) | #3837 (fixed) |
| `ambiguous/unseen` on `new X()` / bare calls between `src/test` and `src/main` | a package split over a module's source sets is two directories, and visibility was per directory | resolver `_package_dir`: java/kotlin/scala/groovy map `m/src/<set>/<lang>/p` to one package key (module-aware); gson 64.9% -> 71.5% recall | #3772 (fixed; real `package` declarations unused) |
| `resolved_outside`, site `self.m()` (rust: 239 of 424 on serde-rs/json) | `impl` methods have no owner, so `self` has no class to walk | resolver `_IMPL_SELF_LANGS`: a `self` receiver reaches any definition; other receivers stay closed (widening all gave `external` 40 -> 138); rust recall 39.9% -> 60.1% | #3772 (fixed) |

A pattern found in one language usually exists in its neighbours. Bodyless signatures apply to
Java, C#, Go interfaces and Rust traits. String interpolation applies to Kotlin, Swift and Ruby.
Expand Down
14 changes: 11 additions & 3 deletions docs/graph_accuracy.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,17 @@ defect. Calls through a trait object or a generic bound resolve to the bodyless
declaration, so they count as neither an edge nor `external`. All numbers are "vs
rust-analyzer".

Measured against rust-analyzer 1.90.0 on serde-rs/json: confident precision 92.1% (469 judged),
ambiguous 52.3%, recall 39.9%, resolution recall 43.1%; 40 confident links resolve outside the
repo and 158 end at a function the index has none for. Read from the real index:
Measured against rust-analyzer 1.90.0 on serde-rs/json: confident precision 93.8% (694 judged),
ambiguous 60.4%, recall 60.1%, resolution recall 64.9%; 41 confident links resolve outside the
repo and 207 end at a function the index has none for. Read from the real index:

- **`impl` methods carry no owner** (`class_start` has `struct`/`enum`/`trait` but an `impl` block is
a separate span), so `self.m()` had no class to walk and 239 of the first 424 `resolved_outside`
edges were `self.` calls into the same file. The resolver now lets a `self` receiver in Rust reach
any definition (`_IMPL_SELF_LANGS`: recall 39.9% -> 60.1%, precision 92.1% -> 93.8%). Widening
every receiver the same way was tried and rejected: `x.len()` linked to every repo `len`
(`external` 40 -> 138, precision down to about 71%). A typed receiver (`de.m()`) and a
`Type::new(` path need the impl's type as the owner, a detector change not made here.

- rust-analyzer emits **no `enclosing_range`**, so the adapter reads a fn's extent from the
source (`scip_callgraph._rust_enclosing`: to the `;` of a bodyless declaration, else through
Expand Down
8 changes: 7 additions & 1 deletion gitgalaxy/core/call_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@
# reach any definition, not only a method.
_OWNERLESS_METHOD_LANGS = frozenset({"go"})

# Languages whose methods sit in a block that is not a class_start span (rust's `impl Type { .. }`,
# `struct`/`enum` bodies are separate), so no method has an owner and `self.m()` has no class to
# walk. A `self` receiver is the caller's own type, so it may reach any definition in the repo; any
# other receiver still cannot (an untyped `x.len()` would link to every repo `len`).
_IMPL_SELF_LANGS = frozenset({"rust"})

# #3401: names a BARE call always means as the language's built-in, whatever
# the repository defines. Perl's named operators and built-in functions take
# precedence over a user `sub` of the same name -- `sub map` in Mojo's
Expand Down Expand Up @@ -1258,7 +1264,7 @@ def walk(owners: list[str]) -> tuple[str, _Definition | None] | None:
hit = walk(lineage)
if hit is not None:
return hit
return _ladder(bucket.methods, caller, True, cache)
return _ladder(bucket.all if caller.lang in _IMPL_SELF_LANGS else bucket.methods, caller, True, cache)
if qualifier in _SUPER_RECEIVERS:
for owner_key in lineage[1:]:
d = owned(owner_key)
Expand Down
16 changes: 8 additions & 8 deletions tests/call_graph_resolution_rust_baseline.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"ambiguous_precision_pct": 52.3,
"confident_external": 40,
"confident_judged": 469,
"confident_precision_pct": 92.1,
"confident_strict_precision_pct": 84.9,
"confident_unmapped": 158,
"ambiguous_precision_pct": 60.4,
"confident_external": 41,
"confident_judged": 694,
"confident_precision_pct": 93.8,
"confident_strict_precision_pct": 88.6,
"confident_unmapped": 207,
"pyan_edges": 1084,
"recall_pct": 39.9,
"recall_pct": 60.1,
"reference_version": "1.90.0",
"resolution_recall_pct": 43.1
"resolution_recall_pct": 64.9
}
37 changes: 37 additions & 0 deletions tests/core_engine/test_call_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1237,3 +1237,40 @@ def test_go_source_set_directories_are_still_separate_packages():
_file("m/src/main/java/b.go", "go", [_fn("helper", 3)]),
]
assert _site(resolve_calls(files)[0], "helper")["step"] == "unseen"


def _impl_universe(lang, ext, receiver):
"""An `impl`-style universe: no method carries an owner, as the extractor leaves them."""
return [
_file(
f"src/de.{ext}",
lang,
[
_fn("eat_char", 3),
_fn("run", 9, calls=["eat_char", "len"], quals={"eat_char": [receiver], "len": [receiver]}),
],
),
_file(f"src/other.{ext}", lang, [_fn("len", 1)]),
]


def test_rust_self_receiver_reaches_an_impl_method_of_the_same_file():
sites, _ = resolve_calls(_impl_universe("rust", "rs", "self"))
row = _site(sites, "eat_char")
assert (row["step"], row["dst_path"], row["dst_line"]) == ("file", "src/de.rs", 3)
# an unseen file's method is only the ambiguous guess, never an edge
assert _site(sites, "len")["step"] == "receiver"


def test_rust_other_receiver_stays_unresolved_without_an_owner():
# `x.eat_char()` on an untyped receiver cannot be told from a std method: only `self` is widened
sites, _ = resolve_calls(_impl_universe("rust", "rs", "x"))
assert _site(sites, "eat_char")["step"] == "none"
assert _site(sites, "len")["step"] == "none"


@pytest.mark.parametrize(("lang", "ext"), [("python", "py"), ("java", "java"), ("javascript", "js")])
def test_impl_self_widening_is_rust_only(lang, ext):
# an ownerless `self.m()` in a language with real class owners keeps resolving through methods only
sites, _ = resolve_calls(_impl_universe(lang, ext, "self"))
assert _site(sites, "eat_char")["step"] == "none"

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading