mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 02:47:00 +00:00
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:
@@ -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`)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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]
|
||||
|
||||
Vendored
+42
@@ -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
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user