mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
fix(powershell): record method calls ($this.M(), [Type]::M())
The call walker only visited `command` nodes, so every method invocation was dropped from the call graph. A method call parses as an invokation_expression (callee in the member_name child, args in argument_list); a bare property read ($this.Name) is a separate member_access node and correctly produces no call. Handle invokation_expression and resolve the member name against a method-only label map so a member call binds to a method, never to a free function that merely shares the name (fail-closed on unknown receivers). Fixes #3992
This commit is contained in:
1 parent
ef4450d9c2
commit
1e6a9029e4
2 files changed
+95
No files matched your search
@@ -309,6 +309,13 @@ def extract_powershell(path: Path) -> dict:
|
||||
walk(root)
|
||||
|
||||
label_to_nid = {n["label"].strip("()").lstrip(".").lower(): n["id"] for n in nodes}
|
||||
# Member calls (`$this.M()`, `[C]::M()`) must resolve only to a method, never
|
||||
# to a free function that happens to share the name. Methods are exactly the
|
||||
# nodes whose label is dot-prefixed (`.M()`), so key them separately (#3992).
|
||||
method_label_to_nid = {
|
||||
n["label"].strip("()").lstrip(".").lower(): n["id"]
|
||||
for n in nodes if n["label"].startswith(".")
|
||||
}
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
|
||||
@@ -336,6 +343,33 @@ def extract_powershell(path: Path) -> dict:
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
elif node.type == "invokation_expression":
|
||||
# A method call — `$this.Square(3)`, `$obj.Run()`, or the static
|
||||
# `[Calc]::Build()` form. All parse as an invokation_expression whose
|
||||
# callee is the `member_name` child and whose arguments sit in an
|
||||
# `argument_list` (a bare `$this.Name` property read is a separate
|
||||
# `member_access` node with no argument_list, so it never lands here).
|
||||
# Only the command form was handled before, so every method call was
|
||||
# dropped from the call graph (#3992).
|
||||
member_node = next((c for c in node.children if c.type == "member_name"), None)
|
||||
if member_node is not None:
|
||||
method_name = _read_text(member_node, source)
|
||||
tgt_nid = method_label_to_nid.get(method_name.lower())
|
||||
if tgt_nid and tgt_nid != caller_nid:
|
||||
pair = (caller_nid, tgt_nid)
|
||||
if pair not in seen_call_pairs:
|
||||
seen_call_pairs.add(pair)
|
||||
add_edge(caller_nid, tgt_nid, "calls",
|
||||
node.start_point[0] + 1,
|
||||
confidence="EXTRACTED", weight=1.0)
|
||||
elif method_name and not tgt_nid:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": method_name,
|
||||
"is_member_call": True,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
|
||||
@@ -2602,6 +2602,67 @@ def test_powershell_finds_class_and_method():
|
||||
assert any("Transform" in l for l in labels)
|
||||
|
||||
|
||||
def test_powershell_this_method_call_resolves(tmp_path):
|
||||
"""`$this.Square(3)` inside a class method is a call to a sibling method and
|
||||
must link the two. The invocation parses as an `invokation_expression`, but
|
||||
only the bare-command form was walked, so every method call was dropped from
|
||||
the call graph (#3992)."""
|
||||
f = tmp_path / "calc.ps1"
|
||||
f.write_text(
|
||||
"class Calc {\n"
|
||||
" [int] Square([int]$x) { return $x * $x }\n"
|
||||
" [int] Run() { return $this.Square(3) }\n"
|
||||
"}\n"
|
||||
)
|
||||
r = extract_powershell(f)
|
||||
assert "error" not in r
|
||||
assert ("Run", "Square") in _edge_labels(r, "calls")
|
||||
|
||||
|
||||
def test_powershell_static_method_call_resolves(tmp_path):
|
||||
"""The static `[Calc]::Make()` form is also an `invokation_expression` and
|
||||
must resolve to the method (#3992)."""
|
||||
f = tmp_path / "calc.ps1"
|
||||
f.write_text(
|
||||
"class Calc {\n"
|
||||
" static [Calc] Make() { return [Calc]::new() }\n"
|
||||
" [Calc] Run() { return [Calc]::Make() }\n"
|
||||
"}\n"
|
||||
)
|
||||
r = extract_powershell(f)
|
||||
assert ("Run", "Make") in _edge_labels(r, "calls")
|
||||
|
||||
|
||||
def test_powershell_member_call_does_not_bind_to_free_function(tmp_path):
|
||||
"""Fail-closed: a method call on an unknown receiver (`$obj.Process()`) must
|
||||
NOT bind to a free function that merely shares the name — member calls
|
||||
resolve only to methods (#3992)."""
|
||||
f = tmp_path / "calc.ps1"
|
||||
f.write_text(
|
||||
"function Process { return 1 }\n"
|
||||
"class Calc {\n"
|
||||
" [int] Run() {\n"
|
||||
" $obj = Get-Thing\n"
|
||||
" return $obj.Process()\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
r = extract_powershell(f)
|
||||
assert ("Run", "Process") not in _edge_labels(r, "calls")
|
||||
|
||||
|
||||
def test_powershell_command_call_still_resolves(tmp_path):
|
||||
"""Positive control: the bare-command call path that already worked must
|
||||
keep working (#3992)."""
|
||||
f = tmp_path / "mod.ps1"
|
||||
f.write_text(
|
||||
"function Helper { return 1 }\n"
|
||||
"function Run { Helper }\n"
|
||||
)
|
||||
r = extract_powershell(f)
|
||||
assert ("Run", "Helper") in _edge_labels(r, "calls")
|
||||
|
||||
|
||||
def test_powershell_class_base_type_emits_inherits_edge():
|
||||
# `class Circle : Shape` — the base type after ':' was previously dropped
|
||||
# because the handler only read the first simple_name (the class name).
|
||||
|
||||
Reference in new issue
Block a user