diff --git a/graphify/extractors/pascal.py b/graphify/extractors/pascal.py index 398edb22e..21f8303d1 100644 --- a/graphify/extractors/pascal.py +++ b/graphify/extractors/pascal.py @@ -151,6 +151,75 @@ def _pascal_find_body(text: str, start: int) -> tuple[int, int]: return (body_start, tok.start()) return (body_start, len(text)) + +# --------------------------------------------------------------------------- +# Global singleton receivers (#3101) +# --------------------------------------------------------------------------- +# +# `var mm: TMainModule;` in a unit's interface section, then `mm.ServerReport` +# from any other unit, is the standard Delphi "shared main module" shape. The +# per-file pass cannot bind it (the type lives in another file), so the call +# is reported as a raw call carrying its RECEIVER, and the interface-section +# globals are reported beside it; graphify.pascal_resolution joins the two +# across the corpus. Only interface-section vars are collected: they are the +# only ones another unit can see. + +_PAS_VAR_BLOCK_RE = re.compile( + r"^[ \t]*(?:var|threadvar)\b(.*?)(?=^[ \t]*(?:const|type|var|threadvar|procedure|" + r"function|constructor|destructor|class|implementation|begin|end|uses|resourcestring)\b|\Z)", + re.IGNORECASE | re.MULTILINE | re.DOTALL, +) +_PAS_VAR_DECL_RE = re.compile( + r"^[ \t]*([A-Za-z_][\w]*(?:[ \t]*,[ \t]*[A-Za-z_][\w]*)*)[ \t]*:[ \t]*([A-Za-z_][\w.]*)", + re.MULTILINE, +) +_PAS_RECEIVER_SKIP = frozenset({"self", "inherited", "result"}) + + +def _pascal_interface_globals(text: str) -> dict[str, str]: + """``{var_name_lower: type_name_lower}`` for every variable declared in + the unit's interface-section ``var``/``threadvar`` blocks. + + ``text`` must already be comment-stripped. A name declared twice with + different types (it happens in generated units) is dropped rather than + guessed. Files without an interface section (programs, includes) export + nothing: nothing outside them can name their variables. + """ + iface, _off, _impl, _impl_off = _pascal_split_sections(text) + if not iface: + return {} + found: dict[str, str] = {} + conflicting: set[str] = set() + for block in _PAS_VAR_BLOCK_RE.finditer(iface): + for decl in _PAS_VAR_DECL_RE.finditer(block.group(1)): + type_lower = decl.group(2).split(".")[-1].lower() + for raw_name in decl.group(1).split(","): + name = raw_name.strip().lower() + if not name: + continue + prev = found.get(name) + if prev is not None and prev != type_lower: + conflicting.add(name) + found[name] = type_lower + for name in conflicting: + found.pop(name, None) + return found + + +def _pascal_call_parts(callee_text: str) -> tuple[str, str | None]: + """Split ``mm.ServerReport`` into ``("serverreport", "mm")``; an + unqualified call, or one qualified by ``Self``/``inherited``, has no + receiver worth reporting.""" + parts = [p.strip() for p in callee_text.split(".") if p.strip()] + if not parts: + return "", None + name_lower = parts[-1].lower() + receiver = parts[-2].lower() if len(parts) >= 2 else None + if receiver in _PAS_RECEIVER_SKIP: + receiver = None + return name_lower, receiver + + def _resolve_pascal_callee_factory( records: list[tuple], edges: list[dict], @@ -397,8 +466,8 @@ def _lineno(text: str, offset: int) -> int: raw_calls: list[dict] = [] for caller_nid, caller_line, body_text, _container, _name_lower in impl_records: for cm in _PAS_CALL_RE.finditer(body_text): - callee_name = cm.group(1).split(".")[-1].lower() - if callee_name in _PAS_KEYWORDS: + callee_name, receiver = _pascal_call_parts(cm.group(1)) + if not callee_name or callee_name in _PAS_KEYWORDS: continue call_line = caller_line + body_text.count("\n", 0, cm.start()) target_nid = callee_nid(caller_nid, callee_name) @@ -409,12 +478,15 @@ def _lineno(text: str, offset: int) -> int: # class declared in another file) -- report for the # cross-file resolver (graphify.pascal_resolution) instead of # guessing or dropping it silently. - raw_calls.append({ + rc = { "source_file": str_path, "source_location": f"L{call_line}", "caller_nid": caller_nid, "callee": callee_name, - }) + } + if receiver: + rc["receiver"] = receiver + raw_calls.append(rc) continue pair = (caller_nid, target_nid) if pair in seen_call_pairs: @@ -425,6 +497,7 @@ def _lineno(text: str, offset: int) -> int: return { "nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0, "raw_calls": raw_calls, + "pascal_globals": _pascal_interface_globals(stripped), } def extract_pascal(path: Path) -> dict: @@ -639,21 +712,26 @@ def walk(node, parent_nid: str) -> None: # type: ignore[no-untyped-def] seen_call_pairs: set[tuple[str, str]] = set() raw_calls: list[dict] = [] - def _emit_or_report(caller_nid: str, name_lower: str, line: int) -> None: + def _emit_or_report(caller_nid: str, name_lower: str, line: int, + receiver: str | None = None) -> None: target = resolve_callee(caller_nid, name_lower) if target == caller_nid: return if not target: # Not resolvable within this file (e.g. inherited from a base - # class declared in another file) -- report for the cross-file - # resolver (graphify.pascal_resolution) instead of guessing or - # dropping it silently. - raw_calls.append({ + # class declared in another file, or a method on a global + # singleton declared elsewhere, #3101) -- report for the + # cross-file resolver (graphify.pascal_resolution) instead of + # guessing or dropping it silently. + rc = { "source_file": str_path, "source_location": f"L{line}", "caller_nid": caller_nid, "callee": name_lower, - }) + } + if receiver: + rc["receiver"] = receiver + raw_calls.append(rc) return pair = (caller_nid, target) if pair not in seen_call_pairs: @@ -665,17 +743,23 @@ def walk_calls(node, caller_nid: str) -> None: # type: ignore[no-untyped-def] callee_text = None for child in node.children: if child.is_named and child.type not in ("exprArgs",): - callee_text = _read(child).split(".")[-1] + callee_text = _read(child) break if callee_text: - _emit_or_report(caller_nid, callee_text.lower(), node.start_point[0] + 1) + name_lower, receiver = _pascal_call_parts(callee_text) + if name_lower: + _emit_or_report(caller_nid, name_lower, node.start_point[0] + 1, receiver) elif node.type == "statement": # Pascal bare procedure calls with no args: `Reset;` # tree-sitter represents these as statement → identifier (no exprCall wrapper) + # ... and the qualified form `om.Flush;` is statement -> exprDot + # (no exprCall either), which is exactly the global-singleton + # call shape (#3101). named = [c for c in node.children if c.is_named] - if len(named) == 1 and named[0].type == "identifier": - callee_text = _read(named[0]) - _emit_or_report(caller_nid, callee_text.lower(), node.start_point[0] + 1) + if len(named) == 1 and named[0].type in ("identifier", "exprDot"): + name_lower, receiver = _pascal_call_parts(_read(named[0])) + if name_lower: + _emit_or_report(caller_nid, name_lower, node.start_point[0] + 1, receiver) for child in node.children: walk_calls(child, caller_nid) @@ -685,4 +769,7 @@ def walk_calls(node, caller_nid: str) -> None: # type: ignore[no-untyped-def] return { "nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0, "raw_calls": raw_calls, + "pascal_globals": _pascal_interface_globals( + _pascal_strip_comments(source.decode("utf-8", errors="replace")) + ), } diff --git a/graphify/pascal_resolution.py b/graphify/pascal_resolution.py index 0b89afb2c..78d027ad4 100644 --- a/graphify/pascal_resolution.py +++ b/graphify/pascal_resolution.py @@ -87,6 +87,38 @@ def resolve_pascal_inherited_calls( existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + # Global singleton receivers (#3101): `var mm: TMainModule;` in one unit's + # interface, `mm.ServerReport(...)` from another. Join the receiver name + # to its declared type across every file's interface globals, the type to + # the one class of that name in the corpus, and the method name to that + # class's one method. Any ambiguity at any step - two units declaring the + # same global with different types, two classes with the same name, two + # same-named methods on the class - yields no edge rather than a guess. + global_types: dict[str, set[str]] = {} + for result in per_file: + if not isinstance(result, dict): + continue + for var_name, type_lower in (result.get("pascal_globals") or {}).items(): + global_types.setdefault(str(var_name).lower(), set()).add(str(type_lower).lower()) + class_by_name: dict[str, set[str]] = {} + for owner in class_procs: + onode = node_by_id.get(owner) + if onode is None: + continue + class_by_name.setdefault(str(onode.get("label", "")).lower(), set()).add(owner) + + def _resolve_via_receiver(receiver: str, name_lower: str) -> str | None: + types = global_types.get(receiver) + if not types or len(types) != 1: + return None + classes = class_by_name.get(next(iter(types))) + if not classes or len(classes) != 1: + return None + candidates = class_procs.get(next(iter(classes)), {}).get(name_lower) + if candidates and len(candidates) == 1: + return candidates[0] + return None + def _resolve(owner: str, name_lower: str) -> str | None: seen_bases: set[str] = set() queue = list(class_bases.get(owner, [])) @@ -106,10 +138,16 @@ def _resolve(owner: str, name_lower: str) -> str | None: 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)) + receiver = rc.get("receiver") + if receiver: + # A qualified call names its receiver; it is not an inherited + # call, so the caller's ancestor chain must not be consulted. + target = _resolve_via_receiver(str(receiver).lower(), str(name_lower)) + else: + 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) diff --git a/tests/fixtures/pascal_singleton/Form1.pas b/tests/fixtures/pascal_singleton/Form1.pas new file mode 100644 index 000000000..2fc985015 --- /dev/null +++ b/tests/fixtures/pascal_singleton/Form1.pas @@ -0,0 +1,29 @@ +unit Form1; + +interface + +uses + MainModule, OtherModule; + +type + TForm1 = class + public + procedure ButtonClick; + procedure Local; + end; + +implementation + +procedure TForm1.Local; +begin +end; + +procedure TForm1.ButtonClick; +begin + mm.ServerReport('clicked'); + om.Flush; + Self.Local; + Orphan; +end; + +end. diff --git a/tests/fixtures/pascal_singleton/MainModule.pas b/tests/fixtures/pascal_singleton/MainModule.pas new file mode 100644 index 000000000..b4ec46f57 --- /dev/null +++ b/tests/fixtures/pascal_singleton/MainModule.pas @@ -0,0 +1,28 @@ +unit MainModule; + +interface + +type + TMainModule = class + public + procedure ServerReport(const Msg: string); + function Ping: Boolean; + end; + +var + mm: TMainModule; + Counter, Total: Integer; + +implementation + +procedure TMainModule.ServerReport(const Msg: string); +begin + Ping; +end; + +function TMainModule.Ping: Boolean; +begin + Result := True; +end; + +end. diff --git a/tests/fixtures/pascal_singleton/OtherModule.pas b/tests/fixtures/pascal_singleton/OtherModule.pas new file mode 100644 index 000000000..a5ef95053 --- /dev/null +++ b/tests/fixtures/pascal_singleton/OtherModule.pas @@ -0,0 +1,25 @@ +unit OtherModule; + +interface + +type + TOtherModule = class + public + procedure ServerReport(const Msg: string); + procedure Flush; + end; + +var + om: TOtherModule; + +implementation + +procedure TOtherModule.ServerReport(const Msg: string); +begin +end; + +procedure TOtherModule.Flush; +begin +end; + +end. diff --git a/tests/test_pascal_singleton_calls.py b/tests/test_pascal_singleton_calls.py new file mode 100644 index 000000000..b41fd0a71 --- /dev/null +++ b/tests/test_pascal_singleton_calls.py @@ -0,0 +1,190 @@ +"""Pascal/Delphi calls through a global singleton resolve across files (#3101). + +`var mm: TMainModule;` in one unit's interface, `mm.ServerReport(...)` from +another unit: the shared "main module" shape most Delphi codebases have. The +per-file pass discarded the receiver (`mm.ServerReport` -> `serverreport`) +before resolution started, and the cross-file resolver only walked the +CALLER's ancestor chain — so the edge was silently absent whenever caller and +callee lived in different files, and "who calls X" was empty for exactly the +most-used class in the project. + +Static fixtures under tests/fixtures/pascal_singleton/ for the same reason +test_pascal_resolution.py uses them (the extractor's project-root walk). +""" +from __future__ import annotations + +import io +from contextlib import redirect_stdout +from pathlib import Path + +import pytest + +from graphify.extract import extract, extract_pascal +from graphify.extractors.pascal import _extract_pascal_regex + +try: + from graphify.extractors.pascal import _pascal_call_parts, _pascal_interface_globals +except ImportError: # pre-fix tree + _pascal_call_parts = _pascal_interface_globals = None + +needs_helpers = pytest.mark.skipif(_pascal_call_parts is None, reason="pre-fix tree") + +FIXTURES = Path(__file__).parent / "fixtures" / "pascal_singleton" +MAIN = FIXTURES / "MainModule.pas" +OTHER = FIXTURES / "OtherModule.pas" +FORM = FIXTURES / "Form1.pas" + + +def _labels(nodes): + return {n["id"]: str(n.get("label", "")) for n in nodes} + + +def _calls(graph): + labels = _labels(graph["nodes"]) + return {(labels.get(e["source"]), labels.get(e["target"])) + for e in graph["edges"] if e.get("relation") == "calls"} + + +@pytest.fixture +def corpus(tmp_path): + """Cache into tmp_path: a cache under the fixture dir would outlive a + change to the extractor and serve stale per-file results.""" + with redirect_stdout(io.StringIO()): + return extract([MAIN, OTHER, FORM], cache_root=tmp_path, root=FIXTURES, parallel=False) + + +# --------------------------------------------------------------------------- +# The per-file side: keep the receiver, export the interface globals +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("text, expected", [ + ("mm.ServerReport", ("serverreport", "mm")), + ("ServerReport", ("serverreport", None)), + ("Self.Local", ("local", None)), + ("inherited Create", ("inherited create", None)), + ("Unit1.mm.Run", ("run", "mm")), + ("", ("", None)), +]) +@needs_helpers +def test_call_parts_keep_the_receiver_but_not_self(text, expected): + assert _pascal_call_parts(text) == expected + + +@needs_helpers +def test_interface_globals_are_collected_with_their_types(): + text = MAIN.read_text(encoding="utf-8") + assert _pascal_interface_globals(text) == {"mm": "tmainmodule", "counter": "integer", "total": "integer"} + + +@needs_helpers +def test_implementation_and_procedure_local_vars_are_not_exported(): + text = ( + "unit U;\ninterface\nprocedure P(var Arg: Integer);\nimplementation\n" + "var hidden: TThing;\nprocedure P(var Arg: Integer);\nvar local: TOther;\nbegin\nend;\nend.\n" + ) + assert _pascal_interface_globals(text) == {} + + +@needs_helpers +def test_a_name_declared_twice_with_different_types_is_dropped(): + text = "unit U;\ninterface\nvar\n x: TA;\nvar\n x: TB;\n y: TC;\nimplementation\nend.\n" + assert _pascal_interface_globals(text) == {"y": "tc"} + + +@needs_helpers +def test_a_program_without_an_interface_exports_nothing(): + assert _pascal_interface_globals("program P;\nvar g: TThing;\nbegin\nend.\n") == {} + + +@pytest.mark.parametrize("extractor", [extract_pascal, _extract_pascal_regex]) +def test_the_per_file_pass_reports_the_qualified_call_with_its_receiver(extractor): + result = extractor(FORM) + rcs = {rc["callee"]: rc for rc in result["raw_calls"]} + assert rcs["serverreport"]["receiver"] == "mm" + assert rcs["flush"]["receiver"] == "om" + assert "receiver" not in rcs["orphan"] # unqualified stays as it was + assert result["pascal_globals"] == {} # Form1 declares no globals + + +@pytest.mark.parametrize("extractor", [extract_pascal, _extract_pascal_regex]) +def test_the_per_file_pass_exports_globals(extractor): + assert extractor(MAIN)["pascal_globals"]["mm"] == "tmainmodule" + + +# --------------------------------------------------------------------------- +# The corpus side: the edge now exists, and only where it is unambiguous +# --------------------------------------------------------------------------- + +def test_a_call_through_a_global_singleton_resolves_across_files(corpus): + calls = _calls(corpus) + assert ("ButtonClick()", "ServerReport()") in calls + assert ("ButtonClick()", "Flush()") in calls + + +def test_the_receiver_picks_the_right_class_when_method_names_collide(corpus): + """Both TMainModule and TOtherModule have ServerReport; `mm.` must land on + TMainModule's, and nothing on TOtherModule's.""" + g = corpus + labels = _labels(g["nodes"]) + owner = {} + for e in g["edges"]: + if e.get("relation") == "method": + owner[e["target"]] = labels.get(e["source"]) + targets = {owner.get(e["target"]) for e in g["edges"] + if e.get("relation") == "calls" and labels.get(e["source"]) == "ButtonClick()" + and labels.get(e["target"]) == "ServerReport()"} + assert targets == {"TMainModule"} + + +def test_the_edge_is_extracted_and_carries_the_call_site(corpus): + g = corpus + labels = _labels(g["nodes"]) + edge = next(e for e in g["edges"] if e.get("relation") == "calls" + and labels.get(e["source"]) == "ButtonClick()" and labels.get(e["target"]) == "ServerReport()") + assert edge["confidence"] == "EXTRACTED" + assert edge["source_file"].endswith("Form1.pas") + assert edge["source_location"].startswith("L") + + +def test_an_unqualified_unresolvable_call_still_produces_no_edge(corpus): + calls = _calls(corpus) + assert not any(t and t.lower().startswith("orphan") for _, t in calls) + + +def test_a_receiver_with_two_declared_types_yields_no_edge(tmp_path): + from graphify.pascal_resolution import resolve_pascal_inherited_calls + nodes = [ + {"id": "a_ta", "label": "TA"}, {"id": "a_ta_go", "label": "Go()"}, + {"id": "b_tb", "label": "TB"}, {"id": "b_tb_go", "label": "Go()"}, + {"id": "c_caller", "label": "Caller()"}, + ] + edges = [ + {"source": "a_ta", "target": "a_ta_go", "relation": "method"}, + {"source": "b_tb", "target": "b_tb_go", "relation": "method"}, + ] + per_file = [ + {"pascal_globals": {"x": "ta"}, "raw_calls": []}, + {"pascal_globals": {"x": "tb"}, "raw_calls": [ + {"source_file": "c.pas", "caller_nid": "c_caller", "callee": "go", "receiver": "x"}]}, + ] + resolve_pascal_inherited_calls(per_file, nodes, edges) + assert not any(e.get("relation") == "calls" for e in edges) + + +def test_a_qualified_call_does_not_fall_back_to_the_callers_ancestors(): + """`other.Prepare` is a call on `other`, not an inherited call — the + ancestor-chain walk must not bind it to the caller's base class.""" + from graphify.pascal_resolution import resolve_pascal_inherited_calls + nodes = [ + {"id": "base", "label": "TBase"}, {"id": "base_prepare", "label": "Prepare()"}, + {"id": "derived", "label": "TDerived"}, {"id": "derived_run", "label": "Run()"}, + ] + edges = [ + {"source": "derived", "target": "base", "relation": "inherits"}, + {"source": "base", "target": "base_prepare", "relation": "method"}, + {"source": "derived", "target": "derived_run", "relation": "method"}, + ] + per_file = [{"pascal_globals": {}, "raw_calls": [ + {"source_file": "d.pas", "caller_nid": "derived_run", "callee": "prepare", "receiver": "other"}]}] + resolve_pascal_inherited_calls(per_file, nodes, edges) + assert not any(e.get("relation") == "calls" for e in edges)