From 2c5d3a50bde34f36e36952f668e465422602aaca Mon Sep 17 00:00:00 2001 From: Safi Date: Fri, 17 Apr 2026 12:46:42 +0100 Subject: [PATCH] v0.4.19: fix #390 #298 #410 #401 #385, team workflow docs, Windows/pipx tips Co-Authored-By: Claude Sonnet 4.6 --- README.md | 22 +++++++++++++++++++- graphify/build.py | 20 ++++++++++++++++++ graphify/cache.py | 2 +- graphify/extract.py | 50 ++++++++++++++++++++++++++++++++++++++++----- graphify/hooks.py | 30 +++++++++++++++++++++++---- graphify/skill.md | 6 ++++-- graphify/watch.py | 1 + pyproject.toml | 3 +-- 8 files changed, 119 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 091435e8..2493ffa6 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/graphify/build.py b/graphify/build.py index 4d3a0b98..f00f9842 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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")} diff --git a/graphify/cache.py b/graphify/cache.py index 03e62d3e..e122fb4f 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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 diff --git a/graphify/extract.py b/graphify/extract.py index 333fa39a..717026ab 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 ──────────────────────────────────────────── diff --git a/graphify/hooks.py b/graphify/hooks.py index c119dea6..a76ed7c5 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -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 diff --git a/graphify/skill.md b/graphify/skill.md index 3a0b7329..eef1144f 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -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** diff --git a/graphify/watch.py b/graphify/watch.py index 79d55c6b..6a354c60 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index bd0cca6e..6e7c0706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]