mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-08 14:45:46 +00:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
69001d0c6b
commit
2c5d3a50bd
@@ -56,6 +56,8 @@ pip install graphifyy && graphify install
|
||||
|
||||
> **Official package:** The PyPI package is named `graphifyy` (install with `pip install graphifyy`). Other packages named `graphify*` on PyPI are not affiliated with this project. The only official repository is [safishamsi/graphify](https://github.com/safishamsi/graphify). The CLI and skill command are still `graphify`.
|
||||
|
||||
> **`graphify: command not found`?** On Windows, pip user scripts land in `%APPDATA%\Python\PythonXY\Scripts` — add that to your PATH or use `python -m graphify` instead. On macOS with pipx, run `pipx ensurepath` then restart your terminal.
|
||||
|
||||
### Platform support
|
||||
|
||||
| Platform | Install command |
|
||||
@@ -139,6 +141,24 @@ The always-on hook surfaces `GRAPH_REPORT.md` — a one-page summary of god node
|
||||
|
||||
Think of it this way: the always-on hook gives your assistant a map. The `/graphify` commands let it navigate the map precisely.
|
||||
|
||||
### Team workflows
|
||||
|
||||
`graphify-out/` is designed to be committed to git so every teammate starts with a fresh map.
|
||||
|
||||
**Recommended `.gitignore` additions:**
|
||||
```
|
||||
# commit graph outputs, ignore the extraction cache
|
||||
graphify-out/cache/
|
||||
```
|
||||
|
||||
**Shared setup:**
|
||||
1. One person runs `/graphify .` to build the initial graph and commits `graphify-out/`.
|
||||
2. Everyone else pulls — their assistant reads `GRAPH_REPORT.md` immediately with no extra steps.
|
||||
3. Install the post-commit hook (`graphify hook install`) so the graph rebuilds automatically after code changes — no LLM calls needed for code-only updates.
|
||||
4. For doc/paper changes, whoever edits the files runs `/graphify --update` to refresh semantic nodes.
|
||||
|
||||
**Excluding paths** — create `.graphifyignore` in your project root (same syntax as `.gitignore`). Files matching those patterns are skipped during detection and extraction.
|
||||
|
||||
## Using `graph.json` with an LLM
|
||||
|
||||
`graph.json` is not meant to be pasted into a prompt all at once. The useful
|
||||
@@ -288,7 +308,7 @@ Works with any mix of file types:
|
||||
|
||||
| Type | Extensions | Extraction |
|
||||
|------|-----------|------------|
|
||||
| Code | `.py .ts .js .jsx .tsx .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .mm .jl .vue .svelte` | AST via tree-sitter + call-graph (cross-file for all languages) + docstring/comment rationale |
|
||||
| Code | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .mm .jl .vue .svelte` | AST via tree-sitter + call-graph (cross-file for all languages) + 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 |
|
||||
|
||||
@@ -21,11 +21,22 @@
|
||||
# before any graph construction happens.
|
||||
#
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import sys
|
||||
import networkx as nx
|
||||
from .validate import validate_extraction
|
||||
|
||||
|
||||
def _normalize_id(s: str) -> str:
|
||||
"""Normalize an ID string the same way extract._make_id does.
|
||||
|
||||
Used to reconcile edge endpoints when the LLM generates IDs with slightly
|
||||
different punctuation or casing than the AST extractor.
|
||||
"""
|
||||
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", s)
|
||||
return cleaned.strip("_").lower()
|
||||
|
||||
|
||||
def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
|
||||
"""Build a NetworkX graph from an extraction dict.
|
||||
|
||||
@@ -44,6 +55,10 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
|
||||
for node in extraction.get("nodes", []):
|
||||
G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
|
||||
node_set = set(G.nodes())
|
||||
# Normalized ID map: lets edges survive when the LLM generates IDs with
|
||||
# slightly different casing or punctuation than the AST extractor.
|
||||
# e.g. "Session_ValidateToken" maps to "session_validatetoken".
|
||||
norm_to_id: dict[str, str] = {_normalize_id(nid): nid for nid in node_set}
|
||||
for edge in extraction.get("edges", []):
|
||||
if "source" not in edge and "from" in edge:
|
||||
edge["source"] = edge["from"]
|
||||
@@ -52,6 +67,11 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
|
||||
if "source" not in edge or "target" not in edge:
|
||||
continue
|
||||
src, tgt = edge["source"], edge["target"]
|
||||
# Remap mismatched IDs via normalization before dropping the edge.
|
||||
if src not in node_set:
|
||||
src = norm_to_id.get(_normalize_id(src), src)
|
||||
if tgt not in node_set:
|
||||
tgt = norm_to_id.get(_normalize_id(tgt), tgt)
|
||||
if src not in node_set or tgt not in node_set:
|
||||
continue # skip edges to external/stdlib nodes - expected, not an error
|
||||
attrs = {k: v for k, v in edge.items() if k not in ("source", "target")}
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ def file_hash(path: Path, root: Path = Path(".")) -> str:
|
||||
|
||||
def cache_dir(root: Path = Path(".")) -> Path:
|
||||
"""Returns graphify-out/cache/ - creates it if needed."""
|
||||
d = Path(root) / "graphify-out" / "cache"
|
||||
d = Path(root).resolve() / "graphify-out" / "cache"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
+45
-5
@@ -1970,6 +1970,7 @@ def extract_go(path: Path) -> dict:
|
||||
label_to_nid[normalised.lower()] = n["id"]
|
||||
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
|
||||
def walk_calls(node, caller_nid: str) -> None:
|
||||
if node.type in ("function_declaration", "method_declaration"):
|
||||
@@ -2000,6 +2001,13 @@ def extract_go(path: Path) -> dict:
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
elif callee_name:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee_name,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
@@ -2013,7 +2021,7 @@ def extract_go(path: Path) -> dict:
|
||||
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
|
||||
clean_edges.append(edge)
|
||||
|
||||
return {"nodes": nodes, "edges": clean_edges}
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
|
||||
|
||||
# ── Rust extractor (custom walk) ──────────────────────────────────────────────
|
||||
@@ -2135,6 +2143,7 @@ def extract_rust(path: Path) -> dict:
|
||||
label_to_nid[normalised.lower()] = n["id"]
|
||||
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
|
||||
def walk_calls(node, caller_nid: str) -> None:
|
||||
if node.type == "function_item":
|
||||
@@ -2169,6 +2178,13 @@ def extract_rust(path: Path) -> dict:
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
else:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee_name,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
@@ -2182,7 +2198,7 @@ def extract_rust(path: Path) -> dict:
|
||||
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
|
||||
clean_edges.append(edge)
|
||||
|
||||
return {"nodes": nodes, "edges": clean_edges}
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
|
||||
|
||||
# ── Zig ───────────────────────────────────────────────────────────────────────
|
||||
@@ -2312,6 +2328,7 @@ def extract_zig(path: Path) -> dict:
|
||||
walk(root)
|
||||
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
|
||||
def walk_calls(node, caller_nid: str) -> None:
|
||||
if node.type == "function_declaration":
|
||||
@@ -2329,6 +2346,13 @@ def extract_zig(path: Path) -> dict:
|
||||
add_edge(caller_nid, tgt_nid, "calls",
|
||||
node.start_point[0] + 1,
|
||||
confidence="EXTRACTED", weight=1.0)
|
||||
elif callee:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
@@ -2337,7 +2361,7 @@ def extract_zig(path: Path) -> dict:
|
||||
|
||||
clean_edges = [e for e in edges if e["source"] in seen_ids and
|
||||
(e["target"] in seen_ids or e["relation"] == "imports_from")]
|
||||
return {"nodes": nodes, "edges": clean_edges}
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
|
||||
|
||||
# ── PowerShell ────────────────────────────────────────────────────────────────
|
||||
@@ -2468,6 +2492,7 @@ def extract_powershell(path: Path) -> dict:
|
||||
|
||||
label_to_nid = {n["label"].strip("()").lstrip(".").lower(): n["id"] for n in nodes}
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
|
||||
def walk_calls(node, caller_nid: str) -> None:
|
||||
if node.type in ("function_statement", "class_statement"):
|
||||
@@ -2485,6 +2510,13 @@ def extract_powershell(path: Path) -> dict:
|
||||
add_edge(caller_nid, tgt_nid, "calls",
|
||||
node.start_point[0] + 1,
|
||||
confidence="EXTRACTED", weight=1.0)
|
||||
elif cmd_text:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": cmd_text,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
@@ -2493,7 +2525,7 @@ def extract_powershell(path: Path) -> dict:
|
||||
|
||||
clean_edges = [e for e in edges if e["source"] in seen_ids and
|
||||
(e["target"] in seen_ids or e["relation"] == "imports_from")]
|
||||
return {"nodes": nodes, "edges": clean_edges}
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
|
||||
|
||||
# ── Cross-file import resolution ──────────────────────────────────────────────
|
||||
@@ -2956,6 +2988,7 @@ def extract_elixir(path: Path) -> dict:
|
||||
label_to_nid[normalised.lower()] = n["id"]
|
||||
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
_SKIP_KEYWORDS = frozenset({
|
||||
"def", "defp", "defmodule", "defmacro", "defmacrop",
|
||||
"defstruct", "defprotocol", "defimpl", "defguard",
|
||||
@@ -2995,6 +3028,13 @@ def extract_elixir(path: Path) -> dict:
|
||||
seen_call_pairs.add(pair)
|
||||
add_edge(caller_nid, tgt_nid, "calls",
|
||||
node.start_point[0] + 1, confidence="EXTRACTED", weight=1.0)
|
||||
else:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee_name,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
@@ -3003,7 +3043,7 @@ def extract_elixir(path: Path) -> dict:
|
||||
|
||||
clean_edges = [e for e in edges if e["source"] in seen_ids and
|
||||
(e["target"] in seen_ids or e["relation"] == "imports")]
|
||||
return {"nodes": nodes, "edges": clean_edges, "input_tokens": 0, "output_tokens": 0}
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls, "input_tokens": 0, "output_tokens": 0}
|
||||
|
||||
|
||||
# ── Main extract and collect_files ────────────────────────────────────────────
|
||||
|
||||
+26
-4
@@ -1,6 +1,7 @@
|
||||
# git hook integration - install/uninstall graphify post-commit and post-checkout hooks
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_HOOK_MARKER = "# graphify-hook-start"
|
||||
@@ -117,6 +118,28 @@ def _git_root(path: Path) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def _hooks_dir(root: Path) -> Path:
|
||||
"""Return the git hooks directory, respecting core.hooksPath if set (e.g. Husky)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "config", "core.hooksPath"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
custom = result.stdout.strip()
|
||||
if custom:
|
||||
p = Path(custom)
|
||||
if not p.is_absolute():
|
||||
p = root / p
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
d = root / ".git" / "hooks"
|
||||
d.mkdir(exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
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
|
||||
@@ -158,8 +181,7 @@ def install(path: Path = Path(".")) -> str:
|
||||
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)
|
||||
hooks_dir = _hooks_dir(root)
|
||||
|
||||
commit_msg = _install_hook(hooks_dir, "post-commit", _HOOK_SCRIPT, _HOOK_MARKER)
|
||||
checkout_msg = _install_hook(hooks_dir, "post-checkout", _CHECKOUT_SCRIPT, _CHECKOUT_MARKER)
|
||||
@@ -173,7 +195,7 @@ def uninstall(path: Path = Path(".")) -> str:
|
||||
if root is None:
|
||||
raise RuntimeError(f"No git repository found at or above {path.resolve()}")
|
||||
|
||||
hooks_dir = root / ".git" / "hooks"
|
||||
hooks_dir = _hooks_dir(root)
|
||||
commit_msg = _uninstall_hook(hooks_dir, "post-commit", _HOOK_MARKER, _HOOK_MARKER_END)
|
||||
checkout_msg = _uninstall_hook(hooks_dir, "post-checkout", _CHECKOUT_MARKER, _CHECKOUT_MARKER_END)
|
||||
|
||||
@@ -185,7 +207,7 @@ def status(path: Path = Path(".")) -> str:
|
||||
root = _git_root(path)
|
||||
if root is None:
|
||||
return "Not in a git repository."
|
||||
hooks_dir = root / ".git" / "hooks"
|
||||
hooks_dir = _hooks_dir(root)
|
||||
|
||||
def _check(name: str, marker: str) -> str:
|
||||
p = hooks_dir / name
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: graphify
|
||||
description: any input (code, docs, papers, images) → knowledge graph → clustered communities → HTML + JSON + audit report
|
||||
description: "any input (code, docs, papers, images) - knowledge graph - clustered communities - HTML + JSON + audit report"
|
||||
trigger: /graphify
|
||||
---
|
||||
|
||||
@@ -299,8 +299,10 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
|
||||
Weak or speculative: 0.4-0.5. Most edges should be 0.6-0.9, not 0.5.
|
||||
- AMBIGUOUS edges: 0.1-0.3
|
||||
|
||||
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the filename without extension and entity is the symbol name, both normalized (lowercase, non-alphanumeric chars replaced with `_`). Example: `src/auth/session.py` + `ValidateToken` → `session_validatetoken`. This must match the ID the AST extractor generates so cross-references between code and semantic nodes connect correctly.
|
||||
|
||||
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|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}
|
||||
{"nodes":[{"id":"session_validatetoken","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**
|
||||
|
||||
@@ -17,6 +17,7 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
|
||||
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
watch_path = watch_path.resolve()
|
||||
try:
|
||||
from graphify.extract import extract
|
||||
from graphify.detect import detect
|
||||
|
||||
+1
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "graphifyy"
|
||||
version = "0.4.16"
|
||||
version = "0.4.19"
|
||||
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
@@ -58,7 +58,6 @@ graphify = "graphify.__main__:main"
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["graphify*"]
|
||||
exclude = ["graphify.llm"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md"]
|
||||
|
||||
Reference in New Issue
Block a user