From 0e3bd922b24926f829c03357e8df276354b6d939 Mon Sep 17 00:00:00 2001 From: Bob Spryn Date: Sat, 25 Jul 2026 19:08:18 -0400 Subject: [PATCH 1/2] feat: add Clojure structural extraction --- ARCHITECTURE.md | 17 +- README.md | 3 +- graphify/analyze.py | 1 + graphify/build.py | 1 + graphify/cli.py | 3 +- graphify/detect.py | 2 +- graphify/extract.py | 10 + graphify/extractors/__init__.py | 2 + graphify/extractors/clojure.py | 567 ++++++++++++++++++ graphify/skill-aider.md | 2 +- graphify/skill-devin.md | 2 +- graphify/skills/agents/references/update.md | 2 +- graphify/skills/amp/references/update.md | 2 +- graphify/skills/claude/references/update.md | 2 +- graphify/skills/claw/references/update.md | 2 +- graphify/skills/codex/references/update.md | 2 +- graphify/skills/copilot/references/update.md | 2 +- graphify/skills/droid/references/update.md | 2 +- graphify/skills/kilo/references/update.md | 2 +- graphify/skills/kiro/references/update.md | 2 +- graphify/skills/opencode/references/update.md | 2 +- graphify/skills/pi/references/update.md | 2 +- graphify/skills/trae/references/update.md | 2 +- graphify/skills/vscode/references/update.md | 2 +- graphify/skills/windows/references/update.md | 2 +- pyproject.toml | 6 +- tests/fixtures/sample.clj | 65 ++ tests/test_backend_extras.py | 7 + tests/test_detect.py | 4 + tests/test_extract.py | 14 + tests/test_languages.py | 243 +++++++- .../expected/graphify__skill-aider.md | 2 +- .../expected/graphify__skill-devin.md | 2 +- ...ify__skills__agents__references__update.md | 2 +- ...aphify__skills__amp__references__update.md | 2 +- ...ify__skills__claude__references__update.md | 2 +- ...phify__skills__claw__references__update.md | 2 +- ...hify__skills__codex__references__update.md | 2 +- ...fy__skills__copilot__references__update.md | 2 +- ...hify__skills__droid__references__update.md | 2 +- ...phify__skills__kilo__references__update.md | 2 +- ...phify__skills__kiro__references__update.md | 2 +- ...y__skills__opencode__references__update.md | 2 +- ...raphify__skills__pi__references__update.md | 2 +- ...phify__skills__trae__references__update.md | 2 +- ...ify__skills__vscode__references__update.md | 2 +- ...fy__skills__windows__references__update.md | 2 +- tools/skillgen/fragments/core/aider.md | 2 +- tools/skillgen/fragments/core/devin.md | 2 +- .../fragments/references/shared/update.md | 2 +- tools/skillgen/gen.py | 19 + uv.lock | 226 +++---- 52 files changed, 1109 insertions(+), 151 deletions(-) create mode 100644 graphify/extractors/clojure.py create mode 100644 tests/fixtures/sample.clj diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5672bf0df..72e6896d8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -57,11 +57,18 @@ Every extractor returns: ## Adding a new language extractor -1. Add a `extract_(path: Path) -> dict` function in `extract.py` following the existing pattern (tree-sitter parse → walk nodes → collect `nodes` and `edges` → call-graph second pass for INFERRED `calls` edges). -2. Register the file suffix in `extract()` dispatch and `collect_files()`. -3. Add the suffix to `CODE_EXTENSIONS` in `detect.py` and `_WATCHED_EXTENSIONS` in `watch.py`. -4. Add the tree-sitter package to `pyproject.toml` dependencies. -5. Add a fixture file to `tests/fixtures/` and tests to `tests/test_languages.py`. +1. Add `extract_(path: Path) -> dict` in + `graphify/extractors/.py`, following the existing pattern (tree-sitter + parse → walk nodes → collect `nodes` and `edges` → call-graph second pass). +2. Re-export it from `graphify.extract`, register it in + `graphify.extractors.LANGUAGE_EXTRACTORS`, and register each suffix in the + `graphify.extract` dispatch table. +3. Add each suffix to `CODE_EXTENSIONS` in `detect.py`; watch support derives + from that set. +4. Add the tree-sitter package to `pyproject.toml` dependencies or an optional + language extra when the package does not provide portable wheels. +5. Add a fixture file to `tests/fixtures/` and tests to + `tests/test_languages.py`. ## Security diff --git a/README.md b/README.md index 0459f57cf..2f3cc97aa 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi | `bedrock` | AWS Bedrock (uses IAM, no API key) | `uv tool install "graphifyy[bedrock]"` | | `azure` | Azure OpenAI Service (`--backend azure`, uses `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`) | `uv tool install "graphifyy[openai]"` | | `sql` | SQL schema extraction | `uv tool install "graphifyy[sql]"` | +| `clojure` | Clojure/ClojureScript `.clj`/`.cljs`/`.cljc` and Babashka `.bb` AST extraction (requires a C compiler) | `uv tool install "graphifyy[clojure]"` | | `postgres` | Live PostgreSQL introspection (`--postgres DSN`) | `uv tool install "graphifyy[postgres]"` | | `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]"` | @@ -331,7 +332,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (36 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 .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]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| 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 .clj .cljs .cljc .bb .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .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` (`.clj`/`.cljs`/`.cljc`/`.bb` requires `uv tool install graphifyy[clojure]`; `.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.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]`) | | 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/analyze.py b/graphify/analyze.py index ec1e61a99..853427dfd 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -32,6 +32,7 @@ **{e: "swift" for e in (".swift",)}, **{e: "dotnet" for e in (".cs",)}, **{e: "php" for e in (".php",)}, + **{e: "clojure" for e in (".clj", ".cljs", ".cljc", ".bb")}, **{e: "r" for e in (".r",)}, } diff --git a/graphify/build.py b/graphify/build.py index 44e8c441c..c7ebac232 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -51,6 +51,7 @@ ".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", + ".clj": "clojure", ".cljs": "clojure", ".cljc": "clojure", ".bb": "clojure", } diff --git a/graphify/cli.py b/graphify/cli.py index 91df09672..7d1d6bb39 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -71,7 +71,8 @@ _HOOK_SOURCE_EXTS = ( '.py', '.js', '.cjs', '.ts', '.tsx', '.jsx', '.astro', '.vue', '.svelte', '.go', '.rs', '.java', '.rb', '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.kt', - '.swift', '.php', '.scala', '.lua', '.sh', '.md', '.rst', '.txt', '.mdx', + '.swift', '.php', '.scala', '.clj', '.cljs', '.cljc', '.bb', '.lua', '.sh', + '.md', '.rst', '.txt', '.mdx', ) _GEMINI_NUDGE_TEXT = ( 'graphify: knowledge graph at graphify-out/. For focused questions, run ' diff --git a/graphify/detect.py b/graphify/detect.py index 338b1695c..d7b9b5325 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -28,7 +28,7 @@ class FileType(str, Enum): _MANIFEST_PATH = str(out_path("manifest.json")) -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', '.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'} +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', '.clj', '.cljs', '.cljc', '.bb', '.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', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'} 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 bbfa301cc..f515c41dc 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -33,6 +33,7 @@ from graphify.extractors.apex import extract_apex # noqa: F401 from graphify.extractors.bash import extract_bash # noqa: F401 from graphify.extractors.blade import extract_blade # noqa: F401 +from graphify.extractors.clojure import extract_clojure # noqa: F401 from graphify.extractors.csharp import ( _resolve_cross_file_csharp_imports, _resolve_csharp_type_references, @@ -1887,6 +1888,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".zig": "zig", ".ex": "elixir", ".exs": "elixir", ".jl": "julia", + ".clj": "clojure", ".cljs": "clojure", ".cljc": "clojure", ".bb": "clojure", ".dart": "dart", ".sh": "shell", ".bash": "shell", ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", @@ -3995,6 +3997,10 @@ def add_existing_edge(edge: dict) -> None: ".kts": extract_kotlin, ".scala": extract_scala, ".php": extract_php, + ".clj": extract_clojure, + ".cljs": extract_clojure, + ".cljc": extract_clojure, + ".bb": extract_clojure, ".swift": extract_swift, ".lua": extract_lua, ".luau": extract_lua, @@ -4068,6 +4074,10 @@ def add_existing_edge(edge: dict) -> None: # rather than falling back like Pascal does. Used by the #1745 warning in # extract() to tell the user which extra restores the language. _EXTRA_FOR_EXTENSION = { + ".clj": "clojure", + ".cljs": "clojure", + ".cljc": "clojure", + ".bb": "clojure", ".sql": "sql", ".tf": "terraform", ".tfvars": "terraform", diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..f90223f4a 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -13,6 +13,7 @@ from graphify.extractors.apex import extract_apex from graphify.extractors.bash import extract_bash from graphify.extractors.blade import extract_blade +from graphify.extractors.clojure import extract_clojure from graphify.extractors.dart import extract_dart from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm from graphify.extractors.elixir import extract_elixir @@ -37,6 +38,7 @@ "apex": extract_apex, "bash": extract_bash, "blade": extract_blade, + "clojure": extract_clojure, "dart": extract_dart, "delphi_form": extract_delphi_form, "dm": extract_dm, diff --git a/graphify/extractors/clojure.py b/graphify/extractors/clojure.py new file mode 100644 index 000000000..be0d7ea81 --- /dev/null +++ b/graphify/extractors/clojure.py @@ -0,0 +1,567 @@ +"""Clojure structural extractor.""" +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +from graphify.extractors.base import _make_id + + +def _clojure_id(*parts: str) -> str: + """Build a canonical ID while preserving Clojure's case distinctions.""" + node_id = _make_id(*parts) + exact = "\0".join(parts) + if exact == exact.casefold(): + return node_id + digest = hashlib.sha1( + exact.encode("utf-8"), + usedforsecurity=False, + ).hexdigest()[:8] + return _make_id(node_id, "case", digest) + + +def extract_clojure(path: Path) -> dict: + """Extract Clojure namespaces, definitions, dependencies, and local calls.""" + try: + import tree_sitter_clojure_orchard as tsclojure + from tree_sitter import Language, Parser + except ImportError: + return { + "nodes": [], + "edges": [], + "error": "tree-sitter-clojure-orchard not installed", + } + + try: + source = path.read_bytes() + root = Parser(Language(tsclojure.language())).parse(source).root_node + except Exception as exc: + return {"nodes": [], "edges": [], "error": str(exc)} + + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + nodes_by_id: dict[str, dict] = {} + edges_by_key: dict[tuple[str, str, str, str | None], dict] = {} + unconditional_node_ids: set[str] = set() + unconditional_edge_keys: set[tuple[str, str, str, str | None]] = set() + namespace_nid: str | None = None + namespace_name: str | None = None + function_bodies: list[tuple[str, list[Any], str | None]] = [] + definition_ids_by_name: dict[str, str] = {} + type_implementation_forms: list[tuple[str, Any, str | None]] = [] + defmethod_implementations: list[tuple[str, str, int, str | None]] = [] + + definition_heads = frozenset({ + "def", + "defonce", + "defn", + "defn-", + "defmacro", + "defmulti", + "defmethod", + "defprotocol", + "defrecord", + "deftype", + }) + callable_heads = frozenset({"defn", "defn-", "defmacro", "defmethod"}) + type_heads = frozenset({"defprotocol", "defrecord", "deftype"}) + skipped_call_heads = frozenset({ + ".", + "..", + "and", + "case", + "catch", + "comment", + "cond", + "cond->", + "cond->>", + "def", + "defmacro", + "defmethod", + "defmulti", + "defn", + "defn-", + "defonce", + "defprotocol", + "defrecord", + "deftype", + "do", + "doseq", + "fn", + "for", + "if", + "import", + "let", + "loop", + "ns", + "or", + "quote", + "recur", + "require", + "try", + "when", + "when-let", + "when-not", + }) + + def node_text(node: Any) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + + def named_children(node: Any) -> list[Any]: + return [child for child in node.children if child.is_named] + + def form_values(node: Any) -> list[Any]: + return list(node.children_by_field_name("value")) + + def symbol_text(node: Any) -> str: + name_node = node.child_by_field_name("name") + if name_node is None: + return node_text(node) + name = node_text(name_node) + namespace_node = node.child_by_field_name("namespace") + if namespace_node is None: + return name + return f"{node_text(namespace_node)}/{name}" + + def form_head(node: Any) -> str | None: + if node.type != "list_lit": + return None + values = form_values(node) + if not values or values[0].type not in {"sym_lit", "kwd_lit"}: + return None + return symbol_text(values[0]) if values[0].type == "sym_lit" else node_text(values[0]) + + def first_symbol(children: list[Any], start: int = 0) -> tuple[str | None, Any | None]: + for child in children[start:]: + if child.type == "sym_lit": + return symbol_text(child), child + return None, None + + def add_reader_feature(item: dict, reader_feature: str | None) -> None: + if reader_feature is None: + return + features = item.setdefault("reader_features", []) + if reader_feature not in features: + features.append(reader_feature) + features.sort() + + def add_node( + nid: str, + label: str, + line: int, + reader_feature: str | None = None, + ) -> None: + existing = nodes_by_id.get(nid) + if existing is not None: + assert existing["label"] == label, ( + f"Clojure node ID collision: {nid!r} maps to both " + f"{existing['label']!r} and {label!r}" + ) + if reader_feature is None: + unconditional_node_ids.add(nid) + existing.pop("reader_features", None) + elif nid not in unconditional_node_ids: + add_reader_feature(existing, reader_feature) + return + node = { + "id": nid, + "label": label, + "file_type": "code", + "source_file": str_path, + "source_location": f"L{line}", + } + if reader_feature is None: + unconditional_node_ids.add(nid) + else: + add_reader_feature(node, reader_feature) + nodes_by_id[nid] = node + nodes.append(node) + + def add_edge( + source_nid: str, + target_nid: str, + relation: str, + line: int, + context: str | None = None, + reader_feature: str | None = None, + ) -> None: + key = (source_nid, target_nid, relation, context) + existing = edges_by_key.get(key) + if existing is not None: + if reader_feature is None: + unconditional_edge_keys.add(key) + existing.pop("reader_features", None) + elif key not in unconditional_edge_keys: + add_reader_feature(existing, reader_feature) + return + edge = { + "source": source_nid, + "target": target_nid, + "relation": relation, + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + } + if context: + edge["context"] = context + if reader_feature is None: + unconditional_edge_keys.add(key) + else: + add_reader_feature(edge, reader_feature) + edges_by_key[key] = edge + edges.append(edge) + + file_nid = _make_id(str_path) + add_node(file_nid, path.name, 1) + + def container_nid() -> str: + return namespace_nid or file_nid + + def unquote_form(form: Any) -> Any: + if form.type in {"quoting_lit", "syn_quoting_lit"}: + values = form_values(form) + return values[0] if values else form + return form + + def add_require_form( + require_form: Any, + reader_feature: str | None = None, + ) -> None: + require_form = unquote_form(require_form) + if require_form.type == "sym_lit": + module_name, module_node = symbol_text(require_form), require_form + elif require_form.type == "vec_lit": + module_name, module_node = first_symbol(form_values(require_form)) + else: + return + if module_name is not None and module_node is not None: + add_edge( + container_nid(), + _clojure_id(module_name), + "imports_from", + module_node.start_point[0] + 1, + context="import", + reader_feature=reader_feature, + ) + + def add_java_import_form( + import_form: Any, + reader_feature: str | None = None, + ) -> None: + import_form = unquote_form(import_form) + if import_form.type == "sym_lit": + import_symbols = [symbol_text(import_form)] + else: + import_symbols = [ + symbol_text(child) + for child in form_values(import_form) + if child.type == "sym_lit" + ] + if not import_symbols: + return + package = import_symbols[0] + imported_names = import_symbols[1:] or [package] + for imported_name in imported_names: + target = ( + f"{package}.{imported_name}" + if imported_name != package + else package + ) + add_edge( + container_nid(), + _make_id(target), + "imports", + import_form.start_point[0] + 1, + context="import", + reader_feature=reader_feature, + ) + + def conditional_branches(node: Any) -> list[tuple[str, Any]]: + values = form_values(node) + branches: list[tuple[str, Any]] = [] + for index in range(0, len(values) - 1, 2): + feature_node = values[index] + if feature_node.type != "kwd_lit": + continue + branches.append((node_text(feature_node), values[index + 1])) + return branches + + def add_namespace_clause( + clause: Any, + reader_feature: str | None = None, + ) -> None: + if clause.type in {"read_cond_lit", "splicing_read_cond_lit"}: + for feature, branch in conditional_branches(clause): + add_namespace_clause(branch, feature) + return + if clause.type == "vec_lit": + for value in form_values(clause): + add_namespace_clause(value, reader_feature) + return + if clause.type != "list_lit": + return + clause_values = form_values(clause) + if not clause_values: + return + clause_head = node_text(clause_values[0]) + if clause_head in {":require", ":require-macros", ":use"}: + for require_form in clause_values[1:]: + add_require_form(require_form, reader_feature) + elif clause_head == ":import": + for import_form in clause_values[1:]: + add_java_import_form(import_form, reader_feature) + + def add_import_edges(ns_form: Any) -> None: + for clause in form_values(ns_form)[2:]: + add_namespace_clause(clause) + + def add_protocol_methods( + protocol_nid: str, + form: Any, + reader_feature: str | None, + ) -> None: + for child in form_values(form)[2:]: + method_name = form_head(child) + if not method_name: + continue + line = child.start_point[0] + 1 + method_nid = _clojure_id(protocol_nid, method_name) + add_node(method_nid, f".{method_name}()", line, reader_feature) + add_edge( + protocol_nid, + method_nid, + "method", + line, + reader_feature=reader_feature, + ) + definition_ids_by_name.setdefault(method_name, method_nid) + + def body_nodes(head: str, children: list[Any]) -> list[Any]: + if head == "defmethod": + for index, child in enumerate(children[2:], start=2): + if child.type == "vec_lit": + return children[index + 1:] + return [] + for index, child in enumerate(children[2:], start=2): + if child.type == "vec_lit": + return children[index + 1:] + return children[2:] + + def add_definition( + form: Any, + reader_feature: str | None = None, + ) -> None: + children = form_values(form) + if len(children) < 2: + return + head = symbol_text(children[0]) + if head not in definition_heads: + return + name, name_node = first_symbol(children, 1) + if name is None or name_node is None: + return + + line = name_node.start_point[0] + 1 + if head == "defmethod": + dispatch = node_text(children[2]) if len(children) > 2 else "" + label = f"{name} {dispatch}".strip() + node_id = _clojure_id(container_nid(), name, dispatch) + elif head in type_heads: + label = name + node_id = _clojure_id(container_nid(), name) + elif head in callable_heads or head == "defmulti": + label = f"{name}()" + node_id = _clojure_id(container_nid(), name) + else: + label = name + node_id = _clojure_id(container_nid(), name) + + add_node(node_id, label, line, reader_feature) + add_edge( + container_nid(), + node_id, + "contains", + line, + reader_feature=reader_feature, + ) + if head == "defprotocol": + add_protocol_methods(node_id, form, reader_feature) + elif head in {"defrecord", "deftype"}: + type_implementation_forms.append((node_id, form, reader_feature)) + elif head == "defmethod": + defmethod_implementations.append( + (node_id, name, line, reader_feature) + ) + if head != "defmethod": + definition_ids_by_name.setdefault(name, node_id) + if head in callable_heads: + function_bodies.append(( + node_id, + body_nodes(head, children), + reader_feature, + )) + + def walk_top_level( + child: Any, + reader_feature: str | None = None, + ) -> None: + nonlocal namespace_name, namespace_nid + if child.type != "list_lit": + if child.type in {"read_cond_lit", "splicing_read_cond_lit"}: + for feature, branch in conditional_branches(child): + walk_top_level(branch, feature) + elif child.type == "vec_lit": + for value in form_values(child): + walk_top_level(value, reader_feature) + return + if form_head(child) == "ns": + ns_children = form_values(child) + if len(ns_children) > 1 and ns_children[1].type == "sym_lit": + namespace_name = symbol_text(ns_children[1]) + namespace_nid = _clojure_id(namespace_name) + line = ns_children[1].start_point[0] + 1 + add_node(namespace_nid, namespace_name, line) + add_edge(file_nid, namespace_nid, "contains", line) + add_import_edges(child) + return + if form_head(child) in {"require", "use"}: + for require_form in form_values(child)[1:]: + add_require_form(require_form, reader_feature) + return + if form_head(child) == "import": + for import_form in form_values(child)[1:]: + add_java_import_form(import_form, reader_feature) + return + add_definition(child, reader_feature) + + for child in named_children(root): + walk_top_level(child) + + def method_body_nodes(children: list[Any]) -> list[Any]: + for index, child in enumerate(children[1:], start=1): + if child.type == "vec_lit": + return children[index + 1:] + return children[1:] + + for type_nid, form, reader_feature in type_implementation_forms: + children = form_values(form) + fields_index = next( + ( + index + for index, child in enumerate(children[2:], start=2) + if child.type == "vec_lit" + ), + None, + ) + if fields_index is None: + continue + for child in children[fields_index + 1:]: + if child.type == "sym_lit": + protocol_name = symbol_text(child) + target_nid = definition_ids_by_name.get( + protocol_name.rsplit("/", 1)[-1], + _clojure_id(protocol_name), + ) + add_edge( + type_nid, + target_nid, + "implements", + child.start_point[0] + 1, + reader_feature=reader_feature, + ) + continue + method_name = form_head(child) + if method_name is None: + continue + line = child.start_point[0] + 1 + method_nid = _clojure_id(type_nid, method_name) + add_node(method_nid, f".{method_name}()", line, reader_feature) + add_edge( + type_nid, + method_nid, + "method", + line, + reader_feature=reader_feature, + ) + function_bodies.append(( + method_nid, + method_body_nodes(form_values(child)), + reader_feature, + )) + + for method_nid, multimethod_name, line, reader_feature in defmethod_implementations: + multimethod_nid = definition_ids_by_name.get(multimethod_name) + if multimethod_nid is not None: + add_edge( + multimethod_nid, + method_nid, + "method", + line, + reader_feature=reader_feature, + ) + + seen_call_pairs: set[tuple[str, str, str | None]] = set() + + def callee_name(raw_head: str) -> str | None: + if raw_head in skipped_call_heads or raw_head.startswith(":"): + return None + if "/" not in raw_head: + return raw_head + qualifier, name = raw_head.rsplit("/", 1) + if namespace_name is not None and qualifier == namespace_name: + return name + return None + + def walk_calls( + node: Any, + caller_nid: str, + reader_feature: str | None, + ) -> None: + if node.type in { + "comment", + "dis_expr", + "quoting_lit", + "syn_quoting_lit", + "var_quoting_lit", + }: + return + if node.type in {"read_cond_lit", "splicing_read_cond_lit"}: + for feature, branch in conditional_branches(node): + branch_feature = ( + feature + if reader_feature is None + else reader_feature + if reader_feature == feature + else f"{reader_feature}&{feature}" + ) + walk_calls(branch, caller_nid, branch_feature) + return + if node.type == "list_lit": + raw_head = form_head(node) + if raw_head in definition_heads or raw_head in {"comment", "quote"}: + return + if raw_head: + name = callee_name(raw_head) + target_nid = definition_ids_by_name.get(name or "") + pair = (caller_nid, target_nid or "", reader_feature) + if target_nid and target_nid != caller_nid and pair not in seen_call_pairs: + seen_call_pairs.add(pair) + add_edge( + caller_nid, + target_nid, + "calls", + node.start_point[0] + 1, + context="call", + reader_feature=reader_feature, + ) + for child in form_values(node) if node.type.endswith("_lit") else named_children(node): + walk_calls(child, caller_nid, reader_feature) + + for caller_nid, bodies, reader_feature in function_bodies: + for body in bodies: + walk_calls(body, caller_nid, reader_feature) + + return {"nodes": nodes, "edges": edges, "raw_calls": []} diff --git a/graphify/skill-aider.md b/graphify/skill-aider.md index 4f03ccbae..d684dcaf3 100644 --- a/graphify/skill-aider.md +++ b/graphify/skill-aider.md @@ -780,7 +780,7 @@ import json from pathlib import Path result = json.loads(open('.graphify_incremental.json').read()) if Path('.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skill-devin.md b/graphify/skill-devin.md index e3e6d2dec..0d26dc903 100644 --- a/graphify/skill-devin.md +++ b/graphify/skill-devin.md @@ -919,7 +919,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/agents/references/update.md b/graphify/skills/agents/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/agents/references/update.md +++ b/graphify/skills/agents/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/amp/references/update.md b/graphify/skills/amp/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/amp/references/update.md +++ b/graphify/skills/amp/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/claude/references/update.md b/graphify/skills/claude/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/claude/references/update.md +++ b/graphify/skills/claude/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/claw/references/update.md b/graphify/skills/claw/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/claw/references/update.md +++ b/graphify/skills/claw/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/codex/references/update.md b/graphify/skills/codex/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/codex/references/update.md +++ b/graphify/skills/codex/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/copilot/references/update.md b/graphify/skills/copilot/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/copilot/references/update.md +++ b/graphify/skills/copilot/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/droid/references/update.md b/graphify/skills/droid/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/droid/references/update.md +++ b/graphify/skills/droid/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/kilo/references/update.md b/graphify/skills/kilo/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/kilo/references/update.md +++ b/graphify/skills/kilo/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/kiro/references/update.md b/graphify/skills/kiro/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/kiro/references/update.md +++ b/graphify/skills/kiro/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/opencode/references/update.md b/graphify/skills/opencode/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/opencode/references/update.md +++ b/graphify/skills/opencode/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/pi/references/update.md b/graphify/skills/pi/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/pi/references/update.md +++ b/graphify/skills/pi/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/trae/references/update.md b/graphify/skills/trae/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/trae/references/update.md +++ b/graphify/skills/trae/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/vscode/references/update.md b/graphify/skills/vscode/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/vscode/references/update.md +++ b/graphify/skills/vscode/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/graphify/skills/windows/references/update.md b/graphify/skills/windows/references/update.md index 3632fd412..aa9e378f2 100644 --- a/graphify/skills/windows/references/update.md +++ b/graphify/skills/windows/references/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/pyproject.toml b/pyproject.toml index 1c46f149d..bc2bec491 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,9 @@ gemini = ["openai", "tiktoken"] openai = ["openai", "tiktoken"] chinese = ["jieba"] sql = ["tree-sitter-sql"] +# tree-sitter-clojure-orchard ships as an sdist, so installing it requires a +# C toolchain. Keep it optional to preserve graphifyy's default wheel-only path. +clojure = ["tree-sitter-clojure-orchard>=0.2.8,<0.3"] # extract_pascal() uses tree-sitter-pascal for AST-quality extraction (more # accurate calls/inherits edges) and falls back to a regex extractor when it is # absent (#781), so this stays optional. Unlike tree-sitter-dm below, it ships @@ -81,7 +84,7 @@ pascal = ["tree-sitter-pascal"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] -all = ["mcp", "starlette>=1.3.1", "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"] +all = ["mcp", "starlette>=1.3.1", "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-clojure-orchard>=0.2.8,<0.3", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"] [project.scripts] graphify = "graphify.__main__:main" @@ -103,6 +106,7 @@ dev = [ "setuptools>=82.0.1", "wheel>=0.47.0", "tomli>=2.0 ; python_version < '3.11'", + "tree-sitter-clojure-orchard>=0.2.8,<0.3", "tree-sitter-hcl>=1.2.0", ] diff --git a/tests/fixtures/sample.clj b/tests/fixtures/sample.clj new file mode 100644 index 000000000..b09bb9a56 --- /dev/null +++ b/tests/fixtures/sample.clj @@ -0,0 +1,65 @@ +(ns sample.core + (:require [sample.db :as db] + [clojure.string :as str] + sample.audit) + (:import [java.time Instant])) + +(defonce default-role :guest) + +(defprotocol Store + (fetch-user [this id]) + (save-user! [this user])) + +(defrecord User [id name] + Store + (fetch-user [this id] + (normalize-name name)) + (save-user! [this user] + user)) + +(deftype Cache [state] + Store + (fetch-user [this id] + (normalize-name id)) + (save-user! [this user] + user)) + +(defn normalize-name [name] + (str/trim name)) + +(defn- enrich-user [user] + (normalize-name (:name user))) + +(defn quoted-example [] + '(normalize-name "quoted")) + +(defn syntax-quoted-example [] + `(normalize-name "syntax-quoted")) + +(defn var-quoted-example [] + #'normalize-name) + +(defn comment-example [] + (comment + (normalize-name "commented")) + nil) + +(defn discarded-example [] + #_(normalize-name "discarded") + nil) + +(defmacro with-user [binding & body] + `(let [~binding (fetch-current)] + ~@body)) + +(defmulti render :type) + +(defmethod render :user [user] + (enrich-user user)) + +(defmethod render :admin [user] + (normalize-name (:name user))) + +(defn handle-request [id] + (let [user (db/fetch-user id)] + (render (assoc user :name (normalize-name (:name user)))))) diff --git a/tests/test_backend_extras.py b/tests/test_backend_extras.py index f513c57dc..9af5a30fe 100644 --- a/tests/test_backend_extras.py +++ b/tests/test_backend_extras.py @@ -34,6 +34,13 @@ def test_anthropic_in_all_extra(): assert any("anthropic" in dep for dep in extras["all"]), "[all] must include anthropic" +def test_clojure_parser_extra_exists_and_is_in_all(): + extras = _extras() + + assert any("tree-sitter-clojure-orchard" in dep for dep in extras["clojure"]) + assert any("tree-sitter-clojure-orchard" in dep for dep in extras["all"]) + + def test_backend_pkg_hint_points_at_uv_tool_and_extra(): msg = _backend_pkg_hint("anthropic", "anthropic") assert "uv tool install" in msg diff --git a/tests/test_detect.py b/tests/test_detect.py index e97ff5d4b..555b77227 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -13,6 +13,10 @@ def test_classify_python(): def test_classify_typescript(): assert classify_file(Path("bar.ts")) == FileType.CODE +@pytest.mark.parametrize("suffix", [".clj", ".cljs", ".cljc", ".bb"]) +def test_classify_clojure_source(suffix): + assert classify_file(Path(f"core{suffix}")) == FileType.CODE + def test_classify_powershell_module(): # #1315: .psm1 modules were never indexed (CODE_EXTENSIONS gap). assert classify_file(Path("Utils.psm1")) == FileType.CODE diff --git a/tests/test_extract.py b/tests/test_extract.py index be633c0fd..0b5a00c1f 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2540,6 +2540,20 @@ def test_extract_no_missing_dep_warning_when_sql_installed(tmp_path, capsys): assert "#1745" not in err +def test_extract_warns_when_clojure_extra_missing(tmp_path, capsys, monkeypatch): + monkeypatch.setitem(sys.modules, "tree_sitter_clojure_orchard", None) + source = tmp_path / "core.clj" + source.write_text("(ns sample.core)\n(defn run [] :ok)\n", encoding="utf-8") + + extract([source], root=tmp_path, cache_root=tmp_path) + err = capsys.readouterr().err + + assert "1 .clj file(s)" in err + assert "tree-sitter-clojure-orchard not installed" in err + assert "graphifyy[clojure]" in err + assert "#1745" in err + + def test_extract_progress_final_line_uses_consistent_denominator(tmp_path, capsys): # #1693: intermediate progress lines count against uncached_work; the final # "100%" line must NOT switch to total_files (which includes cached hits and diff --git a/tests/test_languages.py b/tests/test_languages.py index aff689d2a..7559a8422 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -9,8 +9,9 @@ extract_groovy, extract_sln, extract_csproj, extract_xaml, extract_razor, extract_dm, extract_dmi, extract_dmm, extract_dmf, extract_powershell, extract_apex, extract_verilog, - extract_powershell_manifest, + extract_powershell_manifest, extract_clojure, extract, collect_files, ) +from graphify.build import build_from_json FIXTURES = Path(__file__).parent / "fixtures" @@ -77,6 +78,246 @@ def _edge_labels(result: dict, relation: str, context: str | None = None) -> set return pairs +# ── Clojure ────────────────────────────────────────────────────────────────── + +def test_clojure_extracts_namespace_and_common_definitions(): + result = extract_clojure(FIXTURES / "sample.clj") + + assert "error" not in result + assert { + "sample.core", + "default-role", + "User", + "Store", + "Cache", + "normalize-name()", + "enrich-user()", + "with-user()", + "render()", + "render :user", + "handle-request()", + } <= set(_labels(result)) + + +def test_clojure_extracts_require_and_import_edges(): + result = extract_clojure(FIXTURES / "sample.clj") + + require_targets = { + edge["target"] + for edge in _edges_with_relation(result, "imports_from") + } + import_targets = { + edge["target"] + for edge in _edges_with_relation(result, "imports") + } + import_edges = _edges_with_relation(result, "imports", "imports_from") + + assert {"sample_db", "clojure_string", "sample_audit"} <= require_targets + assert "java_time_instant" in import_targets + assert all(edge.get("context") == "import" for edge in import_edges) + + +def test_clojure_extracts_same_file_calls_without_quoted_forms(): + result = extract_clojure(FIXTURES / "sample.clj") + calls = _calls(result) + + assert ("enrich-user()", "normalize-name()") in calls + assert ("render :user", "enrich-user()") in calls + assert ("handle-request()", "render()") in calls + assert ("handle-request()", "normalize-name()") in calls + assert not any(source == "quoted-example()" for source, _ in calls) + assert not any(source == "syntax-quoted-example()" for source, _ in calls) + assert not any(source == "var-quoted-example()" for source, _ in calls) + assert not any(source == "comment-example()" for source, _ in calls) + assert not any(source == "discarded-example()" for source, _ in calls) + + +def test_clojure_extracts_protocol_methods(): + result = extract_clojure(FIXTURES / "sample.clj") + + assert { + ("Store", "fetch-user"), + ("Store", "save-user!"), + } <= _edge_labels(result, "method") + + +def test_clojure_extracts_record_and_type_implementations(): + result = extract_clojure(FIXTURES / "sample.clj") + + assert { + ("User", "Store"), + ("Cache", "Store"), + } <= _edge_labels(result, "implements") + assert { + ("User", "fetch-user"), + ("User", "save-user!"), + ("Cache", "fetch-user"), + ("Cache", "save-user!"), + } <= _edge_labels(result, "method") + assert ("fetch-user", "normalize-name") in _edge_labels(result, "calls") + + +def test_clojure_relates_multimethod_implementations_to_defmulti(): + result = extract_clojure(FIXTURES / "sample.clj") + + assert { + ("render", "render :user"), + ("render", "render :admin"), + } <= _edge_labels(result, "method") + + +def test_clojure_preserves_case_sensitive_symbols(tmp_path): + source = tmp_path / "case_sensitive.clj" + source.write_text( + """(ns sample.case) +(defn Foo [] :upper) +(defn foo [] :lower) +(defn call-upper [] (Foo)) +(defn call-lower [] (foo)) +""", + encoding="utf-8", + ) + + result = extract( + collect_files(tmp_path), + root=tmp_path, + cache_root=tmp_path, + ) + ids_by_label = {node["label"]: node["id"] for node in result["nodes"]} + calls = _calls(result) + + assert ids_by_label["Foo()"] != ids_by_label["foo()"] + assert ("call-upper()", "Foo()") in calls + assert ("call-lower()", "foo()") in calls + assert ("call-upper()", "foo()") not in calls + assert ("call-lower()", "Foo()") not in calls + + graph = build_from_json(result) + ids_by_label = { + attributes["label"]: node_id + for node_id, attributes in graph.nodes(data=True) + } + assert ids_by_label["Foo()"] != ids_by_label["foo()"] + assert graph.has_edge(ids_by_label["call-upper()"], ids_by_label["Foo()"]) + assert graph.has_edge(ids_by_label["call-lower()"], ids_by_label["foo()"]) + assert not graph.has_edge(ids_by_label["call-upper()"], ids_by_label["foo()"]) + assert not graph.has_edge(ids_by_label["call-lower()"], ids_by_label["Foo()"]) + + +def test_clojure_uses_symbol_names_without_metadata(tmp_path): + source = tmp_path / "metadata.clj" + source.write_text( + """(ns sample.metadata) +(defn ^String format-name [^String name] name) +(def ^:private default-name "Guest") +""", + encoding="utf-8", + ) + + result = extract_clojure(source) + + assert {"format-name()", "default-name"} <= set(_labels(result)) + assert all("^" not in label for label in _labels(result)) + + +def test_clojure_source_extensions_dispatch_through_extract(tmp_path): + expected_namespaces = set() + for suffix in (".clj", ".cljs", ".cljc", ".bb"): + namespace = f"sample.source{suffix[1:]}" + expected_namespaces.add(namespace) + (tmp_path / f"source{suffix}").write_text( + f"(ns {namespace})\n(defn run [] :ok)\n", + encoding="utf-8", + ) + + files = collect_files(tmp_path) + result = extract(files, root=tmp_path, cache_root=tmp_path) + + assert {path.suffix for path in files} == {".clj", ".cljs", ".cljc", ".bb"} + assert expected_namespaces <= set(_labels(result)) + + +def test_clojure_walks_reader_conditional_branches(tmp_path): + source = tmp_path / "shared.cljc" + source.write_text( + """(ns sample.shared + #?(:clj (:require [sample.jvm :as platform]) + :cljs (:require [sample.js :as platform]))) +#?(:clj (defn platform-name [] :clj) + :cljs (defn platform-name [] :cljs)) +#?@(:clj [(defn shared-helper [] (platform-name))]) +(defn jvm-helper [] :jvm) +(defn js-helper [] :js) +(defn platform-handler [] + #?(:clj (jvm-helper) + :cljs (js-helper))) +(defn mixed-handler [] + (jvm-helper) + #?(:cljs (jvm-helper))) +""", + encoding="utf-8", + ) + + result = extract_clojure(source) + + assert {"platform-name()", "shared-helper()"} <= set(_labels(result)) + assert ("shared-helper()", "platform-name()") in _calls(result) + assert set(_node_by_label(result, "platform-name")["reader_features"]) == { + ":clj", + ":cljs", + } + assert _node_by_label(result, "shared-helper")["reader_features"] == [":clj"] + dependency_features = { + edge["target"]: edge["reader_features"] + for edge in _edges_with_relation(result, "imports_from") + } + assert dependency_features == { + "sample_jvm": [":clj"], + "sample_js": [":cljs"], + } + node_ids = { + _normalize_symbol_label(node["label"]): node["id"] + for node in result["nodes"] + } + call_features = { + edge["target"]: edge["reader_features"] + for edge in _edges_with_relation(result, "calls") + if edge["source"] == node_ids["platform-handler"] + } + assert call_features == { + node_ids["jvm-helper"]: [":clj"], + node_ids["js-helper"]: [":cljs"], + } + mixed_edge = next( + edge + for edge in _edges_with_relation(result, "calls") + if edge["source"] == node_ids["mixed-handler"] + and edge["target"] == node_ids["jvm-helper"] + ) + assert "reader_features" not in mixed_edge + + +def test_clojure_extracts_top_level_script_dependencies(tmp_path): + source = tmp_path / "task.bb" + source.write_text( + """(require '[babashka.fs :as fs] 'clojure.string) +(import '[java.time Instant ZoneId] 'java.util.UUID) +""", + encoding="utf-8", + ) + + result = extract_clojure(source) + require_targets = { + edge["target"] for edge in _edges_with_relation(result, "imports_from") + } + import_targets = { + edge["target"] for edge in _edges_with_relation(result, "imports") + } + + assert {"babashka_fs", "clojure_string"} <= require_targets + assert {"java_time_instant", "java_time_zoneid", "java_util_uuid"} <= import_targets + + # ── Java ────────────────────────────────────────────────────────────────────── def test_java_no_error(): diff --git a/tools/skillgen/expected/graphify__skill-aider.md b/tools/skillgen/expected/graphify__skill-aider.md index 4f03ccbae..d684dcaf3 100644 --- a/tools/skillgen/expected/graphify__skill-aider.md +++ b/tools/skillgen/expected/graphify__skill-aider.md @@ -780,7 +780,7 @@ import json from pathlib import Path result = json.loads(open('.graphify_incremental.json').read()) if Path('.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skill-devin.md b/tools/skillgen/expected/graphify__skill-devin.md index e3e6d2dec..0d26dc903 100644 --- a/tools/skillgen/expected/graphify__skill-devin.md +++ b/tools/skillgen/expected/graphify__skill-devin.md @@ -919,7 +919,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__agents__references__update.md b/tools/skillgen/expected/graphify__skills__agents__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__agents__references__update.md +++ b/tools/skillgen/expected/graphify__skills__agents__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__amp__references__update.md b/tools/skillgen/expected/graphify__skills__amp__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__amp__references__update.md +++ b/tools/skillgen/expected/graphify__skills__amp__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__claude__references__update.md b/tools/skillgen/expected/graphify__skills__claude__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__claude__references__update.md +++ b/tools/skillgen/expected/graphify__skills__claude__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__claw__references__update.md b/tools/skillgen/expected/graphify__skills__claw__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__claw__references__update.md +++ b/tools/skillgen/expected/graphify__skills__claw__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__codex__references__update.md b/tools/skillgen/expected/graphify__skills__codex__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__codex__references__update.md +++ b/tools/skillgen/expected/graphify__skills__codex__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__copilot__references__update.md b/tools/skillgen/expected/graphify__skills__copilot__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__copilot__references__update.md +++ b/tools/skillgen/expected/graphify__skills__copilot__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__droid__references__update.md b/tools/skillgen/expected/graphify__skills__droid__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__droid__references__update.md +++ b/tools/skillgen/expected/graphify__skills__droid__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__kilo__references__update.md b/tools/skillgen/expected/graphify__skills__kilo__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__kilo__references__update.md +++ b/tools/skillgen/expected/graphify__skills__kilo__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__kiro__references__update.md b/tools/skillgen/expected/graphify__skills__kiro__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__kiro__references__update.md +++ b/tools/skillgen/expected/graphify__skills__kiro__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__opencode__references__update.md b/tools/skillgen/expected/graphify__skills__opencode__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__opencode__references__update.md +++ b/tools/skillgen/expected/graphify__skills__opencode__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__pi__references__update.md b/tools/skillgen/expected/graphify__skills__pi__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__pi__references__update.md +++ b/tools/skillgen/expected/graphify__skills__pi__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__trae__references__update.md b/tools/skillgen/expected/graphify__skills__trae__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__trae__references__update.md +++ b/tools/skillgen/expected/graphify__skills__trae__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__vscode__references__update.md b/tools/skillgen/expected/graphify__skills__vscode__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__vscode__references__update.md +++ b/tools/skillgen/expected/graphify__skills__vscode__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/expected/graphify__skills__windows__references__update.md b/tools/skillgen/expected/graphify__skills__windows__references__update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/expected/graphify__skills__windows__references__update.md +++ b/tools/skillgen/expected/graphify__skills__windows__references__update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/fragments/core/aider.md b/tools/skillgen/fragments/core/aider.md index 4f03ccbae..d684dcaf3 100644 --- a/tools/skillgen/fragments/core/aider.md +++ b/tools/skillgen/fragments/core/aider.md @@ -780,7 +780,7 @@ import json from pathlib import Path result = json.loads(open('.graphify_incremental.json').read()) if Path('.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/fragments/core/devin.md b/tools/skillgen/fragments/core/devin.md index e3e6d2dec..0d26dc903 100644 --- a/tools/skillgen/fragments/core/devin.md +++ b/tools/skillgen/fragments/core/devin.md @@ -919,7 +919,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/fragments/references/shared/update.md b/tools/skillgen/fragments/references/shared/update.md index 3632fd412..aa9e378f2 100644 --- a/tools/skillgen/fragments/references/shared/update.md +++ b/tools/skillgen/fragments/references/shared/update.md @@ -53,7 +53,7 @@ import json from pathlib import Path result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {} -code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} +code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.clj','.cljs','.cljc','.bb','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'} new_files = result.get('new_files', {}) all_changed = [f for files in new_files.values() for f in files] code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed) diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index 732215e8a..c86d393a3 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -954,6 +954,24 @@ def _is_semantic_cache_scope_fix_line(line: str) -> bool: ) or stripped.startswith("saved = save_semantic_cache(") +_CLOJURE_CODE_EXTS = "'.clj','.cljs','.cljc','.bb'," +_CLOJURE_CODE_EXTS_BASELINES = frozenset({ + "code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb'," + "'.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'}", + "code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb'," + "'.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'," + "'.lua','.toc'}", +}) + + +def _is_clojure_code_exts_line(line: str) -> bool: + """Whether a monolith code-extension line only adds Clojure sources.""" + stripped = line.strip() + if stripped.count(_CLOJURE_CODE_EXTS) > 1: + return False + return stripped.replace(_CLOJURE_CODE_EXTS, "") in _CLOJURE_CODE_EXTS_BASELINES + + # Every line that may differ between a rendered monolith and its pristine v8 # baseline. Each predicate documents one sanctioned change-class; a blank line is # allowed because the multi-line fix blocks insert spacing. Anything else failing @@ -974,6 +992,7 @@ def _is_semantic_cache_scope_fix_line(line: str) -> bool: _is_obsidian_usage_comment_line, _is_uv_from_interpreter_fix_line, _is_semantic_cache_scope_fix_line, + _is_clojure_code_exts_line, ) diff --git a/uv.lock b/uv.lock index 72c0e89ea..b337b9095 100644 --- a/uv.lock +++ b/uv.lock @@ -97,7 +97,7 @@ name = "autograd" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/1c/3c24ec03c8ba4decc742b1df5a10c52f98c84ca8797757f313e7bdcdf276/autograd-1.8.0.tar.gz", hash = "sha256:107374ded5b09fc8643ac925348c0369e7b0e73bbed9565ffd61b8fd04425683", size = 2562146, upload-time = "2025-05-05T12:49:02.502Z" } wheels = [ @@ -477,7 +477,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -555,7 +555,7 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } @@ -813,10 +813,10 @@ name = "ctranslate2" version = "4.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "setuptools", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml" }, + { name = "setuptools" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/cb/e0/b69c40c3d739b213a78d327071240590792071b4f890e34088b03b95bb1e/ctranslate2-4.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9017a355dd7c6d29dc3bca6e9fc74827306c61b702c66bb1f6b939655e7de3fa", size = 1255773, upload-time = "2026-02-04T06:11:04.769Z" }, @@ -926,7 +926,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -951,12 +951,12 @@ name = "faster-whisper" version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "av", marker = "python_full_version >= '3.11'" }, - { name = "ctranslate2", marker = "python_full_version >= '3.11'" }, - { name = "huggingface-hub", marker = "python_full_version >= '3.11'" }, - { name = "onnxruntime", marker = "python_full_version >= '3.11'" }, - { name = "tokenizers", marker = "python_full_version >= '3.11'" }, - { name = "tqdm", marker = "python_full_version >= '3.11'" }, + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, @@ -1059,10 +1059,10 @@ name = "gensim" version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "smart-open", marker = "python_full_version < '3.13'" }, + { name = "smart-open" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/80/fe9d2e1ace968041814dbcfce4e8499a643a36c41267fa4b6c4f54cce420/gensim-4.4.0.tar.gz", hash = "sha256:a3f5b626da5518e79a479140361c663089fe7998df8ba52d56e1ded71ac5bdf5", size = 23260095, upload-time = "2025-10-18T02:06:45.962Z" } wheels = [ @@ -1145,6 +1145,7 @@ all = [ { name = "python-docx" }, { name = "starlette" }, { name = "tiktoken" }, + { name = "tree-sitter-clojure-orchard" }, { name = "tree-sitter-dm" }, { name = "tree-sitter-hcl" }, { name = "tree-sitter-pascal" }, @@ -1161,6 +1162,9 @@ bedrock = [ chinese = [ { name = "jieba" }, ] +clojure = [ + { name = "tree-sitter-clojure-orchard" }, +] dm = [ { name = "tree-sitter-dm" }, ] @@ -1242,6 +1246,7 @@ dev = [ { name = "ruff" }, { name = "setuptools" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tree-sitter-clojure-orchard" }, { name = "tree-sitter-hcl" }, { name = "wheel" }, ] @@ -1296,6 +1301,8 @@ requires-dist = [ { name = "tree-sitter-bash", specifier = ">=0.23,<0.27" }, { name = "tree-sitter-c", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-c-sharp", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-clojure-orchard", marker = "extra == 'all'", specifier = ">=0.2.8,<0.3" }, + { name = "tree-sitter-clojure-orchard", marker = "extra == 'clojure'", specifier = ">=0.2.8,<0.3" }, { name = "tree-sitter-cpp", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-dm", marker = "extra == 'all'" }, { name = "tree-sitter-dm", marker = "extra == 'dm'" }, @@ -1331,7 +1338,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.6.9" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.6.9" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "clojure", "pascal", "dm", "terraform", "all"] [package.metadata.requires-dev] dev = [ @@ -1348,6 +1355,7 @@ dev = [ { name = "ruff", specifier = ">=0.15.13" }, { name = "setuptools", specifier = ">=82.0.1" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, + { name = "tree-sitter-clojure-orchard", specifier = ">=0.2.8,<0.3" }, { name = "tree-sitter-hcl", specifier = ">=1.2.0" }, { name = "wheel", specifier = ">=0.47.0" }, ] @@ -1357,26 +1365,26 @@ name = "graspologic" version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anytree", marker = "python_full_version < '3.13'" }, - { name = "beartype", marker = "python_full_version < '3.13'" }, - { name = "future", marker = "python_full_version < '3.13'" }, - { name = "gensim", marker = "python_full_version < '3.13'" }, - { name = "graspologic-native", marker = "python_full_version < '3.13'" }, - { name = "hyppo", marker = "python_full_version < '3.13'" }, - { name = "joblib", marker = "python_full_version < '3.13'" }, - { name = "matplotlib", marker = "python_full_version < '3.13'" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "anytree" }, + { name = "beartype" }, + { name = "future" }, + { name = "gensim" }, + { name = "graspologic-native" }, + { name = "hyppo" }, + { name = "joblib" }, + { name = "matplotlib" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "pot", marker = "python_full_version < '3.13'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pot" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "seaborn", marker = "python_full_version < '3.13'" }, - { name = "statsmodels", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "umap-learn", marker = "python_full_version < '3.13'" }, + { name = "seaborn" }, + { name = "statsmodels" }, + { name = "typing-extensions" }, + { name = "umap-learn" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/bb/0fe2ef85ea775e7b8416b2cf90097aa4b5e0c9c2271d7fe6789bab27d0ca/graspologic-3.4.4.tar.gz", hash = "sha256:79878caf367da3e89046a4ec94291c5b1a5da569f19fdd879d8b45c3563d7110", size = 5134258, upload-time = "2025-09-08T21:44:01.969Z" } wheels = [ @@ -1478,15 +1486,15 @@ name = "huggingface-hub" version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock", marker = "python_full_version >= '3.11'" }, - { name = "fsspec", marker = "python_full_version >= '3.11'" }, - { name = "hf-xet", marker = "(python_full_version >= '3.11' and platform_machine == 'AMD64') or (python_full_version >= '3.11' and platform_machine == 'aarch64') or (python_full_version >= '3.11' and platform_machine == 'amd64') or (python_full_version >= '3.11' and platform_machine == 'arm64') or (python_full_version >= '3.11' and platform_machine == 'x86_64')" }, - { name = "httpx", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "tqdm", marker = "python_full_version >= '3.11'" }, - { name = "typer", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/b6/e22bd20a25299c34b8c5922c1545a6320825b13906eb0f7298edfd034a0b/huggingface_hub-1.15.0.tar.gz", hash = "sha256:28abfdddda3927fd4de6a63cf26ab012498a2c24dae52baf150c5c6edf98a1d5", size = 784100, upload-time = "2026-05-15T11:42:52.149Z" } wheels = [ @@ -1511,18 +1519,18 @@ name = "hyppo" version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "autograd", marker = "python_full_version < '3.13'" }, - { name = "future", marker = "python_full_version < '3.13'" }, - { name = "numba", marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "autograd" }, + { name = "future" }, + { name = "numba" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "patsy", marker = "python_full_version < '3.13'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "patsy" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "statsmodels", marker = "python_full_version < '3.13'" }, + { name = "statsmodels" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/a6/0d84fe8486a1447da8bdb8ebb249d525fd8c1d0fe038bceb003c6e0513f9/hyppo-0.5.2.tar.gz", hash = "sha256:4634d15516248a43d25c241ed18beeb79bb3210360f7253693b3f154fe8c9879", size = 125115, upload-time = "2025-05-24T18:33:27.418Z" } wheels = [ @@ -1552,7 +1560,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.11'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2279,8 +2287,8 @@ name = "numba" version = "0.65.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "llvmlite", marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "llvmlite" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } wheels = [ @@ -2440,11 +2448,11 @@ name = "onnxruntime" version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "flatbuffers", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "flatbuffers" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "protobuf", marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "protobuf" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/d4/81/29a9eb470994a75eb7b3ccf32be314d7c66675a00ac7b50294816cc2db27/onnxruntime-1.26.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ee1109ef4ef27cad90e823399e61e03b3c6c7bfe0fb820b4baf3678c15be8b3c", size = 18005108, upload-time = "2026-05-08T19:08:11.728Z" }, @@ -2530,10 +2538,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2596,9 +2604,9 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ @@ -2672,7 +2680,7 @@ name = "patsy" version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } wheels = [ @@ -2855,8 +2863,8 @@ name = "pot" version = "0.9.6.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/8b/5f939eaf1fbeb7ff914fe540d659486951a056e5537b8f454362045b6c72/pot-0.9.6.post1.tar.gz", hash = "sha256:9b6cc14a8daecfe1268268168cf46548f9130976b22b24a9e8ec62a734be6c43", size = 604243, upload-time = "2025-09-22T12:51:14.894Z" } @@ -3199,12 +3207,12 @@ name = "pynndescent" version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib", marker = "python_full_version < '3.13'" }, - { name = "llvmlite", marker = "python_full_version < '3.13'" }, - { name = "numba", marker = "python_full_version < '3.13'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "joblib" }, + { name = "llvmlite" }, + { name = "numba" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" } @@ -3870,10 +3878,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "joblib", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, + { name = "joblib" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -3919,10 +3927,10 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "joblib" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -3972,7 +3980,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -4033,7 +4041,7 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4104,9 +4112,9 @@ name = "seaborn" version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib", marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } @@ -4146,7 +4154,7 @@ name = "smart-open" version = "7.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/65/3ada667d32675399001bf022ad3d9f3989b57101351ebc71d6fbe2384634/smart_open-7.6.1.tar.gz", hash = "sha256:4347996e7ba21db7cd1e059632e0b30395407e4f6c660d2ddffc8f2a9ae5f990", size = 54754, upload-time = "2026-05-09T06:23:37.06Z" } wheels = [ @@ -4211,12 +4219,12 @@ name = "statsmodels" version = "0.14.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "packaging", marker = "python_full_version < '3.13'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "patsy", marker = "python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "patsy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } @@ -4337,7 +4345,7 @@ name = "tokenizers" version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.11'" }, + { name = "huggingface-hub" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } wheels = [ @@ -4525,6 +4533,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/fb/114ff43fdd256d0befed32f77c1dadee9517867181c70794571f718ed05c/tree_sitter_c_sharp-0.23.5-cp310-abi3-win_arm64.whl", hash = "sha256:2de4ebf95ddc2e92cd3105c8a8e0e7ec646bc82f52bfaf2f3acec0fa2401ec09", size = 337260, upload-time = "2026-04-14T16:11:20.849Z" }, ] +[[package]] +name = "tree-sitter-clojure-orchard" +version = "0.2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/79/a47312e43f85ab1b209a0e7e61c03e9efecfac562a05d02b312c4580e7b6/tree_sitter_clojure_orchard-0.2.8.tar.gz", hash = "sha256:aa9835c11e49dd1dfc359c15be1d33038ffc107d3254827fcc4020f0678844fd", size = 59493, upload-time = "2026-07-17T21:08:01.008Z" } + [[package]] name = "tree-sitter-cpp" version = "0.23.4" @@ -4928,10 +4942,10 @@ name = "typer" version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc", marker = "python_full_version >= '3.11'" }, - { name = "click", marker = "python_full_version >= '3.11'" }, - { name = "rich", marker = "python_full_version >= '3.11'" }, - { name = "shellingham", marker = "python_full_version >= '3.11'" }, + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ @@ -4973,14 +4987,14 @@ name = "umap-learn" version = "0.5.12" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numba", marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "pynndescent", marker = "python_full_version < '3.13'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numba" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pynndescent" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "tqdm", marker = "python_full_version < '3.13'" }, + { name = "tqdm" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/ee/af4171241117f85c74b5ca6448ea1033cc28d599c13651d67289bacd4083/umap_learn-0.5.12.tar.gz", hash = "sha256:6aff02ecac5f2aad9f3c65ee518d7ae93e1a985ae38721fdcffceee4232c33c7", size = 96672, upload-time = "2026-04-08T20:03:54.012Z" } wheels = [ @@ -5001,8 +5015,8 @@ name = "uvicorn" version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, - { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "click" }, + { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } From 8f69b18ff19df92d8376b63123186cefb10f3d23 Mon Sep 17 00:00:00 2001 From: Bob Spryn Date: Sun, 26 Jul 2026 14:27:57 -0400 Subject: [PATCH 2/2] fix(clojure): keep sigil-suffixed symbols from colliding into one node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize_id() collapses every non-word run to "_" and strips it, so `live?`, `save!` and `x*` each normalized onto the bare name. _clojure_id() guarded only case (Foo vs foo), leaving the sigil pairs to fuse — and since a predicate beside its plain counterpart is ordinary Clojure, the extractor hit its own ID-collision assertion and dropped the WHOLE file from the graph. A namespace defining `live?` next to `live` contributed nothing. Generalize the guard from "case is significant" to "anything normalization destroys is significant". Only `-`, `.` and `/` are treated as information-preserving: they always mean `_`, and `_` is not idiomatic inside a Clojure symbol, which is what makes that mapping safe. Any other divergence between the exact name and its normalized form pins the name with a short digest. Kebab-case functions and dotted namespaces therefore keep their readable IDs (`fetch-record` -> `..._fetch_record`, `nucleus.store.send-records` -> `nucleus_store_send_records`); only genuinely ambiguous names pay for the disambiguation. On a 215-file Clojure corpus that is 16% of nodes. Call edges are unaffected: they resolve through definition_ids_by_name, which is keyed on the exact source name, so a call to `live?` still reaches `live?` and not `live`. --- graphify/extractors/clojure.py | 38 +++++++++++++++++--- tests/test_languages.py | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/graphify/extractors/clojure.py b/graphify/extractors/clojure.py index be0d7ea81..661c13100 100644 --- a/graphify/extractors/clojure.py +++ b/graphify/extractors/clojure.py @@ -2,23 +2,51 @@ from __future__ import annotations import hashlib +import re from pathlib import Path from typing import Any from graphify.extractors.base import _make_id +# Separators that always *mean* "_" in a Clojure name, so rewriting them loses +# nothing: kebab-case words, namespace dots, and the alias slash. `_` itself is +# not idiomatic inside a Clojure symbol, which is what makes the mapping safe. +_BENIGN_SEPARATORS = str.maketrans({"-": "_", ".": "_", "/": "_"}) + def _clojure_id(*parts: str) -> str: - """Build a canonical ID while preserving Clojure's case distinctions.""" + """Build a canonical ID that preserves the distinctions Clojure makes. + + ``normalize_id`` casefolds and collapses every non-word run to ``_``, which + erases two things Clojure treats as significant: symbol case (``Foo`` vs + ``foo``) and the sigils that separate a predicate or mutating name from its + plain counterpart — ``live?``/``live``, ``save!``/``save``, ``x*``/``x``. + Both pairs are ordinary in real code (``live?``/``live`` in one namespace is + what first tripped this), and an ID built by normalization alone fuses them + into a single node, so two different definitions collide. + + Only the separators in ``_BENIGN_SEPARATORS`` are treated as + information-preserving. Any *other* divergence between the exact name and + its normalized form means the ID cannot round-trip, so the exact name is + pinned with a short digest. Names that normalize faithfully — the common + case, including every kebab-case function and dotted namespace — keep their + readable ID, so only the genuinely ambiguous ones pay for the disambiguation. + """ node_id = _make_id(*parts) - exact = "\0".join(parts) - if exact == exact.casefold(): + # Mirror make_id's join (per-part strip, falsy parts dropped) and + # normalize_id's trailing collapse/strip, but rewrite ONLY the benign + # separators. Whatever still differs from node_id is information that + # normalization destroyed — including case, which node_id casefolds away. + joined = "_".join(p.strip("_.") for p in parts if p) + faithful = joined.translate(_BENIGN_SEPARATORS) + faithful = re.sub(r"_+", "_", faithful).strip("_") + if faithful == node_id: return node_id digest = hashlib.sha1( - exact.encode("utf-8"), + "\0".join(parts).encode("utf-8"), usedforsecurity=False, ).hexdigest()[:8] - return _make_id(node_id, "case", digest) + return _make_id(node_id, "sym", digest) def extract_clojure(path: Path) -> dict: diff --git a/tests/test_languages.py b/tests/test_languages.py index 7559a8422..8b9ebf509 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -204,6 +204,70 @@ def test_clojure_preserves_case_sensitive_symbols(tmp_path): assert not graph.has_edge(ids_by_label["call-lower()"], ids_by_label["Foo()"]) +def test_clojure_distinguishes_sigil_suffixed_symbols(tmp_path): + """A predicate/mutating name and its plain counterpart in one namespace are + two definitions, not one. Node IDs come from normalize_id, which collapses + every non-word run to `_` and strips it, so `live?`, `save!` and `x*` each + normalized onto the bare name — the extractor then hit its own ID-collision + assertion and dropped the WHOLE file from the graph (nucleus's + domain/templates.clj, which defines `live?` beside `live`).""" + source = tmp_path / "sigils.clj" + source.write_text( + """(ns sample.sigils) +(defn live? [x] (boolean x)) +(defn live [x] x) +(defn save! [x] x) +(defn save [x] x) +(defn check [x] (live? x)) +(defn use-plain [x] (live x)) +(defn persist [x] (save! x)) +""", + encoding="utf-8", + ) + + result = extract_clojure(source) + assert not result.get("error"), result.get("error") + + ids_by_label = {node["label"]: node["id"] for node in result["nodes"]} + for label in ("live?()", "live()", "save!()", "save()"): + assert label in ids_by_label, f"{label} missing — the file was dropped" + assert ids_by_label["live?()"] != ids_by_label["live()"] + assert ids_by_label["save!()"] != ids_by_label["save()"] + + # The sigil-free name keeps its readable ID; only the ambiguous one pays. + assert ids_by_label["live()"] == "sample_sigils_live" + assert "_sym_" in ids_by_label["live?()"] + + # Calls must reach the variant actually named at the call site. + calls = _calls(result) + assert ("check()", "live?()") in calls + assert ("check()", "live()") not in calls + assert ("use-plain()", "live()") in calls + assert ("use-plain()", "live?()") not in calls + assert ("persist()", "save!()") in calls + assert ("persist()", "save()") not in calls + + +def test_clojure_kebab_and_dotted_names_keep_readable_ids(tmp_path): + """The sigil disambiguation must not tax the common case: `-` and `.` are + pure separators that always mean `_`, so kebab-case functions and dotted + namespaces keep their plain, greppable IDs rather than a hash suffix.""" + source = tmp_path / "kebab.clj" + source.write_text( + """(ns sample.store.send-records) +(defn fetch-record [id] id) +""", + encoding="utf-8", + ) + + result = extract_clojure(source) + assert not result.get("error"), result.get("error") + ids_by_label = {node["label"]: node["id"] for node in result["nodes"]} + assert ids_by_label["sample.store.send-records"] == "sample_store_send_records" + assert ids_by_label["fetch-record()"].endswith("_fetch_record") + assert "_sym_" not in ids_by_label["fetch-record()"] + + def test_clojure_uses_symbol_names_without_metadata(tmp_path): source = tmp_path / "metadata.clj" source.write_text(