From a7fc2493f0b43d70629227aabec4d508ddf48c68 Mon Sep 17 00:00:00 2001 From: Safi Date: Mon, 6 Apr 2026 00:58:55 +0100 Subject: [PATCH] v2: confidence scores, hyperedges, rationale extraction, git hooks, Claude Code hooks - confidence_score required on every edge (INFERRED: 0.4-0.9, EXTRACTED: 1.0, AMBIGUOUS: 0.1-0.3) - semantically_similar_to edges for non-obvious cross-file conceptual links - hyperedges for 3+ node group relationships - fixed cache and merge pipeline that was silently dropping them - check_semantic_cache returns 4-tuple including cached_hyperedges - extract.py: mine the "why" - module/class/function docstrings and rationale comments (# NOTE: # IMPORTANT: # HACK: # WHY: # RATIONALE: # TODO: # FIXME:) as rationale_for nodes - skill.md: rationale_for in relation schema, doc files extract design rationale - obsidian output opt-in (--obsidian flag) - default output is graph.html + graph.json + GRAPH_REPORT.md only - hooks.py: post-checkout hook added alongside post-commit - graph rebuilds on branch switch - claude install: writes .claude/settings.json PreToolUse hook on Glob/Grep - Claude checks graph before searching raw files - README updated with all v2 features --- README.md | 26 ++++++--- graphify/__main__.py | 63 +++++++++++++++++++++ graphify/cache.py | 15 +++-- graphify/extract.py | 86 +++++++++++++++++++++++++++++ graphify/hooks.py | 127 +++++++++++++++++++++++++++++-------------- graphify/skill.md | 37 +++++++------ tests/test_hooks.py | 2 +- 7 files changed, 286 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 1aebd337..9bf17cd0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v1)](https://github.com/safishamsi/graphify/actions/workflows/ci.yml) -**A Claude Code skill.** Type `/graphify` in Claude Code - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. +**A Claude Code skill.** Type `/graphify` in Claude Code - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions. Fully multimodal. Drop in code, PDFs, markdown, screenshots, diagrams, whiteboard photos, even images in other languages - graphify uses Claude vision to extract concepts and relationships from all of it and connects them into one graph. @@ -15,8 +15,6 @@ Fully multimodal. Drop in code, PDFs, markdown, screenshots, diagrams, whiteboar ``` graphify-out/ ├── graph.html interactive graph - click nodes, search, filter by community -├── obsidian/ open as Obsidian vault -├── wiki/ Wikipedia-style articles for agent navigation (--wiki) ├── GRAPH_REPORT.md god nodes, surprising connections, suggested questions ├── graph.json persistent graph - query weeks later without re-reading └── cache/ SHA256 cache - re-runs only process changed files @@ -63,6 +61,7 @@ When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` /graphify ./raw # run on a specific folder /graphify ./raw --mode deep # more aggressive INFERRED edge extraction /graphify ./raw --update # re-extract only changed files, merge into existing graph +/graphify ./raw --obsidian # also generate Obsidian vault (opt-in) /graphify add https://arxiv.org/abs/1706.03762 # fetch a paper, save, update graph /graphify add https://x.com/karpathy/status/... # fetch a tweet @@ -78,15 +77,16 @@ When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` /graphify ./raw --neo4j # generate cypher.txt for Neo4j /graphify ./raw --mcp # start MCP stdio server -graphify hook install # post-commit git hook - rebuilds graph on every commit automatically +graphify hook install # git hooks - rebuilds graph on commit and branch switch +graphify claude install # write graphify rules to local CLAUDE.md + install PreToolUse hook ``` Works with any mix of file types: | Type | Extensions | Extraction | |------|-----------|------------| -| Code | `.py .ts .js .go .rs .java .c .cpp .rb .cs .kt .scala .php` | AST via tree-sitter + call-graph pass | -| Docs | `.md .txt .rst` | Concepts + relationships via Claude | +| Code | `.py .ts .js .go .rs .java .c .cpp .rb .cs .kt .scala .php` | AST via tree-sitter + call-graph pass + docstring/comment rationale | +| Docs | `.md .txt .rst` | Concepts + relationships + design rationale via Claude | | Papers | `.pdf` | Citation mining + concept extraction | | Images | `.png .jpg .webp .gif` | Claude vision - screenshots, diagrams, any language | @@ -98,11 +98,21 @@ Works with any mix of file types: **Suggested questions** - 4-5 questions the graph is uniquely positioned to answer +**The "why"** - docstrings, inline comments (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`), and design rationale from docs are extracted as `rationale_for` nodes. Not just what the code does - why it was written that way. + +**Confidence scores** - every INFERRED edge has a `confidence_score` (0.0-1.0). You know not just what was guessed but how confident the model was. EXTRACTED edges are always 1.0. + +**Semantic similarity edges** - cross-file conceptual links that have no structural connection. Two functions solving the same problem without calling each other, a class in code and a concept in a paper describing the same algorithm. + +**Hyperedges** - group relationships connecting 3+ nodes that pairwise edges can't express. All classes implementing a shared protocol, all functions in an auth flow, all concepts from a paper section forming one idea. + **Token benchmark** - printed automatically after every run. On a mixed corpus (Karpathy repos + papers + images): **71.5x** fewer tokens per query vs reading raw files. -**Auto-sync** (`--watch`) - run in a background terminal and the graph updates itself as your codebase changes. Code file saves trigger an instant rebuild (AST only, no LLM). Doc/image changes notify you to run `--update` for the LLM re-pass. Useful for agentic workflows where multiple agents are writing code in parallel - the graph stays current between waves automatically. +**Auto-sync** (`--watch`) - run in a background terminal and the graph updates itself as your codebase changes. Code file saves trigger an instant rebuild (AST only, no LLM). Doc/image changes notify you to run `--update` for the LLM re-pass. -**Git commit hook** (`graphify hook install`) - installs a post-commit hook that rebuilds the graph after every commit. No background process needed. Triggers once per commit, works with any editor, safe to add alongside existing hooks. +**Git hooks** (`graphify hook install`) - installs post-commit and post-checkout hooks. Graph rebuilds automatically after every commit and every branch switch. No background process needed. + +**Always-on for Claude** (`graphify claude install`) - writes a `CLAUDE.md` section so Claude checks the graph before answering architecture questions, plus a `.claude/settings.json` PreToolUse hook that fires before every Glob/Grep - Claude is reminded to check the graph before searching raw files. **Wiki** (`--wiki`) - Wikipedia-style markdown articles per community and god node, with an `index.md` entry point. Point any agent at `index.md` and it can navigate the knowledge base by reading files instead of parsing JSON. diff --git a/graphify/__main__.py b/graphify/__main__.py index 8d9b8561..ba1dfeec 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -6,6 +6,20 @@ import shutil import sys from pathlib import Path +_SETTINGS_HOOK = { + "matcher": "Glob|Grep", + "hooks": [ + { + "type": "command", + "command": ( + "[ -f graphify-out/graph.json ] && " + "echo 'graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md " + "for god nodes and community structure before searching raw files.' || true" + ), + } + ], +} + _SKILL_REGISTRATION = ( "\n# graphify\n" "- **graphify** (`~/.claude/skills/graphify/SKILL.md`) " @@ -82,11 +96,59 @@ def claude_install(project_dir: Path | None = None) -> None: target.write_text(new_content) print(f"graphify section written to {target.resolve()}") + + # Also write Claude Code PreToolUse hook to .claude/settings.json + _install_claude_hook(project_dir or Path(".")) + print() print("Claude Code will now check the knowledge graph before answering") print("codebase questions and rebuild it after code changes.") +def _install_claude_hook(project_dir: Path) -> None: + """Add graphify PreToolUse hook to .claude/settings.json.""" + settings_path = project_dir / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text()) + except json.JSONDecodeError: + settings = {} + else: + settings = {} + + hooks = settings.setdefault("hooks", {}) + pre_tool = hooks.setdefault("PreToolUse", []) + + # Check if already installed + if any(h.get("matcher") == "Glob|Grep" and "graphify" in str(h) for h in pre_tool): + print(f" .claude/settings.json → hook already registered (no change)") + return + + pre_tool.append(_SETTINGS_HOOK) + settings_path.write_text(json.dumps(settings, indent=2)) + print(f" .claude/settings.json → PreToolUse hook registered") + + +def _uninstall_claude_hook(project_dir: Path) -> None: + """Remove graphify PreToolUse hook from .claude/settings.json.""" + settings_path = project_dir / ".claude" / "settings.json" + if not settings_path.exists(): + return + try: + settings = json.loads(settings_path.read_text()) + except json.JSONDecodeError: + return + pre_tool = settings.get("hooks", {}).get("PreToolUse", []) + filtered = [h for h in pre_tool if not (h.get("matcher") == "Glob|Grep" and "graphify" in str(h))] + if len(filtered) == len(pre_tool): + return + settings["hooks"]["PreToolUse"] = filtered + settings_path.write_text(json.dumps(settings, indent=2)) + print(f" .claude/settings.json → PreToolUse hook removed") + + def claude_uninstall(project_dir: Path | None = None) -> None: """Remove the graphify section from the local CLAUDE.md.""" target = (project_dir or Path(".")) / "CLAUDE.md" @@ -115,6 +177,7 @@ def claude_uninstall(project_dir: Path | None = None) -> None: return print(f"graphify section removed from {target.resolve()}") + _uninstall_claude_hook(project_dir or Path(".")) def main() -> None: diff --git a/graphify/cache.py b/graphify/cache.py index 0bde3bb7..e983c0c3 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -65,14 +65,15 @@ def clear_cache(root: Path = Path(".")) -> None: def check_semantic_cache( files: list[str], root: Path = Path("."), -) -> tuple[list[dict], list[dict], list[str]]: +) -> tuple[list[dict], list[dict], list[dict], list[str]]: """Check semantic extraction cache for a list of absolute file paths. - Returns (cached_nodes, cached_edges, uncached_files). + Returns (cached_nodes, cached_edges, cached_hyperedges, uncached_files). Uncached files need Claude extraction; cached files are merged directly. """ cached_nodes: list[dict] = [] cached_edges: list[dict] = [] + cached_hyperedges: list[dict] = [] uncached: list[str] = [] for fpath in files: @@ -80,15 +81,17 @@ def check_semantic_cache( if result is not None: cached_nodes.extend(result.get("nodes", [])) cached_edges.extend(result.get("edges", [])) + cached_hyperedges.extend(result.get("hyperedges", [])) else: uncached.append(fpath) - return cached_nodes, cached_edges, uncached + return cached_nodes, cached_edges, cached_hyperedges, uncached def save_semantic_cache( nodes: list[dict], edges: list[dict], + hyperedges: list[dict] | None = None, root: Path = Path("."), ) -> int: """Save semantic extraction results to cache, keyed by source_file. @@ -98,7 +101,7 @@ def save_semantic_cache( """ from collections import defaultdict - by_file: dict[str, dict] = defaultdict(lambda: {"nodes": [], "edges": []}) + by_file: dict[str, dict] = defaultdict(lambda: {"nodes": [], "edges": [], "hyperedges": []}) for n in nodes: src = n.get("source_file", "") if src: @@ -107,6 +110,10 @@ def save_semantic_cache( src = e.get("source_file", "") if src: by_file[src]["edges"].append(e) + for h in (hyperedges or []): + src = h.get("source_file", "") + if src: + by_file[src]["hyperedges"].append(h) saved = 0 for fpath, result in by_file.items(): diff --git a/graphify/extract.py b/graphify/extract.py index c5ceddc0..54f4945f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -149,6 +149,92 @@ def extract_python(path: Path) -> dict: function_bodies: list[tuple[str, object]] = [] walk(root) + # ── Docstring + rationale comment extraction ────────────────────────────── + # Extract module/class/function docstrings and inline rationale comments. + # These become rationale nodes connected to their parent entity via rationale_for. + + _RATIONALE_PREFIXES = ("# NOTE:", "# IMPORTANT:", "# HACK:", "# WHY:", "# RATIONALE:", "# TODO:", "# FIXME:") + + def _get_docstring(body_node) -> tuple[str, int] | None: + """Return (text, line) of the first string literal in a body node, or None.""" + if not body_node: + return None + for child in body_node.children: + if child.type == "expression_statement": + for sub in child.children: + if sub.type in ("string", "concatenated_string"): + text = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace") + text = text.strip("\"'").strip('"""').strip("'''").strip() + if len(text) > 20: + return text, child.start_point[0] + 1 + break # docstring must be the first statement + return None + + def _add_rationale(text: str, line: int, parent_nid: str) -> None: + label = text[:80].replace("\n", " ").strip() + rid = _make_id(stem, "rationale", str(line)) + if rid not in seen_ids: + seen_ids.add(rid) + nodes.append({ + "id": rid, + "label": label, + "file_type": "rationale", + "source_file": str_path, + "source_location": f"L{line}", + }) + edges.append({ + "source": rid, + "target": parent_nid, + "relation": "rationale_for", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + # Module-level docstring + module_body = root # module itself acts as the body + ds = _get_docstring(module_body) + if ds: + _add_rationale(ds[0], ds[1], file_nid) + + # Class and function docstrings (re-walk tree for body nodes) + def walk_docstrings(node, parent_nid: str) -> None: + t = node.type + if t == "class_definition": + name_node = node.child_by_field_name("name") + body = node.child_by_field_name("body") + if name_node and body: + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + nid = _make_id(stem, class_name) + ds = _get_docstring(body) + if ds: + _add_rationale(ds[0], ds[1], nid) + for child in body.children: + walk_docstrings(child, nid) + return + if t == "function_definition": + name_node = node.child_by_field_name("name") + body = node.child_by_field_name("body") + if name_node and body: + func_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + nid = _make_id(parent_nid, func_name) if parent_nid != file_nid else _make_id(stem, func_name) + ds = _get_docstring(body) + if ds: + _add_rationale(ds[0], ds[1], nid) + return + for child in node.children: + walk_docstrings(child, parent_nid) + + walk_docstrings(root, file_nid) + + # Rationale comments (# NOTE:, # IMPORTANT:, etc.) + source_text = source.decode("utf-8", errors="replace") + for lineno, line_text in enumerate(source_text.splitlines(), start=1): + stripped = line_text.strip() + if any(stripped.startswith(p) for p in _RATIONALE_PREFIXES): + _add_rationale(stripped, lineno, file_nid) + # ── Call-graph pass ─────────────────────────────────────────────────────── # Build label→nid lookup from all nodes collected above. # Normalise: strip "()" suffix and leading "." so "cohesion_score()" and diff --git a/graphify/hooks.py b/graphify/hooks.py index ae01a23f..93b839a8 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -1,8 +1,9 @@ -# git hook integration - install/uninstall graphify post-commit hook +# git hook integration - install/uninstall graphify post-commit and post-checkout hooks from __future__ import annotations from pathlib import Path _HOOK_MARKER = "# graphify-hook" +_CHECKOUT_MARKER = "# graphify-checkout-hook" _HOOK_SCRIPT = """\ #!/bin/bash @@ -44,6 +45,38 @@ except Exception as exc: """ +_CHECKOUT_SCRIPT = """\ +#!/bin/bash +# graphify-checkout-hook +# Auto-rebuilds the knowledge graph (code only) when switching branches. +# Installed by: graphify hook install + +PREV_HEAD=$1 +NEW_HEAD=$2 +BRANCH_SWITCH=$3 + +# Only run on branch switches, not file checkouts +if [ "$BRANCH_SWITCH" != "1" ]; then + exit 0 +fi + +# Only run if graphify-out/ exists (graph has been built before) +if [ ! -d "graphify-out" ]; then + exit 0 +fi + +echo "[graphify] Branch switched - rebuilding knowledge graph (code files)..." +python3 -c " +from graphify.watch import _rebuild_code +from pathlib import Path +try: + _rebuild_code(Path('.')) +except Exception as exc: + print(f'[graphify] Rebuild failed: {exc}') +" +""" + + def _git_root(path: Path) -> Path | None: """Walk up to find .git directory.""" current = path.resolve() @@ -53,66 +86,78 @@ def _git_root(path: Path) -> Path | None: return None -def install(path: Path = Path(".")) -> str: - """Install graphify post-commit hook in the nearest git repo. +def _install_hook(hooks_dir: Path, name: str, script: str, marker: str) -> str: + """Install a single git hook, appending if an existing hook is present.""" + hook_path = hooks_dir / name + if hook_path.exists(): + content = hook_path.read_text() + if marker in content: + return f"already installed at {hook_path}" + hook_path.write_text(content.rstrip() + "\n\n" + script) + return f"appended to existing {name} hook at {hook_path}" + hook_path.write_text(script) + hook_path.chmod(0o755) + return f"installed at {hook_path}" - Returns a message describing what was done. - """ + +def _uninstall_hook(hooks_dir: Path, name: str, marker: str) -> str: + """Remove graphify section from a git hook.""" + hook_path = hooks_dir / name + if not hook_path.exists(): + return f"no {name} hook found - nothing to remove." + content = hook_path.read_text() + if marker not in content: + return f"graphify hook not found in {name} - nothing to remove." + before = content.split(marker)[0].rstrip() + non_empty = [l for l in before.splitlines() if l.strip() and not l.startswith("#!")] + if not non_empty: + hook_path.unlink() + return f"removed {name} hook at {hook_path}" + hook_path.write_text(before + "\n") + return f"graphify removed from {name} at {hook_path} (other hook content preserved)" + + +def install(path: Path = Path(".")) -> str: + """Install graphify post-commit and post-checkout hooks in the nearest git repo.""" root = _git_root(path) if root is None: raise RuntimeError(f"No git repository found at or above {path.resolve()}") hooks_dir = root / ".git" / "hooks" hooks_dir.mkdir(exist_ok=True) - hook_path = hooks_dir / "post-commit" - if hook_path.exists(): - content = hook_path.read_text() - if _HOOK_MARKER in content: - return f"graphify hook already installed at {hook_path}" - # Append to existing hook - hook_path.write_text(content.rstrip() + "\n\n" + _HOOK_SCRIPT) - return f"graphify hook appended to existing post-commit hook at {hook_path}" + commit_msg = _install_hook(hooks_dir, "post-commit", _HOOK_SCRIPT, _HOOK_MARKER) + checkout_msg = _install_hook(hooks_dir, "post-checkout", _CHECKOUT_SCRIPT, _CHECKOUT_MARKER) - hook_path.write_text(_HOOK_SCRIPT) - hook_path.chmod(0o755) - return f"graphify hook installed at {hook_path}" + return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}" def uninstall(path: Path = Path(".")) -> str: - """Remove graphify post-commit hook.""" + """Remove graphify post-commit and post-checkout hooks.""" root = _git_root(path) if root is None: raise RuntimeError(f"No git repository found at or above {path.resolve()}") - hook_path = root / ".git" / "hooks" / "post-commit" - if not hook_path.exists(): - return "No post-commit hook found - nothing to remove." + hooks_dir = root / ".git" / "hooks" + commit_msg = _uninstall_hook(hooks_dir, "post-commit", _HOOK_MARKER) + checkout_msg = _uninstall_hook(hooks_dir, "post-checkout", _CHECKOUT_MARKER) - content = hook_path.read_text() - if _HOOK_MARKER not in content: - return "graphify hook not found in post-commit - nothing to remove." - - # Strip everything from our marker onwards - before = content.split(_HOOK_MARKER)[0].rstrip() - # 'before' is empty or just a shebang line if the whole file was ours - non_empty = [l for l in before.splitlines() if l.strip() and not l.startswith("#!")] - if not non_empty: - hook_path.unlink() - return f"Removed post-commit hook at {hook_path}" - else: - hook_path.write_text(before + "\n") - return f"graphify hook removed from {hook_path} (other hook content preserved)" + return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}" def status(path: Path = Path(".")) -> str: - """Check if graphify hook is installed.""" + """Check if graphify hooks are installed.""" root = _git_root(path) if root is None: return "Not in a git repository." - hook_path = root / ".git" / "hooks" / "post-commit" - if not hook_path.exists(): - return "graphify hook: not installed" - if _HOOK_MARKER in hook_path.read_text(): - return f"graphify hook: installed at {hook_path}" - return "graphify hook: not installed (post-commit exists but graphify hook not found)" + hooks_dir = root / ".git" / "hooks" + + def _check(name: str, marker: str) -> str: + p = hooks_dir / name + if not p.exists(): + return "not installed" + return "installed" if marker in p.read_text() else "not installed (hook exists but graphify not found)" + + commit = _check("post-commit", _HOOK_MARKER) + checkout = _check("post-checkout", _CHECKOUT_MARKER) + return f"post-commit: {commit}\npost-checkout: {checkout}" diff --git a/graphify/skill.md b/graphify/skill.md index 444f080f..60e5ee99 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -152,10 +152,10 @@ from pathlib import Path detect = json.loads(Path('.graphify_detect.json').read_text()) all_files = [f for files in detect['files'].values() for f in files] -cached_nodes, cached_edges, uncached = check_semantic_cache(all_files) +cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files) -if cached_nodes or cached_edges: - Path('.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges})) +if cached_nodes or cached_edges or cached_hyperedges: + Path('.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges})) Path('.graphify_uncached.txt').write_text('\n'.join(uncached)) print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction') " @@ -195,7 +195,7 @@ Rules: Code files: focus on semantic edges AST cannot find (call relationships, shared data, arch patterns). Do not re-extract imports - AST already has those. -Doc/paper files: extract named concepts, entities, citations. +Doc/paper files: extract named concepts, entities, citations. Also extract rationale — sections that explain WHY a decision was made, trade-offs chosen, or design intent. These become nodes with `rationale_for` edges pointing to the concept they explain. Image files: use vision to understand what the image IS - do not just OCR. UI screenshot: layout patterns, design decisions, key elements, purpose. Chart: metric, trend/insight, data source. @@ -222,15 +222,16 @@ Use sparingly — only when the group relationship adds information beyond the p If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, contributor onto every node from that file. -confidence_score rules: -- EXTRACTED edges: confidence_score must be 1.0 -- INFERRED edges: score 0.4-0.9 based on how certain you are. - Strong structural inference (e.g. two classes clearly share data): 0.8-0.9. - Reasonable but not certain: 0.6-0.7. Weak inference: 0.4-0.5. -- AMBIGUOUS edges: score 0.1-0.3 +confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a default: +- EXTRACTED edges: confidence_score = 1.0 always +- INFERRED edges: reason about each edge individually. + Direct structural evidence (shared data structure, clear dependency): 0.8-0.9. + Reasonable inference with some uncertainty: 0.6-0.7. + Weak or speculative: 0.4-0.5. Most edges should be 0.6-0.9, not 0.5. +- AMBIGUOUS edges: 0.1-0.3 Output exactly this JSON (no other text): -{"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0} +{"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0} ``` **Step B3 - Collect, cache, and merge** @@ -248,8 +249,8 @@ import json from graphify.cache import save_semantic_cache from pathlib import Path -new = json.loads(Path('.graphify_semantic_new.json').read_text()) if Path('.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[]} -saved = save_semantic_cache(new.get('nodes', []), new.get('edges', [])) +new = json.loads(Path('.graphify_semantic_new.json').read_text()) if Path('.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]} +saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', [])) print(f'Cached {saved} files') " ``` @@ -260,11 +261,12 @@ python3 -c " import json from pathlib import Path -cached = json.loads(Path('.graphify_cached.json').read_text()) if Path('.graphify_cached.json').exists() else {'nodes':[],'edges':[]} -new = json.loads(Path('.graphify_semantic_new.json').read_text()) if Path('.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[]} +cached = json.loads(Path('.graphify_cached.json').read_text()) if Path('.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]} +new = json.loads(Path('.graphify_semantic_new.json').read_text()) if Path('.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]} all_nodes = cached['nodes'] + new.get('nodes', []) all_edges = cached['edges'] + new.get('edges', []) +all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', []) seen = set() deduped = [] for n in all_nodes: @@ -275,6 +277,7 @@ for n in all_nodes: merged = { 'nodes': deduped, 'edges': all_edges, + 'hyperedges': all_hyperedges, 'input_tokens': new.get('input_tokens', 0), 'output_tokens': new.get('output_tokens', 0), } @@ -303,9 +306,11 @@ for n in sem['nodes']: seen.add(n['id']) merged_edges = ast['edges'] + sem['edges'] +merged_hyperedges = sem.get('hyperedges', []) merged = { 'nodes': merged_nodes, 'edges': merged_edges, + 'hyperedges': merged_hyperedges, 'input_tokens': sem.get('input_tokens', 0), 'output_tokens': sem.get('output_tokens', 0), } @@ -408,7 +413,7 @@ Replace INPUT_PATH with the actual path. ### Step 6 - Generate Obsidian vault (default) + optional HTML -**Always generate the Obsidian vault and HTML** - they are the primary visualizations. Skip both if `--no-viz` (report + JSON only). +**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was given** — it generates one file per node which creates thousands of files in large repos. Skip it by default. ```bash python3 -c " diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 088c188f..07b5c0a1 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -53,7 +53,7 @@ def test_uninstall_removes_hook(tmp_path): result = uninstall(repo) hook = repo / ".git" / "hooks" / "post-commit" assert not hook.exists() - assert "Removed" in result + assert "removed" in result.lower() def test_uninstall_no_hook(tmp_path):