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:
rajatnagda45 committed 2026-10-02 13:47:31 +05:30
1 parent ef4450d9c2
commit 1e6a9029e4
2 files changed
+95

No files matched your search

+34
View File
@@ -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)
+61
View File
@@ -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).