mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 18:37:12 +00:00
d89efbf9ef
Both Pascal extractors resolved every call via a single file-wide
{method_name: node_id} dict, so two unrelated classes declaring a same-named
method (property accessors, generated COM/TLB wrapper classes) collapsed onto
whichever declaration was inserted last, producing wrong cross-class `calls`
edges. Resolution is now scoped: own class -> ancestor chain (inherits) ->
file-level free function -> unambiguous file-wide match; ambiguous at every
level emits no edge rather than guessing (same god-node guard as the Ruby
resolver).
Adds graphify/pascal_resolution.py, a corpus-wide post-extraction resolver
(registered via resolver_registry) that walks the inherits chain across file
boundaries, so a call from a manual descendant to a method it inherits from a
base class in a separate unit (the generated-base/manual-descendant split)
resolves. Also stops both extractors from emitting a duplicate base-class stub
carrying the referencing file's source_file, which collided with the real node
under cross-file id disambiguation. cache.py gives the new raw_calls bucket the
same portable-path treatment as nodes/edges so it round-trips.
Re-applied to the post-#1737 module layout (extractor hunks land in
graphify/extractors/pascal.py; registration stays in extract.py). Added one
adaptation the original PR predated: the cross-file resolver's god-node guard
now counts DISTINCT method nids, because the tree-sitter extractor emits a
method edge for both the interface declaration and the implementation, so the
same method_nid arrives twice -- without deduping, every inherited call looked
ambiguous and resolved to nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
"""Regression tests for scoped call resolution in the Pascal/Delphi extractor.
|
|
|
|
Before this fix, both `extract_pascal` (tree-sitter path) and
|
|
`_extract_pascal_regex` (fallback path) resolved every call by a single
|
|
file-wide ``{method_name_lower: node_id}`` dict with no class scoping. Two
|
|
unrelated classes declaring a same-named method (a common Pascal/Delphi
|
|
pattern -- property accessors, generated wrapper classes such as TLB import
|
|
units) silently collapsed onto whichever declaration was inserted last,
|
|
producing wrong cross-class `calls` edges. See `sample_scoped_calls.pas`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
FIXTURE_PATH = FIXTURES / "sample_scoped_calls.pas"
|
|
|
|
|
|
def _extractors():
|
|
from graphify.extract import extract_pascal, _extract_pascal_regex
|
|
return [extract_pascal, _extract_pascal_regex]
|
|
|
|
|
|
def _class_node_id(r, class_label):
|
|
matches = [n["id"] for n in r["nodes"] if n["label"] == class_label]
|
|
assert len(matches) == 1, f"expected exactly one node labeled {class_label!r}, got {matches}"
|
|
return matches[0]
|
|
|
|
|
|
def _method_node_id(r, class_label, method_label):
|
|
class_id = _class_node_id(r, class_label)
|
|
node_by_id = {n["id"]: n for n in r["nodes"]}
|
|
for e in r["edges"]:
|
|
if e["relation"] == "method" and e["source"] == class_id:
|
|
node = node_by_id.get(e["target"])
|
|
if node and node["label"] == method_label:
|
|
return node["id"]
|
|
raise AssertionError(f"no method edge {class_label}.{method_label} found")
|
|
|
|
|
|
def _has_call(r, src_id, tgt_id):
|
|
return any(
|
|
e["relation"] == "calls" and e["source"] == src_id and e["target"] == tgt_id
|
|
for e in r["edges"]
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("extract", [
|
|
pytest.param(0, id="tree-sitter"),
|
|
pytest.param(1, id="regex-fallback"),
|
|
])
|
|
def test_calls_scoped_to_own_class(extract):
|
|
r = _extractors()[extract](FIXTURE_PATH)
|
|
first_configure = _method_node_id(r, "TFirstWidget", "Configure()")
|
|
first_reset = _method_node_id(r, "TFirstWidget", "Reset()")
|
|
assert _has_call(r, first_configure, first_reset)
|
|
|
|
|
|
@pytest.mark.parametrize("extract", [
|
|
pytest.param(0, id="tree-sitter"),
|
|
pytest.param(1, id="regex-fallback"),
|
|
])
|
|
def test_calls_do_not_cross_unrelated_classes(extract):
|
|
r = _extractors()[extract](FIXTURE_PATH)
|
|
first_configure = _method_node_id(r, "TFirstWidget", "Configure()")
|
|
second_reset = _method_node_id(r, "TSecondWidget", "Reset()")
|
|
assert not _has_call(r, first_configure, second_reset), (
|
|
"TFirstWidget.Configure must not resolve Reset() to the unrelated "
|
|
"TSecondWidget.Reset -- same-named methods on unrelated classes must "
|
|
"not collapse into a cross-class edge"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("extract", [
|
|
pytest.param(0, id="tree-sitter"),
|
|
pytest.param(1, id="regex-fallback"),
|
|
])
|
|
def test_calls_scoped_other_direction(extract):
|
|
r = _extractors()[extract](FIXTURE_PATH)
|
|
second_configure = _method_node_id(r, "TSecondWidget", "Configure()")
|
|
second_reset = _method_node_id(r, "TSecondWidget", "Reset()")
|
|
first_reset = _method_node_id(r, "TFirstWidget", "Reset()")
|
|
assert _has_call(r, second_configure, second_reset)
|
|
assert not _has_call(r, second_configure, first_reset), (
|
|
"TSecondWidget.Configure must not resolve Reset() to the unrelated "
|
|
"TFirstWidget.Reset"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("extract", [
|
|
pytest.param(0, id="tree-sitter"),
|
|
pytest.param(1, id="regex-fallback"),
|
|
])
|
|
def test_calls_resolve_via_ancestor_chain(extract):
|
|
r = _extractors()[extract](FIXTURE_PATH)
|
|
derived_run = _method_node_id(r, "TDerivedWidget", "Run()")
|
|
base_prepare = _method_node_id(r, "TBaseWidget", "Prepare()")
|
|
assert _has_call(r, derived_run, base_prepare), (
|
|
"TDerivedWidget.Run should resolve the inherited Prepare() to "
|
|
"TBaseWidget.Prepare via the inherits chain"
|
|
)
|