Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class FileType(str, Enum):

_MANIFEST_PATH = "graphify-out/manifest.json"

CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger'}
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
Expand Down
239 changes: 238 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -6962,6 +6962,8 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
"using", "return", "if", "else", "elseif", "foreach", "for",
"while", "do", "switch", "try", "catch", "finally", "throw",
"break", "continue", "exit", "param", "begin", "process", "end",
# Import commands — handled as import edges, not function calls
"import-module",
})

def _find_script_block_body(node):
Expand Down Expand Up @@ -7011,6 +7013,10 @@ def walk(node, parent_class_nid: str | None = None) -> None:
body = _find_script_block_body(node)
if body:
function_bodies.append((func_nid, body))
# Also walk the body during the main pass so that
# Import-Module / dot-source inside functions emit
# file-level imports_from edges (#1331).
walk(body, parent_class_nid)
return

if t == "class_statement":
Expand Down Expand Up @@ -7081,6 +7087,31 @@ def walk(node, parent_class_nid: str | None = None) -> None:
return

if t == "command":
# Dot-sourcing: `. ./Shared.psm1`
# Uses command_invokation_operator '.' + command_name_expr (not command_name)
invoke_op = next(
(c for c in node.children if c.type == "command_invokation_operator"), None
)
if invoke_op is not None and _read_text(invoke_op, source).strip() == ".":
name_expr = next(
(c for c in node.children if c.type == "command_name_expr"), None
)
if name_expr is not None:
name_node = next(
(c for c in name_expr.children if c.type == "command_name"), None
)
if name_node:
raw_path = _read_text(name_node, source)
# Strip relative path prefix (./ or .\ or just the dot)
module_stem = re.sub(r'^[./\\]+', '', raw_path)
# Drop extension to get bare module name
module_stem = re.sub(r'\.[^.]+$', '', module_stem).replace('\\', '/')
module_name = module_stem.split('/')[-1]
if module_name:
add_edge(file_nid, _make_id(module_name), "imports_from",
node.start_point[0] + 1)
return

cmd_name_node = next((c for c in node.children if c.type == "command_name"), None)
if cmd_name_node:
cmd_text = _read_text(cmd_name_node, source).lower()
Expand All @@ -7097,6 +7128,29 @@ def walk(node, parent_class_nid: str | None = None) -> None:
module_name = module_tokens[-1].split(".")[-1]
add_edge(file_nid, _make_id(module_name), "imports_from",
node.start_point[0] + 1)
elif cmd_text == "import-module":
# Collect generic_token args; skip command_parameter flags like -Name
# The module name is the first generic_token (or the one after -Name)
module_name: str | None = None
expect_name = False
for child in node.children:
if child.type != "command_elements":
continue
for el in child.children:
if el.type == "command_parameter":
param_text = _read_text(el, source).lstrip("-").lower()
expect_name = param_text in ("name", "n")
elif el.type == "generic_token":
token = _read_text(el, source)
if module_name is None or expect_name:
module_name = token
expect_name = False
if module_name:
# Strip extension; keep only the stem for the node ID
bare = re.sub(r'\.[^.]+$', '', module_name).split('/')[-1].split('\\')[-1]
if bare:
add_edge(file_nid, _make_id(bare), "imports_from",
node.start_point[0] + 1)
return

for child in node.children:
Expand Down Expand Up @@ -7139,10 +7193,192 @@ def walk_calls(node, caller_nid: str) -> None:
walk_calls(body_node, caller_nid)

clean_edges = [e for e in edges if e["source"] in seen_ids and
(e["target"] in seen_ids or e["relation"] == "imports_from")]
(e["target"] in seen_ids or e["relation"] in ("imports_from", "imports"))]
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}


# ── PowerShell manifest (.psd1) ──────────────────────────────────────────────

# Keys in a .psd1 whose values are module names/paths we treat as imports.
_PSD1_IMPORT_KEYS = frozenset({"RootModule", "NestedModules", "RequiredModules"})


def _psd1_collect_string_literals(node, source: bytes) -> list[str]:
"""Recursively collect all string_literal text values under *node*."""
results: list[str] = []

def _walk(n) -> None:
if n.type == "string_literal":
raw = source[n.start_byte:n.end_byte].decode(errors="replace")
# Strip surrounding quote chars (' or ")
results.append(raw.strip("'\""))
return
for child in n.children:
_walk(child)

_walk(node)
return results


def _psd1_module_name(raw: str) -> str:
"""Derive a bare module name from a raw string value.

e.g. 'MyModule.psm1' → 'MyModule', './sub/Util.psm1' → 'Util', 'PSReadLine' → 'PSReadLine'
"""
# Strip path prefix and extension
name = raw.replace("\\", "/").split("/")[-1]
name = re.sub(r"\.[^.]+$", "", name) # remove last extension
return name.strip()


def extract_powershell_manifest(path: Path) -> dict:
"""Extract module dependency edges from a PowerShell .psd1 manifest file.

.psd1 files are PowerShell data hashtables, not scripts. tree-sitter-powershell
parses them correctly (they are syntactically valid PS). We walk the AST looking
for RootModule, NestedModules, and RequiredModules keys and emit imports_from
edges for every referenced module.

RequiredModules supports two forms:
- Simple string: 'PSReadLine'
- Module specification: @{ ModuleName = 'Pester'; ModuleVersion = '5.0' }
For the hashtable form we only follow the ModuleName key.
"""
try:
import tree_sitter_powershell as tsps
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_powershell not installed"}

