mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 08:46:43 +00:00
feat(ocaml): add OCaml .ml/.mli extractor (optional tree-sitter-ocaml extra)
New graphify/extractors/ocaml.py handles both the implementation grammar (language_ocaml, .ml) and the interface grammar (language_ocaml_interface, .mli). Emits nodes for modules, top-level/module-level values and functions, types and their variant constructors; edges for defines/contains, open -> imports_from, and application -> calls. Qualified paths (Geo.area) resolve to the final value name, not the module qualifier; local let ... in bindings do not mint nodes or steal call attribution. Cross-file open/call targets are sourceless stubs so the corpus rewire collapses them onto the unique real definition (no #1402 sourced-stub leak). Wired into detect.py (CODE_EXTENSIONS), extract.py (dispatch + _EXTRA_FOR_EXTENSION), pyproject.toml ([ocaml] extra + all + dev dep), and README. Adds tests/test_ocaml.py (behind importorskip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7fe58b0b0f
commit
0302bfa7af
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## 0.9.42 (unreleased)
|
||||
|
||||
- Feature: OCaml `.ml`/`.mli` extraction via tree-sitter-ocaml (optional `[ocaml]` extra). Extracts modules, top-level and module-level values/functions, types and their variant constructors, `open` imports, and function calls; qualified calls (`Geo.area`) resolve to the value, and cross-file `open`/call targets collapse onto the unique real definition via the corpus stub rewire.
|
||||
- Fix: a JS/TS `for...of` / `for...in` loop binding is now shadowed, so passing it as a call argument no longer fabricates an `indirect_call` edge to an unrelated same-named callable (#2685, thanks @ousamabenyounes); completes the loop/closure/catch shadow family (#2568/#2569/#2517).
|
||||
- Fix: graph provenance (`built_at_commit`) is stamped from the analysed repository rather than the shell's working directory, so `graphify extract` run from elsewhere records the target's commit, not the caller's (#2534 family; #2699, thanks @C0KERNEL).
|
||||
- Fix: `affected` resolves a seed passed as a `./`-relative path (or an absolute path when run from the repo root) instead of silently returning nothing (#2707, thanks @phudayyy). Note: an absolute-path seed still requires the working directory to be the analysed repo root.
|
||||
|
||||
@@ -266,6 +266,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi
|
||||
| `dm` | BYOND DreamMaker `.dm`/`.dme` AST extraction (may need a C compiler + `python3-dev` if no wheel matches your platform) | `uv tool install "graphifyy[dm]"` |
|
||||
| `terraform` | Terraform / HCL `.tf`/`.tfvars`/`.hcl` AST extraction | `uv tool install "graphifyy[terraform]"` |
|
||||
| `pascal` | Pascal / Delphi `.pas`/`.dpr`/`.dpk`/`.inc` AST extraction (more accurate `calls`/`inherits` edges; falls back to a regex extractor when absent) | `uv tool install "graphifyy[pascal]"` |
|
||||
| `ocaml` | OCaml `.ml`/`.mli` AST extraction | `uv tool install "graphifyy[ocaml]"` |
|
||||
| `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` |
|
||||
| `all` | Everything above | `uv tool install "graphifyy[all]"` |
|
||||
|
||||
@@ -335,9 +336,10 @@ 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 .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .ml .mli .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
|
||||
| Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) |
|
||||
| Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) |
|
||||
| OCaml | `.ml .mli` (requires `uv tool install graphifyy[ocaml]`) |
|
||||
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
|
||||
| Package manifests | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` — one canonical package node per package (by name) plus `depends_on` edges, so a package referenced from many manifests is a single hub |
|
||||
| Docs | `.md .mdx .qmd .html .txt .rst .yaml .yml` (markdown `[text](./other.md)` links and `[[wikilinks]]` become `references` edges between docs) |
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ _MANIFEST_PATH = str(out_path("manifest.json"))
|
||||
_MTIME_COARSE_S = 2.0
|
||||
_MTIME_SUBSECOND_S = 0.05
|
||||
|
||||
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.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', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
|
||||
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'}
|
||||
PAPER_EXTENSIONS = {'.pdf'}
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
||||
|
||||
@@ -46,6 +46,7 @@ from graphify.extractors.fortran import _cpp_preprocess, extract_fortran # noqa
|
||||
from graphify.extractors.go import _GO_PREDECLARED_FUNCS, extract_go # noqa: F401
|
||||
from graphify.extractors.json_config import extract_json # noqa: F401
|
||||
from graphify.extractors.markdown import extract_markdown # noqa: F401
|
||||
from graphify.extractors.ocaml import extract_ocaml # noqa: F401
|
||||
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401
|
||||
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
|
||||
from graphify.extractors.razor import extract_razor # noqa: F401
|
||||
@@ -4823,6 +4824,8 @@ _DISPATCH: dict[str, Any] = {
|
||||
".svelte": extract_svelte,
|
||||
".astro": extract_astro,
|
||||
".dart": extract_dart,
|
||||
".ml": extract_ocaml,
|
||||
".mli": extract_ocaml,
|
||||
".v": extract_verilog,
|
||||
".sv": extract_verilog,
|
||||
".svh": extract_verilog,
|
||||
@@ -4875,6 +4878,8 @@ _EXTRA_FOR_EXTENSION = {
|
||||
".hcl": "terraform",
|
||||
".dm": "dm",
|
||||
".dme": "dm",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
}
|
||||
|
||||
# Substrings an extractor's error carries to classify why a dependency-backed
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""OCaml extractor (own module, optional tree-sitter-ocaml dependency).
|
||||
|
||||
Handles implementation files (.ml, `language_ocaml`) and interface files
|
||||
(.mli, `language_ocaml_interface`). Interfaces have no expression bodies, so
|
||||
they contribute definitions and `open` imports but no `calls` edges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from graphify.extractors.base import _file_stem, _make_id, _read_text
|
||||
|
||||
|
||||
def extract_ocaml(path: Path) -> dict:
|
||||
"""Extract modules, values, functions, types, variant constructors, `open`
|
||||
imports, and (for .ml) function calls from an OCaml source file."""
|
||||
try:
|
||||
import tree_sitter_ocaml as tsocaml
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return {"nodes": [], "edges": [], "error": "tree-sitter-ocaml not installed"}
|
||||
|
||||
try:
|
||||
if path.suffix == ".mli":
|
||||
language = Language(tsocaml.language_ocaml_interface())
|
||||
else:
|
||||
language = Language(tsocaml.language_ocaml())
|
||||
parser = Parser(language)
|
||||
source = path.read_bytes()
|
||||
tree = parser.parse(source)
|
||||
root = tree.root_node
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
# Same-file definition table for call resolution. A name defined more than
|
||||
# once in the file is marked ambiguous and never resolved locally.
|
||||
local_defs: dict[str, str] = {}
|
||||
ambiguous: set[str] = set()
|
||||
# (caller_nid, callee_name, line) recorded on pass 1, resolved on pass 2 so
|
||||
# that forward references (e.g. `let rec ... and ...`) resolve correctly.
|
||||
call_sites: list[tuple[str, str, int]] = []
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": label,
|
||||
"file_type": "code",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
|
||||
edges.append({
|
||||
"source": src,
|
||||
"target": tgt,
|
||||
"relation": relation,
|
||||
"confidence": confidence,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": weight,
|
||||
})
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
def ref_stub(name: str) -> str:
|
||||
"""Mint a SOURCELESS stub for a cross-file reference target (an `open`ed
|
||||
module or a call to a name not defined in this file). The corpus-level
|
||||
rewire collapses it onto the unique real definition of that name;
|
||||
external names (Stdlib, Core, ...) dangle and are pruned. A *sourced*
|
||||
stub here would bake this file's path into the id and block the rewire
|
||||
(the #1402 phantom-duplicate bug)."""
|
||||
nid = _make_id(name)
|
||||
if nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": name,
|
||||
"file_type": "code",
|
||||
"source_file": "",
|
||||
"source_location": "",
|
||||
"origin_file": str_path,
|
||||
})
|
||||
return nid
|
||||
|
||||
def line_of(node) -> int:
|
||||
return node.start_point[0] + 1
|
||||
|
||||
def named_child_text(node, child_type: str) -> str | None:
|
||||
for child in node.children:
|
||||
if child.type == child_type:
|
||||
return _read_text(child, source)
|
||||
return None
|
||||
|
||||
def last_name(path_node) -> str | None:
|
||||
"""Final component of a *_path node: `Geo.area` -> `area`,
|
||||
`Stdlib.List` -> `List`. The qualifier is nested under a child
|
||||
`module_path`, so the final name is always a *direct* child of the path
|
||||
node -- a deep search would wrongly return the module qualifier."""
|
||||
found: str | None = None
|
||||
for n in path_node.children:
|
||||
if n.type in ("value_name", "module_name", "constructor_name"):
|
||||
found = _read_text(n, source)
|
||||
return found
|
||||
|
||||
def register_def(name: str, nid: str) -> None:
|
||||
if name in ambiguous:
|
||||
return
|
||||
if name in local_defs and local_defs[name] != nid:
|
||||
ambiguous.add(name)
|
||||
local_defs.pop(name, None)
|
||||
return
|
||||
local_defs[name] = nid
|
||||
|
||||
def emit_type(binding, container_nid: str) -> None:
|
||||
type_name = named_child_text(binding, "type_constructor")
|
||||
if not type_name:
|
||||
return
|
||||
line = line_of(binding)
|
||||
nid = _make_id(stem, type_name)
|
||||
add_node(nid, type_name, line)
|
||||
add_edge(container_nid, nid, "defines" if container_nid == file_nid else "contains", line)
|
||||
register_def(type_name, nid)
|
||||
# Variant constructors: variant_declaration -> constructor_declaration -> constructor_name
|
||||
for vd in binding.children:
|
||||
if vd.type != "variant_declaration":
|
||||
continue
|
||||
for cd in vd.children:
|
||||
if cd.type != "constructor_declaration":
|
||||
continue
|
||||
cname = named_child_text(cd, "constructor_name")
|
||||
if not cname:
|
||||
continue
|
||||
cnid = _make_id(stem, type_name, cname)
|
||||
add_node(cnid, cname, line_of(cd))
|
||||
add_edge(nid, cnid, "contains", line_of(cd))
|
||||
|
||||
def walk(node, container_nid: str, enclosing_value: str) -> None:
|
||||
t = node.type
|
||||
|
||||
if t == "open_module":
|
||||
mp = next((c for c in node.children if c.type == "module_path"), None)
|
||||
name = last_name(mp) if mp is not None else None
|
||||
if name:
|
||||
add_edge(container_nid, ref_stub(name), "imports_from", line_of(node),
|
||||
confidence="INFERRED")
|
||||
return
|
||||
|
||||
if t == "module_definition":
|
||||
binding = next((c for c in node.children if c.type == "module_binding"), None)
|
||||
if binding is not None:
|
||||
mname = named_child_text(binding, "module_name")
|
||||
if mname:
|
||||
line = line_of(node)
|
||||
mnid = _make_id(stem, mname)
|
||||
add_node(mnid, mname, line)
|
||||
add_edge(container_nid, mnid,
|
||||
"defines" if container_nid == file_nid else "contains", line)
|
||||
register_def(mname, mnid)
|
||||
for child in binding.children:
|
||||
walk(child, mnid, enclosing_value)
|
||||
return
|
||||
|
||||
if t == "module_type_definition": # .mli
|
||||
mname = named_child_text(node, "module_type_name")
|
||||
if mname:
|
||||
line = line_of(node)
|
||||
mnid = _make_id(stem, mname)
|
||||
add_node(mnid, mname, line)
|
||||
add_edge(container_nid, mnid,
|
||||
"defines" if container_nid == file_nid else "contains", line)
|
||||
register_def(mname, mnid)
|
||||
for child in node.children:
|
||||
walk(child, mnid, enclosing_value)
|
||||
return
|
||||
|
||||
if t == "value_definition": # .ml
|
||||
# Only structure/top-level bindings are real definitions. A local
|
||||
# `let x = e in body` is also a value_definition, but nested under a
|
||||
# let_expression; it must NOT mint a node or steal call attribution
|
||||
# from the enclosing named function.
|
||||
is_toplevel = node.parent is not None and node.parent.type in (
|
||||
"compilation_unit", "structure")
|
||||
for lb in node.children:
|
||||
if lb.type != "let_binding":
|
||||
continue
|
||||
vname = named_child_text(lb, "value_name")
|
||||
new_scope = enclosing_value
|
||||
if vname and is_toplevel:
|
||||
line = line_of(lb)
|
||||
nid = _make_id(stem, vname)
|
||||
add_node(nid, vname, line)
|
||||
add_edge(container_nid, nid,
|
||||
"defines" if container_nid == file_nid else "contains", line)
|
||||
register_def(vname, nid)
|
||||
new_scope = nid
|
||||
# Descend into the body (unit/pattern/local bindings keep the
|
||||
# outer scope) to collect nested definitions and call sites.
|
||||
for child in lb.children:
|
||||
walk(child, container_nid, new_scope)
|
||||
return
|
||||
|
||||
if t == "value_specification": # .mli
|
||||
vname = named_child_text(node, "value_name")
|
||||
if vname:
|
||||
line = line_of(node)
|
||||
nid = _make_id(stem, vname)
|
||||
add_node(nid, vname, line)
|
||||
add_edge(container_nid, nid,
|
||||
"defines" if container_nid == file_nid else "contains", line)
|
||||
register_def(vname, nid)
|
||||
return
|
||||
|
||||
if t == "type_definition":
|
||||
for binding in node.children:
|
||||
if binding.type == "type_binding":
|
||||
emit_type(binding, container_nid)
|
||||
return
|
||||
|
||||
if t == "application_expression":
|
||||
fn = node.named_children[0] if node.named_children else None
|
||||
if fn is not None and fn.type == "value_path":
|
||||
callee = last_name(fn)
|
||||
if callee:
|
||||
caller = enclosing_value if enclosing_value else file_nid
|
||||
call_sites.append((caller, callee, line_of(node)))
|
||||
# Fall through: arguments may contain further applications/definitions.
|
||||
|
||||
for child in node.children:
|
||||
walk(child, container_nid, enclosing_value)
|
||||
|
||||
walk(root, file_nid, "")
|
||||
|
||||
for caller, callee, line in call_sites:
|
||||
if callee in local_defs:
|
||||
add_edge(caller, local_defs[callee], "calls", line)
|
||||
else:
|
||||
add_edge(caller, ref_stub(callee), "calls", line, confidence="INFERRED")
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
+5
-1
@@ -85,7 +85,10 @@ 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>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"]
|
||||
# tree-sitter-ocaml ships prebuilt abi3 wheels for every platform, so no C
|
||||
# toolchain is needed; kept optional because OCaml is a niche corpus language.
|
||||
ocaml = ["tree-sitter-ocaml"]
|
||||
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml"]
|
||||
|
||||
[project.scripts]
|
||||
graphify = "graphify.__main__:main"
|
||||
@@ -108,6 +111,7 @@ dev = [
|
||||
"wheel>=0.47.0",
|
||||
"tomli>=2.0 ; python_version < '3.11'",
|
||||
"tree-sitter-hcl>=1.2.0",
|
||||
"tree-sitter-ocaml>=0.25.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for the OCaml extractor (graphify/extractors/ocaml.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("tree_sitter_ocaml")
|
||||
|
||||
from graphify.extract import extract_ocaml
|
||||
|
||||
|
||||
def _write(tmp_path: Path, name: str, body: str) -> Path:
|
||||
p = tmp_path / name
|
||||
p.write_text(body, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _labels(r) -> set[str]:
|
||||
return {n["label"] for n in r["nodes"]}
|
||||
|
||||
|
||||
def _rel_pairs(r, relation: str) -> set[tuple[str, str]]:
|
||||
lab = {n["id"]: n["label"] for n in r["nodes"]}
|
||||
return {
|
||||
(lab.get(e["source"], e["source"]), lab.get(e["target"], e["target"]))
|
||||
for e in r["edges"]
|
||||
if e["relation"] == relation
|
||||
}
|
||||
|
||||
|
||||
IMPL = """\
|
||||
open Stdlib
|
||||
|
||||
module Shapes = struct
|
||||
type shape = Circle | Square | Triangle
|
||||
|
||||
let pi = 3.14159
|
||||
|
||||
let area_of radius =
|
||||
let squared = radius *. radius in
|
||||
pi *. squared
|
||||
|
||||
let describe r =
|
||||
let a = area_of r in
|
||||
print_float a
|
||||
end
|
||||
|
||||
let main () =
|
||||
let a = Shapes.area_of 2.0 in
|
||||
print_float a
|
||||
"""
|
||||
|
||||
|
||||
def test_impl_defines_module_values_and_types(tmp_path):
|
||||
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
|
||||
assert "error" not in r
|
||||
labels = _labels(r)
|
||||
# module, values/functions, type, variant constructors
|
||||
assert {"Shapes", "pi", "area_of", "describe", "main", "shape"} <= labels
|
||||
assert {"Circle", "Square", "Triangle"} <= labels
|
||||
|
||||
|
||||
def test_impl_containment_and_defines(tmp_path):
|
||||
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
|
||||
# file defines the top-level module and top-level `main`
|
||||
defines = _rel_pairs(r, "defines")
|
||||
assert ("shapes.ml", "Shapes") in defines
|
||||
assert ("shapes.ml", "main") in defines
|
||||
# module contains its members
|
||||
contains = _rel_pairs(r, "contains")
|
||||
assert ("Shapes", "area_of") in contains
|
||||
assert ("Shapes", "shape") in contains
|
||||
# variant constructors are contained by their type
|
||||
assert ("shape", "Circle") in contains
|
||||
|
||||
|
||||
def test_impl_calls_resolve_same_file(tmp_path):
|
||||
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
|
||||
calls = _rel_pairs(r, "calls")
|
||||
# describe -> area_of is a same-file, unambiguous resolution
|
||||
assert ("describe", "area_of") in calls
|
||||
# a qualified call `Shapes.area_of` resolves to the value `area_of`, NOT the
|
||||
# module qualifier `Shapes`.
|
||||
assert ("main", "area_of") in calls
|
||||
assert ("main", "Shapes") not in calls
|
||||
|
||||
|
||||
def test_impl_open_emits_import(tmp_path):
|
||||
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
|
||||
imports = _rel_pairs(r, "imports_from")
|
||||
assert ("shapes.ml", "Stdlib") in imports
|
||||
|
||||
|
||||
def test_open_stub_is_sourceless(tmp_path):
|
||||
# An `open`ed external module must be a SOURCELESS stub so the corpus rewire
|
||||
# can collapse/prune it without baking this file's path into the id (#1402).
|
||||
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
|
||||
stubs = [n for n in r["nodes"] if n["label"] == "Stdlib"]
|
||||
assert stubs and all(n["source_file"] == "" for n in stubs)
|
||||
# origin_file is an internal rewire hint, never a real source path.
|
||||
assert all(n.get("source_location") == "" for n in stubs)
|
||||
|
||||
|
||||
INTERFACE = """\
|
||||
open Base
|
||||
|
||||
module type Store = sig
|
||||
type t
|
||||
val make : int -> t
|
||||
val size : t -> int
|
||||
end
|
||||
|
||||
type color = Red | Green | Blue
|
||||
|
||||
val hello : string -> unit
|
||||
"""
|
||||
|
||||
|
||||
def test_interface_defines_signatures(tmp_path):
|
||||
r = extract_ocaml(_write(tmp_path, "store.mli", INTERFACE))
|
||||
assert "error" not in r
|
||||
labels = _labels(r)
|
||||
assert {"Store", "make", "size", "color", "hello"} <= labels
|
||||
assert {"Red", "Green", "Blue"} <= labels
|
||||
# interfaces have no expression bodies -> no calls
|
||||
assert not [e for e in r["edges"] if e["relation"] == "calls"]
|
||||
|
||||
|
||||
def test_no_dangling_edges(tmp_path):
|
||||
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
|
||||
ids = {n["id"] for n in r["nodes"]}
|
||||
for e in r["edges"]:
|
||||
assert e["source"] in ids, e
|
||||
assert e["target"] in ids, e
|
||||
|
||||
|
||||
def test_missing_file_returns_error(tmp_path):
|
||||
r = extract_ocaml(tmp_path / "nope.ml")
|
||||
assert r["nodes"] == [] and r["edges"] == []
|
||||
assert "error" in r
|
||||
@@ -1090,7 +1090,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "graphifyy"
|
||||
version = "0.9.41"
|
||||
version = "0.9.42"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
@@ -1147,6 +1147,7 @@ all = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tree-sitter-dm" },
|
||||
{ name = "tree-sitter-hcl" },
|
||||
{ name = "tree-sitter-ocaml" },
|
||||
{ name = "tree-sitter-pascal" },
|
||||
{ name = "tree-sitter-sql" },
|
||||
{ name = "watchdog" },
|
||||
@@ -1188,6 +1189,9 @@ mcp = [
|
||||
neo4j = [
|
||||
{ name = "neo4j" },
|
||||
]
|
||||
ocaml = [
|
||||
{ name = "tree-sitter-ocaml" },
|
||||
]
|
||||
office = [
|
||||
{ name = "openpyxl" },
|
||||
{ name = "python-docx" },
|
||||
@@ -1243,6 +1247,7 @@ dev = [
|
||||
{ name = "setuptools" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "tree-sitter-hcl" },
|
||||
{ name = "tree-sitter-ocaml" },
|
||||
{ name = "wheel" },
|
||||
]
|
||||
|
||||
@@ -1312,6 +1317,8 @@ requires-dist = [
|
||||
{ name = "tree-sitter-kotlin", specifier = ">=1.0,<2.0" },
|
||||
{ name = "tree-sitter-lua", specifier = ">=0.2,<0.6" },
|
||||
{ name = "tree-sitter-objc", specifier = ">=3.0,<4.0" },
|
||||
{ name = "tree-sitter-ocaml", marker = "extra == 'all'" },
|
||||
{ name = "tree-sitter-ocaml", marker = "extra == 'ocaml'" },
|
||||
{ name = "tree-sitter-pascal", marker = "extra == 'all'" },
|
||||
{ name = "tree-sitter-pascal", marker = "extra == 'pascal'" },
|
||||
{ name = "tree-sitter-php", specifier = ">=0.23,<0.25" },
|
||||
@@ -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", "pascal", "dm", "terraform", "ocaml", "all"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -1349,6 +1356,7 @@ dev = [
|
||||
{ name = "setuptools", specifier = ">=82.0.1" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" },
|
||||
{ name = "tree-sitter-hcl", specifier = ">=1.2.0" },
|
||||
{ name = "tree-sitter-ocaml", specifier = ">=0.25.0" },
|
||||
{ name = "wheel", specifier = ">=0.47.0" },
|
||||
]
|
||||
|
||||
@@ -4734,6 +4742,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ec/34de4da134f48373d2986137e785da86f4df2b70f688307856588a473cff/tree_sitter_objc-3.0.2-cp39-abi3-win_arm64.whl", hash = "sha256:9a99d9b81a4e507bd33329be136928b3ebe424ce8b9d6b8a8339083ceb453b5b", size = 301378, upload-time = "2024-12-16T00:37:36.424Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-ocaml"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/51/eab58652cc54221192a82bb0cb2c7ce50954e2b18db4fcfb8791d6dfd7fe/tree_sitter_ocaml-0.25.0.tar.gz", hash = "sha256:f5833261dd85cf170da2bb4dd0273e138bf488f1d6937d38c511b2918c19a51e", size = 1642387, upload-time = "2026-05-09T19:26:40.773Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/c6/0184e6872f24e8ad1eab26e7c9dee6e5ac7980b1cfe4885095453f946579/tree_sitter_ocaml-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ee544bb2c28926e173d78de2ba210c8093fc73cc897da9d93fc9062a9be9695f", size = 663463, upload-time = "2026-05-09T19:26:28.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/04/bdd1257dae21cc1172aed235f7f4e908a21d826d303d5d961e9c566ab2e5/tree_sitter_ocaml-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b376fda33b092befd9cc889b2d1f71d5fca643daff04255990580b563ccea63f", size = 711155, upload-time = "2026-05-09T19:26:30.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/c8/8edd26733d1fe67eb781fd35910c8f22e2f115a941993d8367076f78222c/tree_sitter_ocaml-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2035b643ff801c0a1023026284bb462bbc002f98f577395ade660faf21ff65db", size = 713718, upload-time = "2026-05-09T19:26:32.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/b7/0c67840dfa5e2f3a1c5536c0c9f946a7f573a42fdfd2b2a57fd3d88cabdc/tree_sitter_ocaml-0.25.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7897e35905f851a4465674f88cfe116e58b99d0e60630ad82a211a115402a7d", size = 715599, upload-time = "2026-05-09T19:26:33.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/e1/0f7fc18d3b2e02630b266fee6c2f3416460623746da786cd712a82012036/tree_sitter_ocaml-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8fa32599619e21026d21145d0a1af4a6ef17b36d51c2b9ba9dc470ea5d301047", size = 712176, upload-time = "2026-05-09T19:26:35.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/03/1e0563fff25f21d9d67b7f6edb60a260c19dc9a22f5b0b03700f6b585471/tree_sitter_ocaml-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:72ead1435b43d8434c93e9e71c0429d74e90b5005dfcc43cb7776ae4842a110a", size = 714160, upload-time = "2026-05-09T19:26:36.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/59/5263f0209a9fc31a53aeaa2d7be02bb0cab6420b04fa5dee58800929d6c7/tree_sitter_ocaml-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e0b41127fb602878e757c25f2ecc451688f82f909102f422e782a9b7d4fb3ff0", size = 665343, upload-time = "2026-05-09T19:26:38.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/05/ea0517c683cb5572dd8bd688e119bddbb8d2fd3974fcb1db00e724fa836b/tree_sitter_ocaml-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:e370b50ddbc1de652966a78697258d21a26d59a618ff5807cd343811a480372e", size = 661149, upload-time = "2026-05-09T19:26:39.331Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-pascal"
|
||||
version = "0.11.0"
|
||||
|
||||
Reference in New Issue
Block a user