diff --git a/graphify/cache.py b/graphify/cache.py index f4cddebc0..807fa0f66 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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 diff --git a/graphify/extractors/powershell.py b/graphify/extractors/powershell.py index 51d561951..0bc6c9409 100644 --- a/graphify/extractors/powershell.py +++ b/graphify/extractors/powershell.py @@ -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"))] diff --git a/tests/test_languages.py b/tests/test_languages.py index 4e9bdb2cb..99aa44cc6 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -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():