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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi
| `dm` | BYOND DreamMaker `.dm`/`.dme` AST extraction (may need a C compiler + `python3-dev` if no wheel matches your platform) | `uv tool install "graphifyy[dm]"` |
| `terraform` | Terraform / HCL `.tf`/`.tfvars`/`.hcl` AST extraction | `uv tool install "graphifyy[terraform]"` |
| `pascal` | Pascal / Delphi `.pas`/`.dpr`/`.dpk`/`.inc` AST extraction (more accurate `calls`/`inherits` edges; falls back to a regex extractor when absent) | `uv tool install "graphifyy[pascal]"` |
| `vhdl` | VHDL `.vhd`/`.vhdl` AST extraction (entities, architectures, packages, subprograms) | `uv tool install "graphifyy[vhdl]"` |
| `ocaml` | OCaml `.ml`/`.mli` AST extraction | `uv tool install "graphifyy[ocaml]"` |
| `commonlisp` | Common Lisp `.lisp`/`.cl`/`.lsp`/`.asd` AST extraction | `uv tool install "graphifyy[commonlisp]"` |
| `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` |
Expand Down Expand Up @@ -341,6 +342,8 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
| 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) |
| 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]`) |
| VHDL | `.vhd .vhdl` (entities, architectures, packages, subprograms, use-clause imports, component instantiation edges; requires `uv tool install graphifyy[vhdl]`) |
| Tcl | `.tcl` (proc/namespace/package require/source extraction via regex — no tree-sitter grammar dependency) |
| OCaml | `.ml .mli` (requires `uv tool install graphifyy[ocaml]`) |
| Common Lisp | `.lisp .cl .lsp .asd` (requires `uv tool install graphifyy[commonlisp]`) |
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
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', '.vhd', '.vhdl', '.tcl', '.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'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
Expand Down
5 changes: 5 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
from graphify.extractors.sql import extract_sql # noqa: F401
from graphify.extractors.terraform import extract_terraform # noqa: F401
from graphify.extractors.verilog import extract_verilog # noqa: F401
from graphify.extractors.vhdl import extract_vhdl # noqa: F401
from graphify.extractors.tcl import extract_tcl # noqa: F401
from graphify.extractors.zig import extract_zig # noqa: F401
from graphify.security import sanitize_metadata
from graphify.paths import disambiguate_ambiguous_candidates
Expand Down Expand Up @@ -4932,6 +4934,9 @@ def add_existing_edge(edge: dict) -> None:
".v": extract_verilog,
".sv": extract_verilog,
".svh": extract_verilog,
".vhd": extract_vhdl,
".vhdl": extract_vhdl,
".tcl": extract_tcl,
".sql": extract_sql,
".md": extract_markdown,
".mdx": extract_markdown,
Expand Down
96 changes: 96 additions & 0 deletions graphify/extractors/tcl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Tcl extractor for graphify.

Extracts proc definitions, namespace evals, and package requires from .tcl
files using regex — there is no tree-sitter-tcl package on PyPI yet.

Handles both flat proc names and namespace-qualified names (e.g. `::ns::proc`).
"""
from __future__ import annotations

import re
from pathlib import Path

from graphify.extractors.base import _file_stem, _make_id


def extract_tcl(path: Path) -> dict:
"""Extract procs, namespaces, and package imports from a .tcl file."""
try:
source = path.read_text(encoding="utf-8", errors="replace")
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()

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}",
"confidence_score": 1.0,
})

def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", score: float = 1.0,
target_file: str | None = None) -> None:
edge = {
"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "confidence_score": score,
"source_file": str_path, "source_location": f"L{line}",
"weight": 1.0,
}
# Transient resolved-target hint (mirrors bash.py's source-statement
# resolution) — lets the extract() id-remap pass canonicalize this
# edge onto the sourced file's real file-node id even when that file
# isn't in the current extraction batch. Popped before persisting.
if target_file is not None:
edge["target_file"] = target_file
edges.append(edge)

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

# proc definitions — flat and namespace-qualified (::ns::name or ns::name)
for m in re.finditer(r'^\s*proc\s+([\w:]+)', source, re.MULTILINE):
name = m.group(1)
line = source[: m.start()].count("\n") + 1
nid = _make_id(stem, name)
add_node(nid, name, line)
add_edge(file_nid, nid, "defines", line)

# namespace eval blocks
for m in re.finditer(r'^\s*namespace\s+eval\s+([\w:]+)', source, re.MULTILINE):
ns = m.group(1)
line = source[: m.start()].count("\n") + 1
nid = _make_id(stem, ns)
if nid not in seen_ids:
add_node(nid, ns, line)
add_edge(file_nid, nid, "contains", line, "INFERRED", 0.8)

# package require → import edge
for m in re.finditer(r'^\s*package\s+require\s+([\w:]+)', source, re.MULTILINE):
pkg = m.group(1)
line = source[: m.start()].count("\n") + 1
tgt = _make_id(pkg)
add_node(tgt, pkg, line)
add_edge(file_nid, tgt, "imports_from", line)

# source <file> → import edge (quotes/braces optional). Resolve against this
# file's directory so the edge targets the sourced file's real file-node id
# (_make_id(str(path))) rather than a bare-filename id no file node ever
# uses — mirrors bash.py's source-statement resolution. Only emit when the
# target exists on disk, guarding against path-traversal-crafted sources.
for m in re.finditer(r'^\s*source\s+\{?"?([\w./\\-]+\.tcl)"?\}?', source, re.MULTILINE):
filename = m.group(1)
line = source[: m.start()].count("\n") + 1
resolved = (path.parent / filename).resolve()
if resolved.exists():
tgt = _make_id(str(resolved))
add_edge(file_nid, tgt, "imports_from", line, target_file=str(resolved))

return {"nodes": nodes, "edges": edges}
155 changes: 155 additions & 0 deletions graphify/extractors/vhdl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""VHDL extractor for graphify.

Extracts entities, architectures, packages, subprograms, use-clause imports,
and component instantiation edges from .vhd/.vhdl files via tree-sitter-vhdl.

Grammar: alemuller/tree-sitter-vhdl (MIT) — covers VHDL-93 through VHDL-2008.
Requires: pip install tree-sitter-vhdl
"""
from __future__ import annotations

from pathlib import Path

from graphify.extractors.base import _file_stem, _make_id


def _first_id(node, source: bytes) -> str | None:
"""Return the text of the first identifier/library_namespace/label child."""
if node is None:
return None
for c in node.children:
if c.type in ("identifier", "library_namespace", "label"):
return source[c.start_byte:c.end_byte].decode("utf-8", errors="replace").strip()
return None


def extract_vhdl(path: Path) -> dict:
"""Extract VHDL design units and their relationships from a .vhd/.vhdl file."""
try:
import tree_sitter_vhdl as tsvhdl
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-vhdl not installed; run: pip install tree-sitter-vhdl"}

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

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}",
"confidence_score": 1.0,
})

def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", score: float = 1.0) -> None:
edges.append({
"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "confidence_score": score,
"source_file": str_path, "source_location": f"L{line}",
"weight": 1.0,
})

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

def walk(node, scope_nid: str | None = None) -> None:
t = node.type

if t == "entity_declaration":
name = _first_id(node, source)
if name:
line = node.start_point[0] + 1
nid = _make_id(stem, name)
add_node(nid, name, line)
add_edge(file_nid, nid, "defines", line)
for c in node.children:
walk(c, nid)
return

elif t == "architecture_definition":
arch_name = _first_id(node, source)
entity_name = next(
(_first_id(c, source) for c in node.children if c.type == "name"), None)
if arch_name:
line = node.start_point[0] + 1
nid = _make_id(stem, arch_name)
add_node(nid, arch_name, line)
add_edge(file_nid, nid, "defines", line)
if entity_name:
# Same id scheme as entity_declaration's own node (_make_id(stem, name))
# — architecture and entity are almost always in the same file, so this
# resolves to the real entity node instead of minting a disconnected one.
tgt = _make_id(stem, entity_name)
add_node(tgt, entity_name, line)
add_edge(nid, tgt, "implements", line, "INFERRED", 0.9)
for c in node.children:
walk(c, nid)
return

elif t in ("package_declaration", "package_definition"):
name = _first_id(node, source)
if name:
line = node.start_point[0] + 1
nid = _make_id(stem, name)
add_node(nid, name, line)
add_edge(file_nid, nid, "defines", line)
for c in node.children:
walk(c, nid)
return

elif t in ("subprogram_declaration", "subprogram_definition"):
subprog_name = next(
(_first_id(c, source) for c in node.children
if c.type in ("function_specification", "procedure_specification")),
None)
if subprog_name:
line = node.start_point[0] + 1
parent = scope_nid or file_nid
nid = _make_id(parent, subprog_name)
add_node(nid, f"{subprog_name}()", line)
add_edge(parent, nid, "contains", line)

elif t == "component_instantiation_statement" and scope_nid:
inst_type = next(
(_first_id(c, source) for c in node.children if c.type == "name"), None)
if inst_type:
line = node.start_point[0] + 1
tgt = _make_id(inst_type)
add_node(tgt, inst_type, line)
add_edge(scope_nid, tgt, "instantiates", line, "INFERRED", 0.9)

elif t == "use_clause":
for sel_list in node.children:
for sel_name in sel_list.children:
if sel_name.type == "selected_name":
ids = [c for c in sel_name.children
if c.type in ("identifier", "library_namespace")]
if len(ids) >= 2:
pkg = source[ids[1].start_byte:ids[1].end_byte].decode(
"utf-8", errors="replace").strip()
if pkg.lower() != "all":
line = node.start_point[0] + 1
tgt = _make_id(pkg)
add_node(tgt, pkg, line)
add_edge(scope_nid or file_nid, tgt, "imports_from", line)

for c in node.children:
walk(c, scope_nid)

walk(root)
return {"nodes": nodes, "edges": edges}
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ pascal = ["tree-sitter-pascal"]
# avoids breaking the default `uv tool install graphifyy` for everyone (#1104).
dm = ["tree-sitter-dm"]
terraform = ["tree-sitter-hcl"]
# extract_vhdl() needs tree-sitter-vhdl for entity/architecture/package extraction.
# Ships prebuilt wheels for every platform, but kept optional (like pascal/sql)
# since VHDL projects are a minority of installs and the extractor already
# degrades gracefully — returns an "error" key instead of raising — when absent.
vhdl = ["tree-sitter-vhdl"]
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-vhdl"]
# tree-sitter-ocaml ships prebuilt abi3 wheels for every platform, so no C
# toolchain is needed; kept optional because OCaml is a niche corpus language.
ocaml = ["tree-sitter-ocaml"]
Expand Down