diff --git a/CHANGELOG.md b/CHANGELOG.md index 826e63ce..b16f5895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ""` CLI command — BFS/DFS traversal of `graph.json` without needing Claude Code skill (`--dfs`, `--budget N`, `--graph ` 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 ` 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`) diff --git a/README.md b/README.md index 5e18bdf9..ab511f80 100644 --- a/README.md +++ b/README.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 | diff --git a/graphify/__main__.py b/graphify/__main__.py index 1596502f..7db43ffb 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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 \"\" 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 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 \"\" [--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" diff --git a/graphify/cache.py b/graphify/cache.py index 0aa3125c..f198e416 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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 diff --git a/graphify/detect.py b/graphify/detect.py index 96b1d57a..5306d92e 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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[:] = [ diff --git a/graphify/extract.py b/graphify/extract.py index 217caec5..c2d858d9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 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 + # 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) diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index dc77d86e..6675a769 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -421,6 +421,8 @@ Replace INPUT_PATH with the actual path. If `--obsidian` was given: +- If `--obsidian-dir ` 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') diff --git a/graphify/skill.md b/graphify/skill.md index f4d2145f..2c5e5958 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -24,6 +24,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti /graphify --neo4j-push bolt://localhost:7687 # push directly to Neo4j /graphify --mcp # start MCP stdio server for agent access /graphify --watch # watch folder, auto-rebuild on code changes (no LLM needed) +/graphify --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault) /graphify add # fetch URL, save to ./raw, update graph /graphify add --author "Name" # tag who wrote it /graphify add --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 ` 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') diff --git a/graphify/validate.py b/graphify/validate.py index 39434091..2c372777 100644 --- a/graphify/validate.py +++ b/graphify/validate.py @@ -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"} diff --git a/graphify/watch.py b/graphify/watch.py index 1ba30db1..c0cabdde 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 ] diff --git a/pyproject.toml b/pyproject.toml index 4ae7283b..b00ebe15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/fixtures/sample.m b/tests/fixtures/sample.m new file mode 100644 index 00000000..2f1209a4 --- /dev/null +++ b/tests/fixtures/sample.m @@ -0,0 +1,42 @@ +#import +#import "SampleDelegate.h" + +@interface Animal : NSObject + +@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 diff --git a/tests/test_detect.py b/tests/test_detect.py index 2c416e0e..aabe8a67 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -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"]) diff --git a/tests/test_extract.py b/tests/test_extract.py index 930e4080..a852db98 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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")) diff --git a/tests/test_languages.py b/tests/test_languages.py index d4598533..13910070 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.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}"