try:
language = Language(tsps.language())
parser = Parser(language)
source = path.read_bytes()
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}

str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()

def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({"id": nid, "label": label, "file_type": "code",
"source_file": str_path, "source_location": f"L{line}"})

def add_import_edge(src: str, module_raw: str, line: int) -> None:
name = _psd1_module_name(module_raw)
if not name:
return
tgt_nid = _make_id(name)
edges.append({
"source": src,
"target": tgt_nid,
"relation": "imports_from",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
"context": "import",
})

file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)

def walk_manifest(node) -> None:
"""Walk the AST and emit edges for import-relevant hash_entry nodes."""
if node.type != "hash_entry":
for child in node.children:
walk_manifest(child)
return

# Identify the key
key_node = next((c for c in node.children if c.type == "key_expression"), None)
if key_node is None:
return
key_text = source[key_node.start_byte:key_node.end_byte].decode(errors="replace").strip()

if key_text not in _PSD1_IMPORT_KEYS:
# Still recurse in case there are nested hashes (e.g. ModuleVersion entries
# contain sub-hashes, but we only care about top-level keys for imports)
return

line = node.start_point[0] + 1
value_node = next((c for c in node.children if c.type == "pipeline"), None)
if value_node is None:
return

if key_text == "RootModule":
# Value is a single string
strings = _psd1_collect_string_literals(value_node, source)
for s in strings:
add_import_edge(file_nid, s, line)

elif key_text == "NestedModules":
# Value is a string or @('a', 'b', ...) array — collect all string literals
strings = _psd1_collect_string_literals(value_node, source)
for s in strings:
add_import_edge(file_nid, s, line)

elif key_text == "RequiredModules":
# Two forms:
# 1) 'SimpleModule' — direct string literals in the array
# 2) @{ ModuleName = 'Foo'; ModuleVersion = '2.0' } — use ModuleName only
#
# Strategy: walk the value for hash_entry nodes whose key is 'ModuleName';
# collect their string values. For the remaining string_literal nodes that
# are NOT inside a hash_entry subtree, treat them as simple module names.
module_name_strings: list[str] = []
inside_hash_entries: set[int] = set() # byte offsets of handled strings

def find_modulename_entries(n) -> None:
if n.type == "hash_entry":
sub_key = next((c for c in n.children if c.type == "key_expression"), None)
if sub_key is not None:
sk_text = source[sub_key.start_byte:sub_key.end_byte].decode(errors="replace").strip()
# Collect strings inside *all* sub-keys so we can exclude them
for c in n.children:
if c.type == "pipeline":
for s_node in _collect_string_nodes(c):
inside_hash_entries.add(s_node.start_byte)
if sk_text == "ModuleName":
for c in n.children:
if c.type == "pipeline":
for s in _psd1_collect_string_literals(c, source):
module_name_strings.append(s)
return # don't recurse further into this hash_entry
for child in n.children:
find_modulename_entries(child)

def _collect_string_nodes(n):
"""Return all string_literal nodes in subtree."""
if n.type == "string_literal":
yield n
return
for child in n.children:
yield from _collect_string_nodes(child)

find_modulename_entries(value_node)

# Now gather direct string literals not inside hash entries
direct_strings: list[str] = []
for s_node in _collect_string_nodes(value_node):
if s_node.start_byte not in inside_hash_entries:
raw = source[s_node.start_byte:s_node.end_byte].decode(errors="replace")
direct_strings.append(raw.strip("'\""))

for s in direct_strings + module_name_strings:
add_import_edge(file_nid, s, line)

walk_manifest(root)

return {"nodes": nodes, "edges": edges, "raw_calls": []}


# ── Cross-file import resolution ──────────────────────────────────────────────

def _source_key(source_file: str, root: Path) -> str:
Expand Down Expand Up @@ -11597,6 +11833,7 @@ def _body_of(block):
".zig": extract_zig,
".ps1": extract_powershell,
".psm1": extract_powershell,
".psd1": extract_powershell_manifest,
".ex": extract_elixir,
".exs": extract_elixir,
".m": extract_objc,
Expand Down
13 changes: 13 additions & 0 deletions tests/fixtures/sample.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@{
RootModule = 'MyModule.psm1'
ModuleVersion = '1.0.0'
GUID = 'aaaabbbb-cccc-dddd-eeee-ffffffffffff'
Author = 'Test Author'
Description = 'A sample module manifest for graphify tests.'
NestedModules = @('Helpers.psm1', 'Logger.psm1')
RequiredModules = @(
'PSReadLine',
@{ ModuleName = 'Pester'; ModuleVersion = '5.0' }
)
FunctionsToExport = @('Get-Data', 'Process-Items')
}
10 changes: 10 additions & 0 deletions tests/fixtures/sample_import.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Import-Module Foo
Import-Module -Name Bar.psm1
. ./Shared.psm1
. .\Utils.ps1

function Invoke-Main {
Import-Module InnerMod
. ./InnerShared.psm1
Get-Data
}
4 changes: 4 additions & 0 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ def test_classify_powershell_module():
# #1315: .psm1 modules were never indexed (CODE_EXTENSIONS gap).
assert classify_file(Path("Utils.psm1")) == FileType.CODE

def test_classify_powershell_manifest():
# #1331: .psd1 manifests must be classified as CODE so the manifest extractor runs.
assert classify_file(Path("MyModule.psd1")) == FileType.CODE

def test_classify_markdown():
assert classify_file(Path("README.md")) == FileType.DOCUMENT

Expand Down
Loading