Skip to content
Closed
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
10 changes: 7 additions & 3 deletions graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,11 @@ def _relativize_source_files_in(payload: dict, root: Path) -> None:
root_resolved = Path(root).resolve()
except OSError:
return
for bucket in ("nodes", "edges", "hyperedges"):
# raw_calls (#: Pascal/Delphi cross-file inherited-call resolution) carries
# source_file the same way nodes/edges/hyperedges do, so it needs the same
# portable-path treatment for cache entries to round-trip correctly across
# machines/checkout directories.
for bucket in ("nodes", "edges", "hyperedges", "raw_calls"):
for item in payload.get(bucket, []):
if not isinstance(item, dict):
continue
Expand Down Expand Up @@ -307,7 +311,7 @@ def _absolutize_source_files_in(payload: dict, root: Path) -> None:
root_resolved = Path(root).resolve()
except OSError:
return
for bucket in ("nodes", "edges", "hyperedges"):
for bucket in ("nodes", "edges", "hyperedges", "raw_calls"):
for item in payload.get(bucket, []):
if not isinstance(item, dict):
continue
Expand Down Expand Up @@ -400,7 +404,7 @@ def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "a
# source_file field's original absolute form. Mutating the input here would
# silently break those remaps on the first extraction pass.
on_disk = result
if isinstance(result, dict) and any(result.get(k) for k in ("nodes", "edges", "hyperedges")):
if isinstance(result, dict) and any(result.get(k) for k in ("nodes", "edges", "hyperedges", "raw_calls")):
import copy as _copy
on_disk = _copy.deepcopy(result)
_relativize_source_files_in(on_disk, root)
Expand Down
254 changes: 203 additions & 51 deletions graphify/extract.py

Large diffs are not rendered by default.

120 changes: 120 additions & 0 deletions graphify/pascal_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Cross-file resolution for Pascal/Delphi calls to inherited methods.

The per-file Pascal/Delphi extractors (``extract_pascal``,
``_extract_pascal_regex``) resolve calls to a method on the caller's own
class, its ancestor chain, or a file-level free function -- but only within
the single file being extracted (each file is extracted independently; see
``_resolve_pascal_callee_factory`` in ``extract.py``). Real Delphi/MTM-style
codebases very commonly split a class across two files -- a code-generator
base class (e.g. Sistec's ``Th0Xxx``) and a manual descendant that extends it
in a separate unit (``Th5Xxx``) -- so a call from the manual descendant to a
method it inherits from the generated base falls outside any one file's own
scope. The per-file pass reports these as ``raw_calls`` instead of guessing.

This resolver runs after all files are extracted (registered in
``graphify.resolver_registry``), with the full merged node/edge corpus
available, so it can walk an ``inherits`` chain across file boundaries. It
intentionally does NOT fall back to a global by-name match the way the
per-file pass's final tier does -- an unqualified call resolving to a
specific ancestor mirrors Delphi's actual method-lookup semantics (nearest
ancestor in the chain), so walking `inherits` is a structurally justified
resolution, not a heuristic guess; guessing by name across an entire
multi-thousand-file corpus is not the same bet.
"""
from __future__ import annotations

_PASCAL_SUFFIXES = (".pas", ".pp", ".dpr", ".dpk", ".inc")


def _pascal_raw_calls(per_file: list[dict]) -> list[dict]:
calls: list[dict] = []
for result in per_file:
if not isinstance(result, dict):
continue
for rc in result.get("raw_calls", []):
if not isinstance(rc, dict):
continue
if str(rc.get("source_file", "")).endswith(_PASCAL_SUFFIXES):
calls.append(rc)
return calls


def resolve_pascal_inherited_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Pascal/Delphi calls to a method inherited across file boundaries.

Purely additive: only emits edges for raw calls the per-file pass could
not resolve locally (see module docstring). Each emission requires a
single owning class at the nearest matching level of the caller's
``inherits`` chain (god-node guard, same principle as
``resolve_ruby_member_calls``) -- an ambiguous or unresolved name
produces no edge rather than a guess.
"""
node_by_id = {n.get("id"): n for n in all_nodes}

