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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ Both encourage scoped graph queries before broad source searches. Choose the ins

graphify parses ~40 programming languages locally with tree-sitter AST, and maps docs, PDFs, images, and audio/video through an optional semantic pass. Full list: **[Supported inputs](https://docs.graphify.com/concepts/supported-inputs)**.

Shell extraction also supports `.zsh` files through the Bash grammar. SAS `.sas` files use the optional `graphifyy[sas]` extra.

---

## Common commands
Expand Down
1 change: 1 addition & 0 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ def finalize_raw_graph_endpoints(data: dict) -> None:
".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
1 change: 1 addition & 0 deletions graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class FileType(str, Enum):
_MTIME_SUBSECOND_S = 0.05

CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.vb', '.cbl', '.cob', '.cobol', '.cpy', '.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', '.vh', '.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', '.robot', '.resource', '.sol', '.erl', '.hrl', '.escript'}
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.vb', '.cbl', '.cob', '.cobol', '.cpy', '.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', '.vh', '.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', '.robot', '.resource', '.sol', '.erl', '.hrl', '.escript', '.sas'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml', '.j2', '.jinja', '.jinja2'}
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 @@ -74,6 +74,7 @@
from graphify.extractors.razor import extract_razor # noqa: F401
from graphify.extractors.robot import extract_robot # 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.solidity import ( # noqa: F401
extract_solidity,
Expand Down Expand Up @@ -3803,7 +3804,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 @@ -8631,6 +8632,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 @@ -8652,6 +8654,7 @@ def add_existing_edge(edge: dict) -> None:
".resource": extract_robot,
".cls": extract_apex,
".trigger": extract_apex,
".sas": extract_sas,
}


Expand All @@ -8678,6 +8681,7 @@ def add_existing_edge(edge: dict) -> None:
".cl": "commonlisp",
".lsp": "commonlisp",
".asd": "commonlisp",
".sas": "sas",
".robot": "robot",
".resource": "robot",
}
Expand Down Expand Up @@ -10215,7 +10219,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 @@ -31,6 +31,7 @@
from graphify.extractors.r import extract_r
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.solidity import extract_solidity
from graphify.extractors.sql import extract_sql
Expand Down Expand Up @@ -66,6 +67,7 @@
"r": extract_r,
"razor": extract_razor,
"rust": extract_rust,
"sas": extract_sas,
"sln": extract_sln,
"solidity": extract_solidity,
"sql": extract_sql,
Expand Down
14 changes: 10 additions & 4 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_source_bytes, _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 @@ -481,8 +487,8 @@ def walk(node, parent_nid: str) -> None:
add_edge(file_nid, tgt_nid, "imports", line,
context="import")
elif cmd not in defined_functions:
raw = cmd if (cmd and cmd.endswith(".sh")) else None
if cmd and cmd in _BASH_SCRIPT_RUNNERS and args:
raw = cmd if (cmd and cmd.endswith(_SHELL_SUFFIXES)) else None
if cmd in _BASH_SCRIPT_RUNNERS and args:
raw = literal(args[0])
if raw is None:
# The command name is itself an expansion, e.g.
Expand All @@ -497,9 +503,9 @@ def walk(node, parent_nid: str) -> None:
# a `..` segment, and the `resolved.is_file()` gate
# below means a wrong script-dir guess emits nothing.
cand = _read_text(cmd_name_node, source).strip().strip("'\"")
if cand.endswith(".sh") and "$" in cand:
if cand.endswith(_SHELL_SUFFIXES) and "$" in cand:
raw = _bash_source_suffix(cand) or None
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
135 changes: 135 additions & 0 deletions graphify/extractors/sas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""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 regression — extract_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"
# Row/column stays stable across LF and CRLF checkouts while keeping
# same-line steps distinct, like the C# scope IDs in the engine.
row, column = node.start_point
line = row + 1
nid = _make_id(stem, "data", f"{row}:{column}")
add_node(nid, label, line)
add_edge(file_nid, nid, "defines", line, context="data_step")
_emit_macro_calls(node)
elif node.type == "proc_step":
label = _step_label(node) or "proc"
row, column = node.start_point
line = row + 1
nid = _make_id(stem, "proc", f"{row}:{column}")
add_node(nid, label, line)
add_edge(file_nid, nid, "defines", line, 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 @@ -112,6 +112,9 @@ 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"]
# tree-sitter-sas ships prebuilt wheels for every platform; optional because SAS
# is a niche corpus language.
sas = ["tree-sitter-sas"]
# The official Robot Framework parser (robot.api) - pure Python, wheels for
# every platform, no C toolchain; optional because Robot Framework corpora are
# QA-automation specific.
Expand All @@ -123,7 +126,7 @@ vbnet = ["tree-sitter-vb-dotnet==0.3.0"]
r = ["tree-sitter-language-pack==0.11.0"]
erlang = ["tree-sitter-language-pack==0.11.0"]
solidity = ["tree-sitter-solidity==1.2.13"]
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.16.1", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "graspologic-native>=1.3.1,<2; python_version >= '3.13'", "python-docx", "openpyxl", "psycopg[binary]", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.7.4", "matplotlib", "pillow>=12.3.0", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba; python_version < '3.12'", "jieba-py>=0.46.12,<1; python_version >= '3.12'", "tree-sitter-dm>=0.25,<0.26", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "robotframework>=4.0", "tree-sitter-vb-dotnet==0.3.0", "tree-sitter-language-pack==0.11.0", "tree-sitter-solidity==1.2.13"]
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.16.1", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "graspologic-native>=1.3.1,<2; python_version >= '3.13'", "python-docx", "openpyxl", "psycopg[binary]", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.7.4", "matplotlib", "pillow>=12.3.0", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba; python_version < '3.12'", "jieba-py>=0.46.12,<1; python_version >= '3.12'", "tree-sitter-dm>=0.25,<0.26", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "robotframework>=4.0", "tree-sitter-vb-dotnet==0.3.0", "tree-sitter-language-pack==0.11.0", "tree-sitter-solidity==1.2.13", "tree-sitter-sas"]

[project.scripts]
graphify = "graphify.__main__:main"
Expand All @@ -148,6 +151,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 @@ -78,6 +78,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
Loading