diff --git a/README.md b/README.md index 272c5f6f81..593061cb40 100644 --- a/README.md +++ b/README.md @@ -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]"` | @@ -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 | diff --git a/graphify/detect.py b/graphify/detect.py index 4cb123104c..616a27ad48 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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'} diff --git a/graphify/extract.py b/graphify/extract.py index a113d4a7ac..a9248129e1 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 @@ -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, diff --git a/graphify/extractors/tcl.py b/graphify/extractors/tcl.py new file mode 100644 index 0000000000..57169b0431 --- /dev/null +++ b/graphify/extractors/tcl.py @@ -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 → 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} diff --git a/graphify/extractors/vhdl.py b/graphify/extractors/vhdl.py new file mode 100644 index 0000000000..04eb2519ba --- /dev/null +++ b/graphify/extractors/vhdl.py @@ -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} diff --git a/pyproject.toml b/pyproject.toml index 882ef533cb..3dbc02cc54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]