class_bases: dict[str, list[str]] = {}
# method_nid -> its owning class nid, so a raw call's caller_nid (a method
# or free-function nid) can be mapped to the CLASS whose inherits chain
# should be walked. Derived from `all_edges` (already remapped/finalized
# by the id-disambiguation passes that run before resolvers, same as
# caller_nid itself) rather than carried as a separate field on the raw
# call -- a field the generic id-remap machinery would not know to update.
owner_of: dict[str, str] = {}
class_procs: dict[str, dict[str, list[str]]] = {}
for e in all_edges:
if e.get("relation") == "inherits":
class_bases.setdefault(e["source"], []).append(e["target"])
elif e.get("relation") == "method":
owner, method_nid = e.get("source"), e.get("target")
owner_of[method_nid] = owner
mnode = node_by_id.get(method_nid)
if mnode is None:
continue
name_lower = str(mnode.get("label", "")).removesuffix("()").lower()
class_procs.setdefault(owner, {}).setdefault(name_lower, []).append(method_nid)

existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}

def _resolve(owner: str, name_lower: str) -> str | None:
seen_bases: set[str] = set()
queue = list(class_bases.get(owner, []))
while queue:
base = queue.pop(0)
if base in seen_bases:
continue
seen_bases.add(base)
candidates = class_procs.get(base, {}).get(name_lower)
if candidates:
return candidates[0] if len(candidates) == 1 else None
queue.extend(class_bases.get(base, []))
return None

for rc in _pascal_raw_calls(per_file):
caller = rc.get("caller_nid")
name_lower = rc.get("callee")
if not caller or not name_lower:
continue
owner = owner_of.get(caller)
if not owner:
continue
target = _resolve(str(owner), str(name_lower))
if not target or target == caller:
continue
pair = (caller, target)
if pair in existing_pairs:
continue
existing_pairs.add(pair)
all_edges.append({
"source": caller,
"target": target,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
18 changes: 18 additions & 0 deletions tests/fixtures/pascal_cross_file/BaseGadget.pas
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
unit BaseGadget;

interface

type
TBaseGadget = class(TObject)
public
procedure Prepare;
end;

implementation

procedure TBaseGadget.Prepare;
begin
{ base prepare }
end;

end.
21 changes: 21 additions & 0 deletions tests/fixtures/pascal_cross_file/DerivedGadget.pas
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
unit DerivedGadget;

interface

uses
BaseGadget;

type
TDerivedGadget = class(TBaseGadget)
public
procedure Run;
end;

implementation

procedure TDerivedGadget.Run;
begin
Prepare;
end;

end.
18 changes: 18 additions & 0 deletions tests/fixtures/pascal_cross_file/OtherGadget.pas
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
unit OtherGadget;

interface

type
TOtherGadget = class(TObject)
public
procedure Prepare;
end;

implementation

procedure TOtherGadget.Prepare;
begin
{ unrelated prepare }
end;

end.
60 changes: 60 additions & 0 deletions tests/fixtures/sample_scoped_calls.pas
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
unit ScopedCallsUnit;

interface

type
TFirstWidget = class(TObject)
public
procedure Configure;
procedure Reset;
end;

TSecondWidget = class(TObject)
public
procedure Configure;
procedure Reset;
end;

TBaseWidget = class(TObject)
public
procedure Prepare;
end;

TDerivedWidget = class(TBaseWidget)
public
procedure Run;
end;

implementation

procedure TFirstWidget.Configure;
begin
Reset;
end;

procedure TFirstWidget.Reset;
begin
{ first reset }
end;

procedure TSecondWidget.Configure;
begin
Reset;
end;

procedure TSecondWidget.Reset;
begin
{ second reset }
end;

procedure TBaseWidget.Prepare;
begin
{ base prepare }
end;

procedure TDerivedWidget.Run;
begin
Prepare;
end;

end.
102 changes: 102 additions & 0 deletions tests/test_pascal_call_scoping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""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"
)
Loading