mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
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:
1 parent
a5ba60ddf6
commit
69e20c70e3
3 files changed
+52
-3
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user