Skip to content
Open
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
117 changes: 102 additions & 15 deletions graphify/extractors/pascal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_pascal()

fans out to 11 callees (efferent coupling); 17 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand All @@ -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"))
),
}
46 changes: 42 additions & 4 deletions graphify/pascal_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, []))
Expand All @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions tests/fixtures/pascal_singleton/Form1.pas
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions tests/fixtures/pascal_singleton/MainModule.pas
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions tests/fixtures/pascal_singleton/OtherModule.pas
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading