Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg

| Type | Extensions |
|------|-----------|
| Code (37 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .ml .mli .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
| Code (38 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .ml .mli .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .zsh .json .sas .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`, `.sas` requires `uv tool install graphifyy[sas]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar, `.zsh` reuses the Bash grammar) |
| Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) |
| Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) |
| OCaml | `.ml .mli` (requires `uv tool install graphifyy[ocaml]`) |
Expand Down
1 change: 1 addition & 0 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def _is_ast_tier(item: dict) -> bool:
".cxx": "c", ".hh": "c", ".hxx": "c",
".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c",
".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua",
".sh": "shell", ".bash": "shell", ".zsh": "shell", ".sas": "sas",
}


Expand Down
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class FileType(str, Enum):
_MTIME_COARSE_S = 2.0
_MTIME_SUBSECOND_S = 0.05

CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.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', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd'}
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.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', '.zsh', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.sas'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
Expand Down
8 changes: 6 additions & 2 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
from graphify.extractors.razor import extract_razor # noqa: F401
from graphify.extractors.rust import extract_rust # noqa: F401
from graphify.extractors.sas import extract_sas # noqa: F401
from graphify.extractors.sln import extract_sln # noqa: F401
from graphify.extractors.sql import extract_sql # noqa: F401
from graphify.extractors.terraform import extract_terraform # noqa: F401
Expand Down Expand Up @@ -2128,7 +2129,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool:
".ex": "elixir", ".exs": "elixir",
".jl": "julia",
".dart": "dart",
".sh": "shell", ".bash": "shell",
".sh": "shell", ".bash": "shell", ".zsh": "shell",
".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell",
}

Expand Down Expand Up @@ -4948,6 +4949,7 @@ def add_existing_edge(edge: dict) -> None:
".lpk": extract_lazarus_package,
".sh": extract_bash,
".bash": extract_bash,
".zsh": extract_bash,
".json": extract_json,
".tf": extract_terraform,
".tfvars": extract_terraform,
Expand All @@ -4967,6 +4969,7 @@ def add_existing_edge(edge: dict) -> None:
".cshtml": extract_razor,
".cls": extract_apex,
".trigger": extract_apex,
".sas": extract_sas,
}


Expand All @@ -4987,6 +4990,7 @@ def add_existing_edge(edge: dict) -> None:
".cl": "commonlisp",
".lsp": "commonlisp",
".asd": "commonlisp",
".sas": "sas",
}

# Substrings an extractor's error carries to classify why a dependency-backed
Expand Down Expand Up @@ -6196,7 +6200,7 @@ def _looks_like_bash(result: object) -> bool:

sh_pairs = [
(r, p) for r, p in zip(per_file, paths)
if p.suffix in (".sh", ".bash") or _looks_like_bash(r)
if p.suffix in (".sh", ".bash", ".zsh") or _looks_like_bash(r)
]
if sh_pairs:
sh_results = [r for r, _ in sh_pairs]
Expand Down
2 changes: 2 additions & 0 deletions graphify/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest
from graphify.extractors.razor import extract_razor
from graphify.extractors.rust import extract_rust
from graphify.extractors.sas import extract_sas
from graphify.extractors.sln import extract_sln
from graphify.extractors.sql import extract_sql
from graphify.extractors.terraform import extract_terraform
Expand Down Expand Up @@ -58,6 +59,7 @@
"powershell_manifest": extract_powershell_manifest,
"razor": extract_razor,
"rust": extract_rust,
"sas": extract_sas,
"sln": extract_sln,
"sql": extract_sql,
"terraform": extract_terraform,
Expand Down
10 changes: 8 additions & 2 deletions graphify/extractors/bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
from graphify.extractors.base import _file_stem, _make_id, _read_text


# Shell-script suffixes the bash extractor resolves as source/invocation
# targets. `.zsh` is included so zsh files route through the same source-edge
# pass (#2825); `.ksh` is accepted for symmetry with the shebang dispatcher.
_SHELL_SUFFIXES = (".sh", ".bash", ".zsh", ".ksh")


# Leading `${VAR}` / `$VAR` expansion segment(s) of a `source` path argument. The
# canonical `BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom makes
# such a variable resolve to the script's own directory, so the literal suffix that
Expand Down Expand Up @@ -456,10 +462,10 @@ def walk(node, parent_nid: str) -> None:
add_edge(file_nid, tgt_nid, "imports", line,
context="import")
elif cmd and cmd not in defined_functions:
raw = cmd if cmd.endswith(".sh") else None
raw = cmd if cmd.endswith(_SHELL_SUFFIXES) else None
if cmd in _BASH_SCRIPT_RUNNERS and args:
raw = literal(args[0])
if raw and raw.endswith(".sh"):
if raw and raw.endswith(_SHELL_SUFFIXES):
resolved = (path.parent / raw).resolve()
if resolved.is_file():
target_path = resolved
Expand Down
131 changes: 131 additions & 0 deletions graphify/extractors/sas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""SAS extractor (tree-sitter).

Extracts data steps, proc steps, and %macro definitions from a .sas file,
plus calls edges from %macro call sites to macros defined in the same file.
"""
from __future__ import annotations

from pathlib import Path
from typing import Any

from graphify.extractors.base import _file_stem, _make_id, _read_text


def extract_sas(path: Path) -> dict:
Comment thread
SomSamantray marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_sas()

fans out to 8 callees (efferent coupling); 12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""Extract data/proc steps and macro definitions from a .sas file."""
try:
import tree_sitter_sas as tssas
from tree_sitter import Language, Parser
except ImportError as e:
import importlib.util
# Distinguish a genuinely-absent grammar from an installed-but-broken
# one (e.g. a C extension built for a different Python ABI, #2602) so
# the #1745 warning does not send the user to a no-op install.
if importlib.util.find_spec("tree_sitter_sas") is None:
return {"nodes": [], "edges": [],
"error": "tree_sitter_sas not installed"}
return {"nodes": [], "edges": [],
"error": f"tree_sitter_sas is installed but failed to load: {e}"}

try:
language = Language(tssas.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)}

stem = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
seen_edges: set[tuple[str, str, str]] = set()
macro_defs: dict[str, str] = {}

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_edge(src: str, tgt: str, relation: str, line: int,
context: str | None = None) -> None:
key = (src, tgt, relation)
if key in seen_edges:
return
seen_edges.add(key)
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0}
if context:
edge["context"] = context
edges.append(edge)

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

def _child_text(node: Any, child_types: tuple[str, ...]) -> str | None:
for child in node.children:
if child.type in child_types:
return _read_text(child, source).strip()
return None

def _macro_name_text(node: Any) -> str | None:
return _child_text(node, ("macro_name",))

# First pass: collect macro definitions (case-insensitively, per SAS) so
# call sites resolve regardless of where the definition appears.
for node in root.children:
if node.type == "macro_definition":
name = _macro_name_text(node)
if name:
macro_defs[name.casefold()] = _make_id(stem, name)

def _step_label(node: Any) -> str | None:
text = _child_text(node, ("data_step_header", "proc_step_header"))
# strip the trailing `;` so the label reads `data work.customers`
return text.rstrip(";").strip() if text else None

def _emit_macro_calls(node: Any) -> None:
"""Emit calls edges for macro call statements anywhere in the subtree."""
stack = [node]
while stack:
current = stack.pop()
if current.type == "macro_call_statement":
name = _macro_name_text(current)
if name:
nid = macro_defs.get(name.casefold())
if nid:
add_edge(file_nid, nid, "calls",
current.start_point.row + 1, context="call")
stack.extend(current.children)

for node in root.children:
if node.type == "macro_definition":
name = _macro_name_text(node)
if not name:
continue
nid = macro_defs[name.casefold()]
add_node(nid, f"%{name}", node.start_point.row + 1)
add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="macro")
_emit_macro_calls(node)
elif node.type == "data_step":
label = _step_label(node) or "data"
# disambiguate by byte offset so multiple steps (even on one line)
# stay distinct
nid = _make_id(stem, "data", str(node.start_byte))
add_node(nid, label, node.start_point.row + 1)
add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="data_step")
_emit_macro_calls(node)
elif node.type == "proc_step":
label = _step_label(node) or "proc"
nid = _make_id(stem, "proc", str(node.start_byte))
add_node(nid, label, node.start_point.row + 1)
add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="proc_step")
_emit_macro_calls(node)
else:
_emit_macro_calls(node)

return {"nodes": nodes, "edges": edges}
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ ocaml = ["tree-sitter-ocaml"]
# tree-sitter-commonlisp ships prebuilt abi3 wheels for every platform; optional
# because Common Lisp is a niche corpus language.
commonlisp = ["tree-sitter-commonlisp"]
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp"]
# tree-sitter-sas ships prebuilt wheels for every platform; optional because SAS
# is a niche corpus language.
sas = ["tree-sitter-sas"]
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "tree-sitter-sas"]

[project.scripts]
graphify = "graphify.__main__:main"
Expand All @@ -116,6 +119,7 @@ dev = [
"tree-sitter-hcl>=1.2.0",
"tree-sitter-ocaml>=0.25.0",
"tree-sitter-commonlisp>=0.4.1",
"tree-sitter-sas>=0.4.2",
]

[tool.uv]
Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures/sample.sas
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
%macro greet(name);
%put Hello &name;
%mend greet;

data work.customers;
set raw.import;
length name $ 50;
run;

proc sort data=work.customers;
by name;
run;

%greet(World);
14 changes: 14 additions & 0 deletions tests/fixtures/sample.zsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env zsh
set -euo pipefail

greet() {
print "Hello, $1"
}

deploy() {
local env_name="${1:-production}"
greet "$env_name"
print "Deploying to $env_name"
}

deploy staging
8 changes: 8 additions & 0 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ 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_zsh():
# #2825: .zsh was missing from CODE_EXTENSIONS so zsh scripts were unclassified.
assert classify_file(Path("script.zsh")) == FileType.CODE

def test_classify_sas():
# #2681: .sas files should be classified as CODE so the SAS extractor runs.
assert classify_file(Path("model.sas")) == FileType.CODE

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

Expand Down
Loading