v0.4.11: fix query crash on MultiGraph, NoneType in serve.py, MCP CWD path bug, .graphifyignore subfolder patterns; v0.4.10: Dart, Hermes, 6 CLI commands, PHP improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-04-13 12:48:47 +01:00
parent e737f5bf58
commit b00813bcb2
7 changed files with 261 additions and 36 deletions
+9 -1
View File
@@ -2,7 +2,14 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.4.9 (2026-04-13)
## 0.4.11 (2026-04-13)
- Fix: `graphify query` no longer crashes with `ValueError` on MultiGraph graphs — `G.edges[u, v]` replaced with `G[u][v]` + MultiGraph guard (#305)
- Fix: `graphify query` no longer crashes with `AttributeError: 'NoneType' has no attribute 'lower'` when a node has a null `source_file` (#307)
- Fix: MCP server launched from a different directory now correctly derives the `graphify-out` base from the absolute path provided, instead of CWD (#309)
- Fix: `.graphifyignore` patterns from a parent directory now fire correctly when graphify is run on a subfolder — patterns are matched against paths relative to both the scan root and the `.graphifyignore`'s anchor directory (#303)
## 0.4.10 (2026-04-13)
- Fix: `graphify install --platform cursor` no longer crashes — passes `Path(".")` to `_cursor_install` (#281)
- Fix: `_agents_uninstall` now only removes the OpenCode plugin when uninstalling the `opencode` platform — other platforms were incorrectly having their OpenCode plugin stripped (#276)
@@ -20,6 +27,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Add: `prune_dangling_edges()` utility in `export.py` — removes edges whose source/target is not in the node set (#294)
- Fix: Antigravity install injects YAML frontmatter into skill file for native tool discovery; rules now include MCP navigation hint; prints MCP config snippet (#268)
- Fix: Windows hook tests now use platform-aware assertions instead of POSIX executable bit checks (#279)
- Add: CLI commands `path`, `explain`, `add`, `watch`, `update`, `cluster-only` now work as bare terminal commands (not just AI skill invocations) — documented in `--help` output (#277)
## 0.4.8 (2026-04-12)
+13 -2
View File
@@ -36,7 +36,7 @@ dist/
*.generated.py
```
Same syntax as `.gitignore`. Patterns match against file paths relative to the folder you run graphify on.
Same syntax as `.gitignore`. You can keep a single `.graphifyignore` at your repo root — patterns work correctly even when graphify is run on a subfolder.
## How it works
@@ -256,11 +256,22 @@ graphify hermes uninstall
graphify antigravity install # .agent/rules + .agent/workflows (Google Antigravity)
graphify antigravity uninstall
# query the graph directly from the terminal (no AI assistant needed)
# query and navigate the graph directly from the terminal (no AI assistant needed)
graphify query "what connects attention to the optimizer?"
graphify query "show the auth flow" --dfs
graphify query "what is CfgNode?" --budget 500
graphify query "..." --graph path/to/graph.json
graphify path "DigestAuth" "Response" # shortest path between two nodes
graphify explain "SwinTransformer" # plain-language explanation of a node
# add content and update the graph from the terminal
graphify add https://arxiv.org/abs/1706.03762 # fetch paper, save to ./raw, update graph
graphify add https://... --author "Name" --contributor "Name"
# incremental update and maintenance
graphify watch ./src # auto-rebuild on code changes
graphify update ./src # re-extract code files, no LLM needed
graphify cluster-only ./my-project # rerun clustering on existing graph.json
```
Works with any mix of file types:
+192 -1
View File
@@ -750,7 +750,18 @@ def main() -> None:
print("Usage: graphify <command>")
print()
print("Commands:")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor|antigravity)")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes)")
print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" explain \"X\" plain-language explanation of a node and its neighbors")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" add <url> fetch a URL and save it to ./raw, then update the graph")
print(" --author \"Name\" tag the author of the content")
print(" --contributor \"Name\" tag who added it to the corpus")
print(" --dir <path> target directory (default: ./raw)")
print(" watch <path> watch a folder and rebuild the graph on code changes")
print(" update <path> re-extract code files and update the graph (no LLM needed)")
print(" cluster-only <path> rerun clustering on an existing graph.json and regenerate report")
print(" query \"<question>\" BFS traversal of graph.json for a question")
print(" --dfs use depth-first instead of breadth-first")
print(" --budget N cap output at N tokens (default 2000)")
@@ -789,6 +800,8 @@ def main() -> None:
print(" trae-cn uninstall remove graphify section from AGENTS.md")
print(" antigravity install write .agent/rules + .agent/workflows + skill (Google Antigravity)")
print(" antigravity uninstall remove .agent/rules, .agent/workflows, and skill")
print(" hermes install write skill to ~/.hermes/skills/graphify/ (Hermes)")
print(" hermes uninstall remove skill from ~/.hermes/skills/graphify/")
print()
return
@@ -967,6 +980,184 @@ def main() -> None:
source_nodes=opts.nodes or None,
)
print(f"Saved to {out}")
elif cmd == "path":
if len(sys.argv) < 4:
print("Usage: graphify path \"<source>\" \"<target>\" [--graph path]", file=sys.stderr)
sys.exit(1)
from graphify.serve import _score_nodes
from networkx.readwrite import json_graph
import networkx as _nx
source_label = sys.argv[2]
target_label = sys.argv[3]
graph_path = "graphify-out/graph.json"
args = sys.argv[4:]
for i, a in enumerate(args):
if a == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]
gp = Path(graph_path).resolve()
if not gp.exists():
print(f"error: graph file not found: {gp}", file=sys.stderr)
sys.exit(1)
_raw = json.loads(gp.read_text(encoding="utf-8"))
try:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
src_scored = _score_nodes(G, [t.lower() for t in source_label.split()])
tgt_scored = _score_nodes(G, [t.lower() for t in target_label.split()])
if not src_scored:
print(f"No node matching '{source_label}' found.", file=sys.stderr)
sys.exit(1)
if not tgt_scored:
print(f"No node matching '{target_label}' found.", file=sys.stderr)
sys.exit(1)
src_nid, tgt_nid = src_scored[0][1], tgt_scored[0][1]
try:
path_nodes = _nx.shortest_path(G, src_nid, tgt_nid)
except (_nx.NetworkXNoPath, _nx.NodeNotFound):
print(f"No path found between '{source_label}' and '{target_label}'.")
sys.exit(0)
hops = len(path_nodes) - 1
segments = []
for i in range(len(path_nodes) - 1):
u, v = path_nodes[i], path_nodes[i + 1]
edata = G.edges[u, v]
rel = edata.get("relation", "")
conf = edata.get("confidence", "")
conf_str = f" [{conf}]" if conf else ""
if i == 0:
segments.append(G.nodes[u].get("label", u))
segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}")
print(f"Shortest path ({hops} hops):\n " + " ".join(segments))
elif cmd == "explain":
if len(sys.argv) < 3:
print("Usage: graphify explain \"<node>\" [--graph path]", file=sys.stderr)
sys.exit(1)
from graphify.serve import _find_node
from networkx.readwrite import json_graph
label = sys.argv[2]
graph_path = "graphify-out/graph.json"
args = sys.argv[3:]
for i, a in enumerate(args):
if a == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]
gp = Path(graph_path).resolve()
if not gp.exists():
print(f"error: graph file not found: {gp}", file=sys.stderr)
sys.exit(1)
_raw = json.loads(gp.read_text(encoding="utf-8"))
try:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
matches = _find_node(G, label)
if not matches:
print(f"No node matching '{label}' found.")
sys.exit(0)
nid = matches[0]
d = G.nodes[nid]
print(f"Node: {d.get('label', nid)}")
print(f" ID: {nid}")
print(f" Source: {d.get('source_file', '')} {d.get('source_location', '')}".rstrip())
print(f" Type: {d.get('file_type', '')}")
print(f" Community: {d.get('community', '')}")
print(f" Degree: {G.degree(nid)}")
neighbors = list(G.neighbors(nid))
if neighbors:
print(f"\nConnections ({len(neighbors)}):")
for nb in sorted(neighbors, key=lambda n: G.degree(n), reverse=True)[:20]:
edata = G.edges[nid, nb]
rel = edata.get("relation", "")
conf = edata.get("confidence", "")
print(f" --> {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]")
if len(neighbors) > 20:
print(f" ... and {len(neighbors) - 20} more")
elif cmd == "add":
if len(sys.argv) < 3:
print("Usage: graphify add <url> [--author Name] [--contributor Name] [--dir ./raw]", file=sys.stderr)
sys.exit(1)
from graphify.ingest import ingest as _ingest
url = sys.argv[2]
author: str | None = None
contributor: str | None = None
target_dir = Path("raw")
args = sys.argv[3:]
i = 0
while i < len(args):
if args[i] == "--author" and i + 1 < len(args):
author = args[i + 1]; i += 2
elif args[i] == "--contributor" and i + 1 < len(args):
contributor = args[i + 1]; i += 2
elif args[i] == "--dir" and i + 1 < len(args):
target_dir = Path(args[i + 1]); i += 2
else:
i += 1
try:
saved = _ingest(url, target_dir, author=author, contributor=contributor)
print(f"Saved to {saved}")
print("Run /graphify --update in your AI assistant to update the graph.")
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
elif cmd == "watch":
watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
if not watch_path.exists():
print(f"error: path not found: {watch_path}", file=sys.stderr)
sys.exit(1)
from graphify.watch import watch as _watch
try:
_watch(watch_path)
except ImportError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
elif cmd == "cluster-only":
watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
graph_json = watch_path / "graphify-out" / "graph.json"
if not graph_json.exists():
print(f"error: no graph found at {graph_json} — run /graphify first", file=sys.stderr)
sys.exit(1)
from networkx.readwrite import json_graph as _jg
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
print("Loading existing graph...")
_raw = json.loads(graph_json.read_text(encoding="utf-8"))
G = build_from_json(_raw)
print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
print("Re-clustering...")
communities = cluster(G)
cohesion = score_all(G, communities)
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: f"Community {cid}" for cid in communities}
questions = suggest_questions(G, communities, labels)
tokens = {"input": 0, "output": 0}
report = generate(G, communities, cohesion, labels, gods, surprises,
{}, tokens, str(watch_path), suggested_questions=questions)
out = watch_path / "graphify-out"
(out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
to_json(G, communities, str(out / "graph.json"))
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.")
elif cmd == "update":
watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
if not watch_path.exists():
print(f"error: path not found: {watch_path}", file=sys.stderr)
sys.exit(1)
from graphify.watch import _rebuild_code
print(f"Re-extracting code files in {watch_path} (no LLM needed)...")
ok = _rebuild_code(watch_path)
if ok:
print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.")
else:
print("Nothing to update or rebuild failed — check output above.")
elif cmd == "benchmark":
from graphify.benchmark import run_benchmark, print_benchmark
graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json"
+36 -28
View File
@@ -263,20 +263,18 @@ def _is_noise_dir(part: str) -> bool:
return False
def _load_graphifyignore(root: Path) -> list[str]:
"""Read .graphifyignore from root **and ancestor directories**, returning patterns.
def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
"""Read .graphifyignore from root **and ancestor directories**.
Walks upward from *root* towards the filesystem root, collecting patterns
from every ``.graphifyignore`` encountered (like ``.gitignore`` discovery).
The search stops at the filesystem root or at a ``.git`` directory boundary
so it doesn't leak outside the repository.
Returns a list of (anchor_dir, pattern) pairs. Each pattern is matched
against paths relative to both the scan root and the anchor_dir where
the .graphifyignore file was found so patterns written relative to a
parent directory still work when graphify is run on a subfolder.
Lines starting with # are comments. Blank lines are ignored.
Patterns follow gitignore semantics: glob matched against the path
relative to root. A leading slash anchors to root. A trailing slash
matches directories only (we match both dir and file for simplicity).
Walks upward from *root* towards the filesystem root, stopping at a
``.git`` boundary. Lines starting with # are comments; blank lines ignored.
"""
patterns: list[str] = []
patterns: list[tuple[Path, str]] = []
current = root.resolve()
while True:
ignore_file = current / ".graphifyignore"
@@ -284,7 +282,7 @@ def _load_graphifyignore(root: Path) -> list[str]:
for line in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip()
if line and not line.startswith("#"):
patterns.append(line)
patterns.append((current, line))
# Stop climbing once we've processed the git repo root
if (current / ".git").exists():
break
@@ -295,34 +293,44 @@ def _load_graphifyignore(root: Path) -> list[str]:
return patterns
def _is_ignored(path: Path, root: Path, patterns: list[str]) -> bool:
def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
"""Return True if path matches any .graphifyignore pattern."""
if not patterns:
return False
try:
rel = str(path.relative_to(root))
except ValueError:
return False
rel = rel.replace(os.sep, "/")
parts = rel.split("/")
for pattern in patterns:
# Normalize: strip leading/trailing slashes for matching purposes
p = pattern.strip("/")
if not p:
continue
# Match against full relative path
def _matches(rel: str, p: str) -> bool:
parts = rel.split("/")
if fnmatch.fnmatch(rel, p):
return True
# Match against filename alone
if fnmatch.fnmatch(path.name, p):
return True
# Match against any path segment or prefix
# e.g. "vendor" or "vendor/" should match "vendor/lib.py"
for i, part in enumerate(parts):
if fnmatch.fnmatch(part, p):
return True
if fnmatch.fnmatch("/".join(parts[:i + 1]), p):
return True
return False
for anchor, pattern in patterns:
p = pattern.strip("/")
if not p:
continue
# Try path relative to the scan root
try:
rel = str(path.relative_to(root)).replace(os.sep, "/")
if _matches(rel, p):
return True
except ValueError:
pass
# Also try relative to the anchor dir (the .graphifyignore's location),
# so patterns written at a parent level still fire when running on a subfolder
if anchor != root:
try:
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
if _matches(rel_anchor, p):
return True
except ValueError:
pass
return False
+7 -1
View File
@@ -153,7 +153,13 @@ def validate_graph_path(path: str | Path, base: Path | None = None) -> Path:
FileNotFoundError - resolved path does not exist
"""
if base is None:
base = Path("graphify-out").resolve()
resolved_hint = Path(path).resolve()
for candidate in [resolved_hint, *resolved_hint.parents]:
if candidate.name == "graphify-out":
base = candidate
break
if base is None:
base = Path("graphify-out").resolve()
base = base.resolve()
if not base.exists():
+3 -2
View File
@@ -50,7 +50,7 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
norm_terms = [_strip_diacritics(t).lower() for t in terms]
for nid, data in G.nodes(data=True):
norm_label = data.get("norm_label") or _strip_diacritics(data.get("label", "")).lower()
source = data.get("source_file", "").lower()
source = (data.get("source_file") or "").lower()
score = sum(1 for t in norm_terms if t in norm_label) + sum(0.5 for t in norm_terms if t in source)
if score > 0:
scored.append((score, nid))
@@ -99,7 +99,8 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu
lines.append(line)
for u, v in edges:
if u in nodes and v in nodes:
d = G.edges[u, v]
raw = G[u][v]
d = next(iter(raw.values()), {}) if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)) else raw
line = f"EDGE {sanitize_label(G.nodes[u].get('label', u))} --{d.get('relation', '')} [{d.get('confidence', '')}]--> {sanitize_label(G.nodes[v].get('label', v))}"
lines.append(line)
output = "\n".join(lines)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.4.9"
version = "0.4.11"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, OpenClaw, Factory Droid, Trae) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }