chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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