v0.4.19: fix #390 #298 #410 #401 #385, team workflow docs, Windows/pipx tips

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-04-17 12:46:42 +01:00
co-authored by Claude Sonnet 4.6
parent 69001d0c6b
commit 2c5d3a50bd
8 changed files with 119 additions and 15 deletions
+21 -1
View File
@@ -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 |
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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**
+1
View File
@@ -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
View File
@@ -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"]