mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 05:25:40 +00:00
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:
@@ -2,7 +2,7 @@
|
||||
|
||||
[](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.
|
||||
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user