fix(powershell): dot-source quoted/$PSScriptRoot paths and script-scope calls (#4356)

Two gaps in the PowerShell extractor dropped edges from real-world
scripts:

- Dot-source only read the path when tree-sitter parsed it as
  command_name. `. "$PSScriptRoot\lib.ps1"` and `. '.\lib.ps1'` parse as
  string_literal, and unquoted `. $PSScriptRoot\lib.ps1` as
  path_command_name, so no imports_from edge was emitted. Accept all
  three and strip surrounding quotes; the existing basename logic
  already handles the rest.
- Calls were collected only from function/method bodies, so commands at
  script scope (the main body of most .ps1 scripts) produced no calls
  edge or raw_call. Walk the file root too, with the file node as the
  caller. walk_calls stops at function_statement/class_statement, so
  function bodies are not walked twice (covered by a control test).

`. (Join-Path $PSScriptRoot 'lib.ps1')` stays unresolved: the path is
only known at runtime.

AST cache schema 8 -> 9 so cached results for unchanged .ps1 files are
re-extracted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LcXGsdFdpLjYVHemLogkE
This commit is contained in:
Fabio RuedaandClaude Opus 5.5 committed 2026-10-12 11:23:07 -03:00
1 parent a5ba60ddf6
commit 69e20c70e3
3 files changed
+52 -3

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ except Exception:
_EXTRACTOR_VERSION = "unknown"
# Bump when AST cache-key semantics change independently of the package version.
_AST_CACHE_SCHEMA = 8 # Rust `use` groups: one imports_from edge per binding; prior (7): Python opaque-base/super(args) raw-call markers and nested-class enclosing ids.
_AST_CACHE_SCHEMA = 9 # PowerShell dot-source with quoted/$PSScriptRoot paths and script-scope calls; prior (8): Rust `use` groups: one imports_from edge per binding; prior (7): Python opaque-base/super(args) raw-call markers and nested-class enclosing ids.
# Per-process memo: distributions() scans site-packages metadata, which is
+8 -2
View File
@@ -264,11 +264,14 @@ def extract_powershell(path: Path) -> dict:
(c for c in node.children if c.type == "command_name_expr"), None
)
if name_expr is not None:
# Bare paths parse as command_name; `$PSScriptRoot\x.ps1` as path_command_name;
# quoted paths as string_literal. The basename logic below handles all three.
name_node = next(
(c for c in name_expr.children if c.type == "command_name"), None
(c for c in name_expr.children
if c.type in ("command_name", "path_command_name", "string_literal")), None
)
if name_node:
raw_path = _read_text(name_node, source)
raw_path = _read_text(name_node, source).strip().strip("'\"")
# Strip relative path prefix (./ or .\ or just the dot)
module_stem = re.sub(r'^[./\\]+', '', raw_path)
# Drop extension to get bare module name
@@ -414,6 +417,9 @@ def extract_powershell(path: Path) -> dict:
for caller_nid, body_node, parent_class_nid in function_bodies:
walk_calls(body_node, caller_nid, parent_class_nid)
# Script-scope commands (outside any function/class) are called by the file itself.
# walk_calls stops at function_statement/class_statement, so bodies are not walked twice.
walk_calls(root, file_nid, None)
clean_edges = [e for e in edges if e["source"] in seen_ids and
(e["target"] in seen_ids or e["relation"] in ("imports_from", "imports"))]
+43
View File
@@ -3092,6 +3092,49 @@ def test_powershell_dot_source_inside_function_emits_edge():
)
@pytest.mark.parametrize("line", [
'. "$PSScriptRoot\\lib.ps1"', # quoted path with variable (most common in real scripts)
". $PSScriptRoot\\lib.ps1", # unquoted path with variable -> path_command_name
". '.\\lib.ps1'", # single-quoted relative path -> string_literal
])
def test_powershell_dot_source_quoted_or_variable_path_emits_edge(tmp_path, line):
"""Dot-source with a quoted or $PSScriptRoot-based path emits imports_from (#4356)."""
f = tmp_path / "main.ps1"
f.write_text(line + "\n")
r = extract_powershell(f)
targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
assert any(t.endswith("lib") for t in targets), f"Missing dot-source lib edge; targets={targets}"
def test_powershell_script_scope_call_is_recorded(tmp_path):
"""A call at script scope (outside any function) is attributed to the file (#4356)."""
f = tmp_path / "main.ps1"
f.write_text(
"function Helper { return 1 }\n"
"Helper\n"
"Get-External\n"
)
r = extract_powershell(f)
calls = {e["target"] for e in r["edges"] if e["relation"] == "calls"}
raw = {c["callee"] for c in r["raw_calls"]}
assert any(t.endswith("helper") for t in calls), f"Missing script-scope call to Helper; calls={calls}"
assert "Get-External" in raw, f"Missing script-scope raw call; raw={raw}"
def test_powershell_script_scope_walk_does_not_duplicate_function_calls(tmp_path):
"""Walking the file root must not re-attribute calls made inside function bodies."""
f = tmp_path / "mod.ps1"
f.write_text(
"function Helper { return 1 }\n"
"function Run { Helper }\n"
)
r = extract_powershell(f)
file_nid = next(n["id"] for n in r["nodes"] if n["label"] == f.name)
callers = {e["source"] for e in r["edges"] if e["relation"] == "calls"}
callers |= {c["caller_nid"] for c in r["raw_calls"]}
assert file_nid not in callers, f"File node became a caller; callers={callers}"
# ── PowerShell manifest (.psd1) (#1331) ──────────────────────────────────────
def test_powershell_psd1_dispatched():