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
This commit is contained in:
Safi
2026-04-06 00:58:55 +01:00
parent 494f519bf4
commit a7fc2493f0
7 changed files with 286 additions and 70 deletions
+18 -8
View File
@@ -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.
+63
View File
@@ -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:
+11 -4
View File
@@ -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():
+86
View File
@@ -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
+86 -41
View File
@@ -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}"
+21 -16
View File
@@ -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 "
+1 -1
View File
@@ -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):