mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-28 16:36:12 +00:00
fix(extract): scope Pascal/Delphi call resolution + resolve inherited calls across files (#1739)
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>
This commit is contained in:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
2f9deae8d5
commit
d89efbf9ef
+7
-3
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -20,6 +20,7 @@ from .resolver_registry import (
|
||||
run_language_resolvers,
|
||||
)
|
||||
from .ruby_resolution import resolve_ruby_member_calls
|
||||
from .pascal_resolution import resolve_pascal_inherited_calls
|
||||
|
||||
# --- migrated to graphify/extractors/ (see graphify/extractors/MIGRATION.md) ---
|
||||
from graphify.extractors.base import ( # noqa: F401
|
||||
@@ -2642,6 +2643,19 @@ register_language_resolver(
|
||||
register_language_resolver(
|
||||
LanguageResolver("csharp_member_calls", frozenset({".cs"}), _resolve_csharp_member_calls)
|
||||
)
|
||||
# Pascal/Delphi cross-file inherited-method-call resolution: a call from a
|
||||
# manual descendant class to a method it inherits from an ancestor declared
|
||||
# in a DIFFERENT file (the common generated-base/manual-descendant split,
|
||||
# e.g. Sistec's Th0Xxx/Th5Xxx) falls outside the per-file extractor's own
|
||||
# scope. Lives in graphify.pascal_resolution; registered here as a consumer
|
||||
# of the framework, same as the Ruby resolver above.
|
||||
register_language_resolver(
|
||||
LanguageResolver(
|
||||
"pascal_inherited_calls",
|
||||
frozenset({".pas", ".pp", ".dpr", ".dpk", ".inc"}),
|
||||
resolve_pascal_inherited_calls,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Inline markdown link: [text](target "optional title"). The negative lookbehind
|
||||
|
||||
+189
-51
@@ -5,7 +5,7 @@ import re
|
||||
from graphify.extractors.base import _file_stem, _make_id
|
||||
from graphify.extractors.resolution import _pascal_resolve_class, _pascal_resolve_unit
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
_PAS_TOKEN_RE = re.compile(
|
||||
@@ -151,6 +151,83 @@ def _pascal_find_body(text: str, start: int) -> tuple[int, int]:
|
||||
return (body_start, tok.start())
|
||||
return (body_start, len(text))
|
||||
|
||||
def _resolve_pascal_callee_factory(
|
||||
records: list[tuple],
|
||||
edges: list[dict],
|
||||
module_nid: str,
|
||||
) -> Callable[[str, str], str | None]:
|
||||
"""Build a scoped call resolver for a single Pascal/Delphi file.
|
||||
|
||||
``records`` is the list of raw per-procedure tuples produced by either
|
||||
Pascal extractor; only the trailing ``(..., container, name_lower)``
|
||||
fields and the leading ``proc_nid`` are used, so both extractors' tuple
|
||||
shapes work unmodified (regex: proc_nid, line, body_text, container,
|
||||
name_lower; tree-sitter: proc_nid, body_node, container, name_lower).
|
||||
|
||||
Resolution order for a call to ``name_lower`` from ``caller_nid``:
|
||||
1. A method declared on the caller's own class.
|
||||
2. A method declared on an ancestor class (BFS up ``inherits`` edges,
|
||||
which are already resolved by this point).
|
||||
3. A file-level free function (declared directly under the module).
|
||||
4. A global by-name match, but only when unambiguous (exactly one
|
||||
procedure with that name anywhere in the file).
|
||||
|
||||
Returns None (no edge emitted) when the name is ambiguous at every
|
||||
level -- guessing at a same-named method on an unrelated class is worse
|
||||
than omitting the edge. Same-named methods on unrelated classes are a
|
||||
common Pascal/Delphi pattern (property accessors, generated wrapper
|
||||
classes such as TLB import units): without this scoping, a flat
|
||||
file-wide by-name lookup silently collapses onto whichever declaration
|
||||
happens to be inserted last, producing wrong cross-class edges. Mirrors
|
||||
the "god-node guard" already used by ``resolve_ruby_member_calls`` for
|
||||
the analogous Ruby ambiguous-method-name problem.
|
||||
"""
|
||||
class_bases: dict[str, list[str]] = {}
|
||||
for e in edges:
|
||||
if e.get("relation") == "inherits":
|
||||
class_bases.setdefault(e["source"], []).append(e["target"])
|
||||
|
||||
class_procs: dict[str, dict[str, list[str]]] = {}
|
||||
module_procs: dict[str, list[str]] = {}
|
||||
global_procs: dict[str, list[str]] = {}
|
||||
proc_owner: dict[str, str] = {}
|
||||
for rec in records:
|
||||
proc_nid, container, name_lower = rec[0], rec[-2], rec[-1]
|
||||
proc_owner[proc_nid] = container
|
||||
global_procs.setdefault(name_lower, []).append(proc_nid)
|
||||
if container == module_nid:
|
||||
module_procs.setdefault(name_lower, []).append(proc_nid)
|
||||
else:
|
||||
class_procs.setdefault(container, {}).setdefault(name_lower, []).append(proc_nid)
|
||||
|
||||
def _resolve(caller_nid: str, name_lower: str) -> str | None:
|
||||
owner = proc_owner.get(caller_nid)
|
||||
if owner is not None:
|
||||
candidates = class_procs.get(owner, {}).get(name_lower)
|
||||
if candidates:
|
||||
return candidates[0] if len(candidates) == 1 else 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, []))
|
||||
candidates = module_procs.get(name_lower)
|
||||
if candidates:
|
||||
return candidates[0] if len(candidates) == 1 else None
|
||||
candidates = global_procs.get(name_lower)
|
||||
if candidates and len(candidates) == 1:
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
def _extract_pascal_regex(path: Path) -> dict:
|
||||
"""Regex fallback for Pascal/Delphi extraction when tree-sitter-pascal
|
||||
is unavailable. Produces the same node/edge schema as the tree-sitter pass.
|
||||
@@ -235,10 +312,29 @@ def _extract_pascal_regex(path: Path) -> dict:
|
||||
_add_edge(module_nid, cls_nid, "contains", line)
|
||||
|
||||
for base_name in _pascal_split_bases(bases_raw):
|
||||
resolved = _pascal_resolve_class(path, base_name)
|
||||
base_nid = resolved if resolved else _make_id(base_name)
|
||||
if base_nid not in seen_ids:
|
||||
_add_node(base_nid, base_name, line)
|
||||
same_file_nid = _make_id(stem, base_name)
|
||||
if same_file_nid in seen_ids:
|
||||
# Base class already declared earlier in this same file --
|
||||
# reuse its real node instead of the cross-file/stub lookup
|
||||
# below (which assumes one-class-per-file and would create a
|
||||
# duplicate node for a base class that shares this file).
|
||||
base_nid = same_file_nid
|
||||
else:
|
||||
resolved = _pascal_resolve_class(path, base_name)
|
||||
if resolved:
|
||||
# Cross-file base class found on disk -- its real node
|
||||
# arrives via THAT file's own extraction. Do not add a
|
||||
# duplicate stub here: it would carry this file's
|
||||
# source_file (wrong -- it belongs to the base class's
|
||||
# own file) and collide with the real node under
|
||||
# cross-file id disambiguation, producing two different
|
||||
# salted ids for what should be one class (breaks
|
||||
# cross-file `inherits`-chain resolution downstream).
|
||||
base_nid = resolved
|
||||
else:
|
||||
base_nid = _make_id(base_name)
|
||||
if base_nid not in seen_ids:
|
||||
_add_node(base_nid, base_name, line)
|
||||
_add_edge(cls_nid, base_nid, "inherits", line)
|
||||
|
||||
# Find class body (up to next end;)
|
||||
@@ -257,7 +353,8 @@ def _extract_pascal_regex(path: Path) -> dict:
|
||||
pos = end_m.end() if end_m else len(search_text)
|
||||
|
||||
# Implementation headers (procedure/function/constructor/destructor)
|
||||
impl_records: list[tuple[str, int, str]] = []
|
||||
impl_records: list[tuple[str, int, str, str, str]] = []
|
||||
# (proc_nid, line, body_text, container, name_lower)
|
||||
for fm in _PAS_IMPL_HEADER_RE.finditer(impl_text):
|
||||
qualified = fm.group("qual")
|
||||
line = _lineno(stripped, impl_off + fm.start())
|
||||
@@ -267,39 +364,59 @@ def _extract_pascal_regex(path: Path) -> dict:
|
||||
container = cls_nid if cls_nid in seen_ids else module_nid
|
||||
relation = "method" if cls_nid in seen_ids else "contains"
|
||||
label = f"{method_part}()"
|
||||
name_lower = method_part.lower()
|
||||
else:
|
||||
container, relation = module_nid, "contains"
|
||||
label = f"{qualified}()"
|
||||
name_lower = qualified.lower()
|
||||
proc_nid = _make_id(stem, qualified)
|
||||
_add_node(proc_nid, label, line)
|
||||
_add_edge(container, proc_nid, relation, line)
|
||||
|
||||
body_start, body_end = _pascal_find_body(impl_text, fm.end())
|
||||
body_text = impl_text[body_start:body_end] if body_start else ""
|
||||
impl_records.append((proc_nid, line, body_text))
|
||||
impl_records.append((proc_nid, line, body_text, container, name_lower))
|
||||
|
||||
# Intra-file call edges
|
||||
all_procs: dict[str, str] = {
|
||||
n["label"].removesuffix("()").lower(): n["id"]
|
||||
for n in nodes
|
||||
if n["id"] != file_nid and n["label"].endswith("()")
|
||||
}
|
||||
for caller_nid, caller_line, body_text in impl_records:
|
||||
# Intra-file call edges, scoped by the caller's own class, then its
|
||||
# ancestor chain (via `inherits` edges already emitted above), then
|
||||
# file-level free functions; fall back to a global by-name match only
|
||||
# when it is unambiguous (single owner across the file). Prevents
|
||||
# same-named methods on unrelated classes (property accessors, generated
|
||||
# wrapper classes such as TLB import units, etc. -- a common Pascal/Delphi
|
||||
# pattern) from collapsing into an arbitrary cross-class edge.
|
||||
callee_nid = _resolve_pascal_callee_factory(impl_records, edges, module_nid)
|
||||
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:
|
||||
continue
|
||||
callee_nid = all_procs.get(callee_name)
|
||||
if not callee_nid or callee_nid == caller_nid:
|
||||
call_line = caller_line + body_text.count("\n", 0, cm.start())
|
||||
target_nid = callee_nid(caller_nid, callee_name)
|
||||
if target_nid == caller_nid:
|
||||
continue
|
||||
pair = (caller_nid, callee_nid)
|
||||
if not target_nid:
|
||||
# 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({
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{call_line}",
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee_name,
|
||||
})
|
||||
continue
|
||||
pair = (caller_nid, target_nid)
|
||||
if pair in seen_call_pairs:
|
||||
continue
|
||||
seen_call_pairs.add(pair)
|
||||
call_line = caller_line + body_text.count("\n", 0, cm.start())
|
||||
_add_edge(caller_nid, callee_nid, "calls", call_line, context="call")
|
||||
_add_edge(caller_nid, target_nid, "calls", call_line, context="call")
|
||||
|
||||
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
|
||||
return {
|
||||
"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0,
|
||||
"raw_calls": raw_calls,
|
||||
}
|
||||
|
||||
def extract_pascal(path: Path) -> dict:
|
||||
"""Extract units, classes, procedures, uses-imports, and calls from Pascal/Delphi files.
|
||||
@@ -342,7 +459,8 @@ def extract_pascal(path: Path) -> dict:
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
proc_bodies: list[tuple[str, Any]] = []
|
||||
proc_bodies: list[tuple[str, Any, str, str]] = []
|
||||
# (proc_nid, body_node, container, name_lower)
|
||||
|
||||
def _read(node) -> str: # type: ignore[no-untyped-def]
|
||||
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
||||
@@ -425,10 +543,21 @@ def extract_pascal(path: Path) -> dict:
|
||||
if base_nid not in seen_ids:
|
||||
# Try cross-file resolution (TFooBar → FooBar.pas)
|
||||
resolved = _pascal_resolve_class(path, base_name)
|
||||
base_nid = resolved if resolved else _make_id(base_name)
|
||||
if base_nid not in seen_ids:
|
||||
# Stub for RTL/external/cross-file base classes
|
||||
add_node(base_nid, base_name, line)
|
||||
if resolved:
|
||||
# Cross-file base class found on disk -- its
|
||||
# real node arrives via THAT file's own
|
||||
# extraction. Do not add a duplicate stub
|
||||
# here: it would carry this file's
|
||||
# source_file (wrong) and collide with the
|
||||
# real node under cross-file id
|
||||
# disambiguation, producing two different
|
||||
# salted ids for what should be one class.
|
||||
base_nid = resolved
|
||||
else:
|
||||
base_nid = _make_id(base_name)
|
||||
if base_nid not in seen_ids:
|
||||
# Stub for RTL/external base classes.
|
||||
add_node(base_nid, base_name, line)
|
||||
add_edge(cls_nid, base_nid, "inherits", line)
|
||||
for child in kind_node.children:
|
||||
walk(child, cls_nid)
|
||||
@@ -476,7 +605,7 @@ def extract_pascal(path: Path) -> dict:
|
||||
line,
|
||||
)
|
||||
if body_node:
|
||||
proc_bodies.append((proc_nid, body_node))
|
||||
proc_bodies.append((proc_nid, body_node, container, label.removesuffix("()").lower()))
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
@@ -484,12 +613,34 @@ def extract_pascal(path: Path) -> dict:
|
||||
|
||||
walk(root, file_nid)
|
||||
|
||||
# Second pass: resolve calls inside procedure/function bodies
|
||||
all_procs: dict[str, str] = {
|
||||
n["label"].removesuffix("()").lower(): n["id"]
|
||||
for n in nodes if n["id"] != file_nid
|
||||
}
|
||||
# Second pass: resolve calls inside procedure/function bodies, scoped by
|
||||
# the caller's own class, then its ancestor chain, then file-level free
|
||||
# functions, falling back to an unambiguous global match (see
|
||||
# _resolve_pascal_callee_factory).
|
||||
resolve_callee = _resolve_pascal_callee_factory(proc_bodies, edges, module_nid)
|
||||
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:
|
||||
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({
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"caller_nid": caller_nid,
|
||||
"callee": name_lower,
|
||||
})
|
||||
return
|
||||
pair = (caller_nid, target)
|
||||
if pair not in seen_call_pairs:
|
||||
seen_call_pairs.add(pair)
|
||||
add_edge(caller_nid, target, "calls", line, context="call")
|
||||
|
||||
def walk_calls(node, caller_nid: str) -> None: # type: ignore[no-untyped-def]
|
||||
if node.type == "exprCall":
|
||||
@@ -499,34 +650,21 @@ def extract_pascal(path: Path) -> dict:
|
||||
callee_text = _read(child).split(".")[-1]
|
||||
break
|
||||
if callee_text:
|
||||
callee_nid = all_procs.get(callee_text.lower())
|
||||
if callee_nid and callee_nid != caller_nid:
|
||||
pair = (caller_nid, callee_nid)
|
||||
if pair not in seen_call_pairs:
|
||||
seen_call_pairs.add(pair)
|
||||
add_edge(
|
||||
caller_nid, callee_nid, "calls",
|
||||
node.start_point[0] + 1, context="call",
|
||||
)
|
||||
_emit_or_report(caller_nid, callee_text.lower(), node.start_point[0] + 1)
|
||||
elif node.type == "statement":
|
||||
# Pascal bare procedure calls with no args: `Reset;`
|
||||
# tree-sitter represents these as statement → identifier (no exprCall wrapper)
|
||||
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])
|
||||
callee_nid = all_procs.get(callee_text.lower())
|
||||
if callee_nid and callee_nid != caller_nid:
|
||||
pair = (caller_nid, callee_nid)
|
||||
if pair not in seen_call_pairs:
|
||||
seen_call_pairs.add(pair)
|
||||
add_edge(
|
||||
caller_nid, callee_nid, "calls",
|
||||
node.start_point[0] + 1, context="call",
|
||||
)
|
||||
_emit_or_report(caller_nid, callee_text.lower(), node.start_point[0] + 1)
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
for proc_nid, body_node in proc_bodies:
|
||||
for proc_nid, body_node, _container, _name_lower in proc_bodies:
|
||||
walk_calls(body_node, proc_nid)
|
||||
|
||||
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
|
||||
return {
|
||||
"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0,
|
||||
"raw_calls": raw_calls,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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()
|
||||
# Count DISTINCT methods, not edge multiplicity: the tree-sitter
|
||||
# Pascal extractor emits one `method` edge for the interface
|
||||
# declaration and one for the implementation, so the same
|
||||
# method_nid arrives twice. Deduping keeps the single-owner
|
||||
# god-node guard below (`len(candidates) == 1`) measuring real
|
||||
# same-name collisions across classes, not the same method
|
||||
# double-counted -- otherwise every inherited call looks ambiguous.
|
||||
bucket = class_procs.setdefault(owner, {}).setdefault(name_lower, [])
|
||||
if method_nid not in bucket:
|
||||
bucket.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,
|
||||
})
|
||||
Reference in New Issue
Block a user