Add ObjC support, C# inheritance, CLI query, symlink support, bug fixes (v0.3.7-0.3.9)

- Add Objective-C extractor (.m/.mm) with @interface, @implementation, @protocol, imports, calls
- Add C# inheritance extraction via base_list nodes (inherits edges)
- Add --obsidian-dir flag to skill.md and skill-windows.md
- Add graphify query CLI command with --dfs, --budget, --graph flags
- Add follow_symlinks parameter to detect() and collect_files() with cycle detection
- Fix semantic cache relative path resolution (was saving only 4/17 files)
- Fix validate.py missing rationale file_type causing 75 warnings per run
- Add tree-sitter-objc dependency
- Update README with .m/.mm extensions, --obsidian-dir usage, Codex $ skill trigger
- 367 tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-04-07 20:20:49 +01:00
parent 3d5da6039a
commit a78d25d4fc
15 changed files with 511 additions and 37 deletions
+21
View File
@@ -2,6 +2,27 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.3.9 (2026-04-07)
- Add: `follow_symlinks` parameter to `detect()` and `collect_files()` — opt-in symlink following with circular symlink cycle detection (#33)
- Fix: `watch.py` now uses `collect_files()` instead of manual rglob loop for consistency
- Docs: Codex uses `$graphify .` not `/graphify .` (#36)
- Test: 5 new symlink tests (367 total)
## 0.3.8 (2026-04-07)
- Add: C# inheritance and interface implementation extraction — `base_list` now emits `inherits` edges for both simple (`identifier`) and generic (`generic_name`) base types (#45)
- Add: `graphify query "<question>"` CLI command — BFS/DFS traversal of `graph.json` without needing Claude Code skill (`--dfs`, `--budget N`, `--graph <path>` flags)
- Test: 2 new C# inheritance tests (362 total)
## 0.3.7 (2026-04-07)
- Add: Objective-C support (`.m`, `.mm`) — `@interface`, `@implementation`, `@protocol`, method declarations, `#import` directives, message-expression call edges
- Add: `--obsidian-dir <path>` flag — write Obsidian vault to a custom directory instead of `graphify-out/obsidian`
- Fix: semantic cache was only saving 4/17 files — relative paths from subagents now resolved against corpus root before existence check
- Fix: 75 validation warnings per run for `file_type: "rationale"` — added `"rationale"` to `VALID_FILE_TYPES`
- Test: 6 Objective-C tests; `.m`/`.mm` added to `test_collect_files_from_dir` supported set (360 total)
## 0.3.0 (2026-04-06)
- Add: multi-platform support — Codex (`skill-codex.md`), OpenCode (`skill-opencode.md`), OpenClaw (`skill-claw.md`)
+5 -2
View File
@@ -73,6 +73,8 @@ Then open your AI coding assistant and type:
/graphify .
```
Note: Codex uses `$` instead of `/` for skill calling, so type `$graphify .` instead.
### Make your assistant always use the graph (recommended)
After building a graph, run this once in your project:
@@ -126,7 +128,8 @@ When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"`
/graphify ./raw --update # re-extract only changed files, merge into existing graph
/graphify ./raw --cluster-only # rerun clustering on existing graph, no re-extraction
/graphify ./raw --no-viz # skip HTML, just produce report + JSON
/graphify ./raw --obsidian # also generate Obsidian vault (opt-in)
/graphify ./raw --obsidian # also generate Obsidian vault (opt-in)
/graphify ./raw --obsidian --obsidian-dir ~/vaults/myproject # write vault to a specific directory
/graphify add https://arxiv.org/abs/1706.03762 # fetch a paper, save, update graph
/graphify add https://x.com/karpathy/status/... # fetch a tweet
@@ -165,7 +168,7 @@ Works with any mix of file types:
| Type | Extensions | Extraction |
|------|-----------|------------|
| Code | `.py .ts .js .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1` | AST via tree-sitter + call-graph + docstring/comment rationale |
| Code | `.py .ts .js .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .mm` | AST via tree-sitter + call-graph + docstring/comment rationale |
| Docs | `.md .txt .rst` | Concepts + relationships + design rationale via Claude |
| Office | `.docx .xlsx` | Converted to markdown then extracted via Claude (requires `pip install graphifyy[office]`) |
| Papers | `.pdf` | Citation mining + concept extraction |
+33
View File
@@ -288,6 +288,10 @@ def main() -> None:
print()
print("Commands:")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|claw|droid)")
print(" query \"<question>\" BFS traversal of graph.json for a question")
print(" --dfs use depth-first instead of breadth-first")
print(" --budget N cap output at N tokens (default 2000)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
print(" hook install install post-commit/post-checkout git hooks (all platforms)")
print(" hook uninstall remove git hooks")
@@ -352,6 +356,35 @@ def main() -> None:
else:
print("Usage: graphify hook [install|uninstall|status]", file=sys.stderr)
sys.exit(1)
elif cmd == "query":
if len(sys.argv) < 3:
print("Usage: graphify query \"<question>\" [--dfs] [--budget N] [--graph path]", file=sys.stderr)
sys.exit(1)
from graphify.serve import _load_graph, _score_nodes, _bfs, _dfs, _subgraph_to_text
question = sys.argv[2]
use_dfs = "--dfs" in sys.argv
budget = 2000
graph_path = "graphify-out/graph.json"
args = sys.argv[3:]
i = 0
while i < len(args):
if args[i] == "--budget" and i + 1 < len(args):
budget = int(args[i + 1]); i += 2
elif args[i].startswith("--budget="):
budget = int(args[i].split("=", 1)[1]); i += 1
elif args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]; i += 2
else:
i += 1
G = _load_graph(graph_path)
terms = [t.lower() for t in question.split() if len(t) > 2]
scored = _score_nodes(G, terms)
if not scored:
print("No matching nodes found.")
sys.exit(0)
start = [nid for _, nid in scored[:5]]
nodes, edges = (_dfs if use_dfs else _bfs)(G, start, depth=2)
print(_subgraph_to_text(G, nodes, edges, token_budget=budget))
elif cmd == "benchmark":
from graphify.benchmark import run_benchmark, print_benchmark
graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json"
+2
View File
@@ -130,6 +130,8 @@ def save_semantic_cache(
saved = 0
for fpath, result in by_file.items():
p = Path(fpath)
if not p.is_absolute():
p = Path(root) / p
if p.exists():
save_cached(p, result, root)
saved += 1
+9 -3
View File
@@ -17,7 +17,7 @@ class FileType(str, Enum):
_MANIFEST_PATH = "graphify-out/manifest.json"
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs'}
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm'}
DOC_EXTENSIONS = {'.md', '.txt', '.rst'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
@@ -293,7 +293,7 @@ def _is_ignored(path: Path, root: Path, patterns: list[str]) -> bool:
return False
def detect(root: Path) -> dict:
def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
files: dict[FileType, list[str]] = {
FileType.CODE: [],
FileType.DOCUMENT: [],
@@ -316,8 +316,14 @@ def detect(root: Path) -> dict:
for scan_root in scan_paths:
in_memory_tree = memory_dir.exists() and str(scan_root).startswith(str(memory_dir))
for dirpath, dirnames, filenames in os.walk(scan_root, followlinks=False):
for dirpath, dirnames, filenames in os.walk(scan_root, followlinks=follow_symlinks):
dp = Path(dirpath)
if follow_symlinks and os.path.islink(dirpath):
real = os.path.realpath(dirpath)
parent_real = os.path.realpath(os.path.dirname(dirpath))
if parent_real == real or parent_real.startswith(real + os.sep):
dirnames.clear()
continue
if not in_memory_tree:
# Prune noise dirs in-place so os.walk never descends into them
dirnames[:] = [
+259 -13
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import importlib
import json
import os
import re
import sys
from dataclasses import dataclass, field
@@ -743,6 +744,31 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
# C#-specific: inheritance / interface implementation via base_list
if config.ts_module == "tree_sitter_c_sharp":
for child in node.children:
if child.type == "base_list":
for sub in child.children:
if sub.type in ("identifier", "generic_name"):
if sub.type == "generic_name":
name_child = sub.child_by_field_name("name")
base = _read_text(name_child, source) if name_child else _read_text(sub.children[0], source)
else:
base = _read_text(sub, source)
base_nid = _make_id(stem, base)
if base_nid not in seen_ids:
base_nid = _make_id(base)
if base_nid not in seen_ids:
nodes.append({
"id": base_nid,
"label": base,
"file_type": "code",
"source_file": "",
"source_location": "",
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
# Find body and recurse
body = _find_body(node, config)
if body:
@@ -1928,6 +1954,204 @@ def _resolve_cross_file_imports(
return new_edges
def extract_objc(path: Path) -> dict:
"""Extract interfaces, implementations, protocols, methods, and imports from .m/.mm/.h files."""
try:
import tree_sitter_objc as tsobjc
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_objc not installed"}
try:
language = Language(tsobjc.language())
parser = Parser(language)
source = path.read_bytes()
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = path.stem
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
method_bodies: list[tuple[str, Any]] = []
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(stem)
add_node(file_nid, path.name, 1)
def _read(node) -> str:
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
def _get_name(node, field: str) -> str | None:
n = node.child_by_field_name(field)
return _read(n) if n else None
def walk(node, parent_nid: str | None = None) -> None:
t = node.type
line = node.start_point[0] + 1
if t == "preproc_include":
# #import <Foundation/Foundation.h> or #import "MyClass.h"
for child in node.children:
if child.type == "system_lib_string":
raw = _read(child).strip("<>")
module = raw.split("/")[-1].replace(".h", "")
if module:
tgt_nid = _make_id(module)
add_edge(file_nid, tgt_nid, "imports", line)
elif child.type == "string_literal":
# recurse into string_literal to find string_content
for sub in child.children:
if sub.type == "string_content":
raw = _read(sub)
module = raw.split("/")[-1].replace(".h", "")
if module:
tgt_nid = _make_id(module)
add_edge(file_nid, tgt_nid, "imports", line)
return
if t == "class_interface":
# @interface ClassName : SuperClass <Protocols>
# children: @interface, identifier(name), ':', identifier(super), parameterized_arguments, ...
identifiers = [c for c in node.children if c.type == "identifier"]
if not identifiers:
for child in node.children:
walk(child, parent_nid)
return
name = _read(identifiers[0])
cls_nid = _make_id(stem, name)
add_node(cls_nid, name, line)
add_edge(file_nid, cls_nid, "contains", line)
# superclass is second identifier after ':'
colon_seen = False
for child in node.children:
if child.type == ":":
colon_seen = True
elif colon_seen and child.type == "identifier":
super_nid = _make_id(_read(child))
add_edge(cls_nid, super_nid, "inherits", line)
colon_seen = False
elif child.type == "parameterized_arguments":
# protocols adopted
for sub in child.children:
if sub.type == "type_name":
for s in sub.children:
if s.type == "type_identifier":
proto_nid = _make_id(_read(s))
add_edge(cls_nid, proto_nid, "imports", line)
elif child.type == "method_declaration":
walk(child, cls_nid)
return
if t == "class_implementation":
# @implementation ClassName
name = None
for child in node.children:
if child.type == "identifier":
name = _read(child)
break
if not name:
for child in node.children:
walk(child, parent_nid)
return
impl_nid = _make_id(stem, name)
if impl_nid not in seen_ids:
add_node(impl_nid, name, line)
add_edge(file_nid, impl_nid, "contains", line)
for child in node.children:
if child.type == "implementation_definition":
for sub in child.children:
walk(sub, impl_nid)
return
if t == "protocol_declaration":
name = None
for child in node.children:
if child.type == "identifier":
name = _read(child)
break
if name:
proto_nid = _make_id(stem, name)
add_node(proto_nid, f"<{name}>", line)
add_edge(file_nid, proto_nid, "contains", line)
for child in node.children:
walk(child, proto_nid)
return
if t in ("method_declaration", "method_definition"):
container = parent_nid or file_nid
# method name is the first identifier child (simple selector)
# for compound selectors: identifier + method_parameter pairs
parts = []
for child in node.children:
if child.type == "identifier":
parts.append(_read(child))
elif child.type == "method_parameter":
for sub in child.children:
if sub.type == "identifier":
# selector keyword before ':'
pass
method_name = "".join(parts) if parts else None
if method_name:
method_nid = _make_id(container, method_name)
add_node(method_nid, f"-{method_name}", line)
add_edge(container, method_nid, "method", line)
if t == "method_definition":
method_bodies.append((method_nid, node))
return
for child in node.children:
walk(child, parent_nid)
walk(root)
# Second pass: resolve calls inside method bodies
all_method_nids = {n["id"] for n in nodes if n["id"] != file_nid}
seen_calls: set[tuple[str, str]] = set()
for caller_nid, body_node in method_bodies:
def walk_calls(n) -> None:
if n.type == "message_expression":
# [receiver selector]
for child in n.children:
if child.type in ("selector", "keyword_argument_list"):
sel = []
if child.type == "selector":
sel.append(_read(child))
else:
for sub in child.children:
if sub.type == "keyword_argument":
for s in sub.children:
if s.type == "selector":
sel.append(_read(s))
method_name = "".join(sel)
for candidate in all_method_nids:
if candidate.endswith(_make_id("", method_name).lstrip("_")):
pair = (caller_nid, candidate)
if pair not in seen_calls and caller_nid != candidate:
seen_calls.add(pair)
add_edge(caller_nid, candidate, "calls", body_node.start_point[0] + 1,
confidence="INFERRED", weight=0.8)
for child in n.children:
walk_calls(child)
walk_calls(body_node)
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
def extract_elixir(path: Path) -> dict:
"""Extract modules, functions, imports, and calls from a .ex/.exs file."""
try:
@@ -2159,6 +2383,8 @@ def extract(paths: list[Path]) -> dict:
".ps1": extract_powershell,
".ex": extract_elixir,
".exs": extract_elixir,
".m": extract_objc,
".mm": extract_objc,
}
for path in paths:
@@ -2194,21 +2420,41 @@ def extract(paths: list[Path]) -> dict:
}
def collect_files(target: Path) -> list[Path]:
def collect_files(target: Path, *, follow_symlinks: bool = False) -> list[Path]:
if target.is_file():
return [target]
_EXTENSIONS = (
"*.py", "*.js", "*.ts", "*.tsx", "*.go", "*.rs",
"*.java", "*.c", "*.h", "*.cpp", "*.cc", "*.cxx", "*.hpp",
"*.rb", "*.cs", "*.kt", "*.kts", "*.scala", "*.php", "*.swift",
"*.lua", "*.toc", "*.zig", "*.ps1",
)
results: list[Path] = []
for pattern in _EXTENSIONS:
results.extend(
p for p in target.rglob(pattern)
if not any(part.startswith(".") for part in p.parts)
)
_EXTENSIONS = {
".py", ".js", ".ts", ".tsx", ".go", ".rs",
".java", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp",
".rb", ".cs", ".kt", ".kts", ".scala", ".php", ".swift",
".lua", ".toc", ".zig", ".ps1",
".m", ".mm",
}
if not follow_symlinks:
results: list[Path] = []
for ext in sorted(_EXTENSIONS):
results.extend(
p for p in target.rglob(f"*{ext}")
if not any(part.startswith(".") for part in p.parts)
)
return sorted(results)
# Walk with symlink following + cycle detection
results = []
for dirpath, dirnames, filenames in os.walk(target, followlinks=True):
if os.path.islink(dirpath):
real = os.path.realpath(dirpath)
parent_real = os.path.realpath(os.path.dirname(dirpath))
if parent_real == real or parent_real.startswith(real + os.sep):
dirnames.clear()
continue
dp = Path(dirpath)
if any(part.startswith(".") for part in dp.parts):
dirnames.clear()
continue
for fname in filenames:
p = dp / fname
if p.suffix in _EXTENSIONS and not fname.startswith("."):
results.append(p)
return sorted(results)
+9 -5
View File
@@ -421,6 +421,8 @@ Replace INPUT_PATH with the actual path.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, use that path as the vault directory. Otherwise default to `graphify-out/obsidian`.
```powershell
python -c "
import sys, json
@@ -437,13 +439,15 @@ communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
labels = {int(k): v for k, v in labels_raw.items()}
n = to_obsidian(G, communities, 'graphify-out/obsidian', community_labels=labels or None, cohesion=cohesion)
print(f'Obsidian vault: {n} notes in graphify-out/obsidian/')
obsidian_dir = 'OBSIDIAN_DIR' # replace with --obsidian-dir value, or 'graphify-out/obsidian' if not given
to_canvas(G, communities, 'graphify-out/obsidian/graph.canvas', community_labels=labels or None)
print('Canvas: graphify-out/obsidian/graph.canvas - open in Obsidian for structured community layout')
n = to_obsidian(G, communities, obsidian_dir, community_labels=labels or None, cohesion=cohesion)
print(f'Obsidian vault: {n} notes in {obsidian_dir}/')
to_canvas(G, communities, f'{obsidian_dir}/graph.canvas', community_labels=labels or None)
print(f'Canvas: {obsidian_dir}/graph.canvas - open in Obsidian for structured community layout')
print()
print('Open graphify-out/obsidian/ as a vault in Obsidian.')
print(f'Open {obsidian_dir}/ as a vault in Obsidian.')
print(' Graph view - nodes colored by community (set automatically)')
print(' graph.canvas - structured layout with communities as groups')
print(' _COMMUNITY_* - overview notes with cohesion scores and dataview queries')
+10 -5
View File
@@ -24,6 +24,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
@@ -428,6 +429,8 @@ Replace INPUT_PATH with the actual path.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, use that path as the vault directory. Otherwise default to `graphify-out/obsidian`.
```bash
$(cat .graphify_python) -c "
import sys, json
@@ -444,13 +447,15 @@ communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
labels = {int(k): v for k, v in labels_raw.items()}
n = to_obsidian(G, communities, 'graphify-out/obsidian', community_labels=labels or None, cohesion=cohesion)
print(f'Obsidian vault: {n} notes in graphify-out/obsidian/')
obsidian_dir = 'OBSIDIAN_DIR' # replace with --obsidian-dir value, or 'graphify-out/obsidian' if not given
to_canvas(G, communities, 'graphify-out/obsidian/graph.canvas', community_labels=labels or None)
print('Canvas: graphify-out/obsidian/graph.canvas - open in Obsidian for structured community layout')
n = to_obsidian(G, communities, obsidian_dir, community_labels=labels or None, cohesion=cohesion)
print(f'Obsidian vault: {n} notes in {obsidian_dir}/')
to_canvas(G, communities, f'{obsidian_dir}/graph.canvas', community_labels=labels or None)
print(f'Canvas: {obsidian_dir}/graph.canvas - open in Obsidian for structured community layout')
print()
print('Open graphify-out/obsidian/ as a vault in Obsidian.')
print(f'Open {obsidian_dir}/ as a vault in Obsidian.')
print(' Graph view - nodes colored by community (set automatically)')
print(' graph.canvas - structured layout with communities as groups')
print(' _COMMUNITY_* - overview notes with cohesion scores and dataview queries')
+1 -1
View File
@@ -1,7 +1,7 @@
# validate extraction JSON against the graphify schema before graph assembly
from __future__ import annotations
VALID_FILE_TYPES = {"code", "document", "paper", "image"}
VALID_FILE_TYPES = {"code", "document", "paper", "image", "rationale"}
VALID_CONFIDENCES = {"EXTRACTED", "INFERRED", "AMBIGUOUS"}
REQUIRED_NODE_FIELDS = {"id", "label", "file_type", "source_file"}
REQUIRED_EDGE_FIELDS = {"source", "target", "relation", "confidence", "source_file"}
+3 -6
View File
@@ -18,7 +18,7 @@ _CODE_EXTENSIONS = {
}
def _rebuild_code(watch_path: Path) -> bool:
def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
"""Re-run AST extraction + build + cluster + report for code files. No LLM needed.
Returns True on success, False on error.
@@ -31,13 +31,10 @@ def _rebuild_code(watch_path: Path) -> bool:
from graphify.report import generate
from graphify.export import to_json
code_files = []
for ext in _CODE_EXTENSIONS:
code_files.extend(watch_path.rglob(f"*{ext}"))
code_files = collect_files(watch_path, follow_symlinks=follow_symlinks)
code_files = [
f for f in code_files
if not any(part.startswith(".") for part in f.parts)
and "graphify-out" not in f.parts
if "graphify-out" not in f.parts
and "__pycache__" not in f.parts
]
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.3.6"
version = "0.3.9"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, OpenClaw) - turn any folder of code, docs, papers, or images into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
@@ -31,6 +31,7 @@ dependencies = [
"tree-sitter-zig",
"tree-sitter-powershell",
"tree-sitter-elixir",
"tree-sitter-objc",
]
[project.urls]
+42
View File
@@ -0,0 +1,42 @@
#import <Foundation/Foundation.h>
#import "SampleDelegate.h"
@interface Animal : NSObject <SampleDelegate>
@property (nonatomic, strong) NSString *name;
- (instancetype)initWithName:(NSString *)name;
- (void)speak;
@end
@implementation Animal
- (instancetype)initWithName:(NSString *)name {
self = [super init];
if (self) {
_name = name;
}
return self;
}
- (void)speak {
NSLog(@"%@ makes a sound.", self.name);
}
@end
@interface Dog : Animal
- (void)fetch;
@end
@implementation Dog
- (void)fetch {
[self speak];
NSLog(@"%@ fetches the ball!", self.name);
}
@end
+34
View File
@@ -103,3 +103,37 @@ def test_graphifyignore_comments_ignored(tmp_path):
result = detect(tmp_path)
assert not any("main.py" in f for f in result["files"]["code"])
assert any("other.py" in f for f in result["files"]["code"])
def test_detect_follows_symlinked_directory(tmp_path):
real_dir = tmp_path / "real_lib"
real_dir.mkdir()
(real_dir / "util.py").write_text("x = 1")
(tmp_path / "linked_lib").symlink_to(real_dir)
result_no = detect(tmp_path, follow_symlinks=False)
result_yes = detect(tmp_path, follow_symlinks=True)
assert any("real_lib" in f for f in result_no["files"]["code"])
assert not any("linked_lib" in f for f in result_no["files"]["code"])
assert any("linked_lib" in f for f in result_yes["files"]["code"])
def test_detect_follows_symlinked_file(tmp_path):
(tmp_path / "real.py").write_text("x = 1")
(tmp_path / "link.py").symlink_to(tmp_path / "real.py")
result = detect(tmp_path, follow_symlinks=True)
code = result["files"]["code"]
assert any("real.py" in f for f in code)
assert any("link.py" in f for f in code)
def test_detect_handles_circular_symlinks(tmp_path):
sub = tmp_path / "a"
sub.mkdir()
(sub / "main.py").write_text("x = 1")
(sub / "loop").symlink_to(tmp_path)
result = detect(tmp_path, follow_symlinks=True)
assert any("main.py" in f for f in result["files"]["code"])
+25 -1
View File
@@ -61,7 +61,8 @@ def test_collect_files_from_dir():
supported = {".py", ".js", ".ts", ".tsx", ".go", ".rs",
".java", ".c", ".cpp", ".cc", ".cxx", ".rb",
".cs", ".kt", ".kts", ".scala", ".php", ".h", ".hpp",
".swift", ".lua", ".toc", ".zig", ".ps1", ".ex", ".exs"}
".swift", ".lua", ".toc", ".zig", ".ps1", ".ex", ".exs",
".m", ".mm"}
assert all(f.suffix in supported for f in files)
assert len(files) > 0
@@ -72,6 +73,29 @@ def test_collect_files_skips_hidden():
assert not any(part.startswith(".") for part in f.parts)
def test_collect_files_follows_symlinked_directory(tmp_path):
real_dir = tmp_path / "real_src"
real_dir.mkdir()
(real_dir / "lib.py").write_text("x = 1")
(tmp_path / "linked_src").symlink_to(real_dir)
files_no = collect_files(tmp_path, follow_symlinks=False)
files_yes = collect_files(tmp_path, follow_symlinks=True)
assert [f.name for f in files_no].count("lib.py") == 1
assert [f.name for f in files_yes].count("lib.py") == 2
def test_collect_files_handles_circular_symlinks(tmp_path):
sub = tmp_path / "pkg"
sub.mkdir()
(sub / "mod.py").write_text("x = 1")
(sub / "cycle").symlink_to(tmp_path)
files = collect_files(tmp_path, follow_symlinks=True)
assert any(f.name == "mod.py" for f in files)
def test_no_dangling_edges_on_extract():
"""After merging multiple files, no internal edges should be dangling."""
files = list(FIXTURES.glob("*.py"))
+56
View File
@@ -148,6 +148,21 @@ def test_csharp_finds_usings():
r = extract_csharp(FIXTURES / "sample.cs")
assert "imports" in _relations(r)
def test_csharp_inherits_edge():
r = extract_csharp(FIXTURES / "sample.cs")
inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
assert len(inherits) >= 1
def test_csharp_inherits_iprocessor():
r = extract_csharp(FIXTURES / "sample.cs")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
found = any(
"DataProcessor" in node_by_id.get(e["source"], "") and
"IProcessor" in node_by_id.get(e["target"], "")
for e in r["edges"] if e["relation"] == "inherits"
)
assert found, "DataProcessor should have inherits edge to IProcessor"
# ── Kotlin ───────────────────────────────────────────────────────────────────
@@ -371,3 +386,44 @@ def test_elixir_method_edges():
r = extract_elixir(FIXTURES / "sample.ex")
methods = [e for e in r["edges"] if e["relation"] == "method"]
assert len(methods) >= 3
# ── Objective-C ──────────────────────────────────────────────────────────────
from graphify.extract import extract_objc
def test_objc_finds_interface():
r = extract_objc(FIXTURES / "sample.m")
labels = [n["label"] for n in r["nodes"]]
assert "Animal" in labels
def test_objc_finds_subclass():
r = extract_objc(FIXTURES / "sample.m")
labels = [n["label"] for n in r["nodes"]]
assert "Dog" in labels
def test_objc_finds_methods():
r = extract_objc(FIXTURES / "sample.m")
labels = [n["label"] for n in r["nodes"]]
assert any("speak" in l or "fetch" in l or "initWithName" in l for l in labels)
def test_objc_finds_imports():
r = extract_objc(FIXTURES / "sample.m")
import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
assert len(import_edges) >= 1
def test_objc_inherits_edge():
r = extract_objc(FIXTURES / "sample.m")
inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
assert len(inherits) >= 1
def test_objc_no_dangling_edges():
r = extract_objc(FIXTURES / "sample.m")
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids, f"Dangling source: {e}"