From da287edfb5be418b77f465f60e80c472d3b348d1 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sat, 10 Oct 2026 01:01:10 +0100 Subject: [PATCH] fix(powershell): extract functions inside file-scope begin/process/end blocks (#4270) File-scope begin/process/end/dynamicparam/clean parse as pseudo-commands (command_name 'begin'), not named_block, so the command branch returned before the child walk and any function/class defined inside was dropped. Descend into those five block keywords' children; ids stay name-based and stable. Co-Authored-By: Claude Opus 4.8 --- graphify/extractors/powershell.py | 14 ++++++++++++++ tests/test_languages.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/graphify/extractors/powershell.py b/graphify/extractors/powershell.py index fe0e4df38..51d561951 100644 --- a/graphify/extractors/powershell.py +++ b/graphify/extractors/powershell.py @@ -61,6 +61,10 @@ def extract_powershell(path: Path) -> dict: # Import commands — handled as import edges, not function calls "import-module", }) + # File-scope advanced-function blocks that parse as pseudo-commands rather + # than named_block nodes; walk() must descend into them to reach nested + # function/class/enum statements (#4270). + _PS_NAMED_BLOCK_CMDS = frozenset({"begin", "process", "end", "dynamicparam", "clean"}) def _find_script_block_body(node): for child in node.children: @@ -314,6 +318,16 @@ def extract_powershell(path: Path) -> dict: if bare: add_edge(file_nid, _make_id(bare), "imports_from", node.start_point[0] + 1) + elif cmd_text in _PS_NAMED_BLOCK_CMDS: + # File-scope begin/process/end/dynamicparam/clean blocks parse as a + # pseudo-command (command_name 'begin') whose `{ }` body is a + # script_block_expression under command_elements, NOT a real + # named_block (those exist only inside a function/scriptblock body). + # Descend so nested function/class/enum statements are extracted + # (#4270); without this the command branch returns before the + # generic child walk below and the whole block is dropped. + for child in node.children: + walk(child, parent_class_nid) return for child in node.children: diff --git a/tests/test_languages.py b/tests/test_languages.py index 03d458370..d54b4a642 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3078,6 +3078,30 @@ def test_powershell_psd1_no_dangling_edges(): assert e["source"] in node_ids, f"Dangling source in edge: {e}" +def test_powershell_functions_in_file_scope_begin_block_are_extracted(tmp_path): + # File-scope begin/process/end blocks parse as pseudo-commands (command_name + # 'begin'), not named_block, so functions defined inside them were dropped (#4270). + f = tmp_path / "advanced.ps1" + f.write_text( + "[CmdletBinding()]\n" + "param()\n" + "begin {\n" + " function Get-Foo { Get-Bar }\n" + " function Get-Bar { 'bar' }\n" + "}\n" + "process {\n" + " Get-Foo\n" + "}\n", + encoding="utf-8", + ) + r = extract_powershell(f) + assert "error" not in r + labels = {n["label"] for n in r["nodes"]} + assert "Get-Foo()" in labels and "Get-Bar()" in labels + # the call between the two helpers (inside Get-Foo's body) is recovered + assert ("Get-Foo", "Get-Bar") in _edge_labels(r, "calls") + + # ── TypeScript dynamic imports ─────────────────────────────────────────────── def test_ts_dynamic_import_no_error():