mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 10:27:11 +00:00
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:
+9
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user