Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ build/
*.egg
.graphify/
graphify-out/
.graphify-evals/
.graphify_*.json
.graphify_python
.claude/
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## 0.10.0 (2026-07-09)

- **Feat: query results are now ranked by relevance** instead of raw node degree. `graphify query` fuses lexical match, graph proximity to the matched symbols, hub centrality, and community cohesion via Reciprocal Rank Fusion (`graphify/ranking.py`), so the nodes that answer the question render first and survive the token budget. New flags: `--top-k N` (keep the N most relevant nodes), `--explain` (per-node ranking breakdown), and `--semantic` (see below). The MCP `query_graph` tool gains `top_k` and `explain`. Traversal, seeds, and the graph itself are unchanged — only the render order and the new flags are new.
- **Feat: `graphify bench` — relevance evals.** Scores `graphify query` against a JSONL fixture (`{query, expect}`) with P@k / Recall@k / MRR / nDCG@k, running the real ranking pipeline (`graphify/evals.py`). `--init` scaffolds a starter fixture from prominent nodes; `--save` appends the run to `.graphify-evals/eval-results.jsonl`; `--replay` diffs metrics against the last saved run and exits non-zero on regression (CI/pre-commit safe). Deterministic, no API.
- **Feat: optional local semantic search — `graphify embed` + `graphify query --semantic`.** Builds a local embedding sidecar next to `graph.json` (`graphify/embed.py`) using Ollama (`nomic-embed-text` by default) or `sentence-transformers` — no API key, localhost only. `--semantic` fuses cosine similarity into the ranking and, when no label matches the wording, *seeds* the traversal from the nearest embeddings, so fuzzy questions ("where do we handle expired logins") resolve. Strictly opt-in; the default query path stays $0 and deterministic. Configure with `GRAPHIFY_EMBED_BACKEND` / `GRAPHIFY_EMBED_MODEL`.
- **Feat: `graphify chronicle` — structural diff over time.** Diffs two `graph.json` snapshots (`graphify chronicle OLD NEW`) or two git revisions (`--rev REV [--rev2 REV2]`, read via `git show`, no re-extraction) and reports nodes/edges added or removed, god-nodes that emerged or vanished, and community shifts (`graphify/chronicle.py`). Generalizes the single-PR impact view across the whole history; API-free.
- **Feat: `graphify skill status` / `graphify skill check-update` — skill drift detection.** Reports whether the installed skill (SKILL.md + references) matches the running package across every platform, and names any migration a re-install would apply (`graphify/skill_migrations.py`). `check-update` exits non-zero on drift for cron/CI. The agent skill documents the new query flags and a new `references/analysis.md` covering bench / embed / chronicle / skill.

## 0.9.0 (2026-06-28)

- **Breaking — node IDs now include the full repo-relative path** (#1504, #1509). The node-ID stem was the immediate parent dir + filename, so same-named files in different directories collided into one last-writer-wins node and silently dropped graph content (`docs/v1/api/README.md` and `docs/v2/api/README.md` both → `api_readme`). The stem is now the full repo-relative path (`docs_v1_api_readme` vs `docs_v2_api_readme`); top-level files are unchanged (`setup.py` → `setup`). The AST extractor, the LLM system prompt, the extraction-spec, and the two hand-copied stem helpers are all aligned to this one rule (fixing the #1509 AST↔LLM divergence that produced ghost duplicates), and `build_from_json` deterministically re-keys any cached/older semantic fragment onto the new IDs from its `source_file` so the unversioned semantic cache survives without ghosts or a re-bill. **Existing graphs migrate to the new ID format automatically on the next `build`/`update`** (no re-bill). Note: same-named files in different directories that previously collided into one node are only *recovered as distinct nodes* by a fresh extraction — run `graphify extract --force` to rebuild and gain them (migrating an already-collided graph/cache can't resurrect the nodes that were already dropped). If you push to a persisted **Neo4j** store, re-import after upgrading (re-exported IDs change); saved Gephi/yEd (GraphML) layouts go stale; MCP/cypher consumers should query by label rather than persisting node IDs across rebuilds.
Expand Down
361 changes: 359 additions & 2 deletions graphify/__main__.py

Large diffs are not rendered by default.

185 changes: 185 additions & 0 deletions graphify/chronicle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Chronicle — diff two graph snapshots to see how a codebase's structure moved.

`prs.py` answers "what does *this* PR touch?". Chronicle generalizes that across
time: given two ``graph.json`` snapshots (two files, or the same file at two git
revisions), it reports how the *structure* changed — nodes and edges added or
removed, which god-nodes (high-degree hubs) emerged or vanished, and how the
community structure shifted. That surfaces architectural drift a line diff never
shows: a hub forming, a module fragmenting, a subsystem appearing.

The git path is deliberately cheap and API-free: graphify users commit
``graphify-out/graph.json``, so a historical snapshot is just
``git show <rev>:graphify-out/graph.json`` — no re-extraction, no model calls.

Communities are compared by ``community_name`` rather than numeric id, because
ids are reassigned on every rebuild while the human label ("auth", "py") is
stable enough to track a subsystem growing or splitting across versions.
"""
from __future__ import annotations

import json
from collections import Counter

import networkx as nx
from networkx.readwrite import json_graph


def load_graph_from_text(text: str) -> nx.Graph:
"""Parse a graph.json payload (edges- or links-keyed) into a DiGraph."""
data = json.loads(text)
if "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
data = {**data, "directed": True}
try:
return json_graph.node_link_graph(data, edges="links")
except TypeError:
return json_graph.node_link_graph(data)


def _label(G: nx.Graph, nid: str) -> str:
return str(G.nodes[nid].get("label", nid))


def _god_node_ids(G: nx.Graph, top_n: int) -> list[str]:
"""Top-N node ids by degree (graphify's god-node definition)."""
ranked = sorted(G.nodes(), key=lambda n: (-G.degree(n), str(n)))
return ranked[:top_n]


def _edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple[str, str, str]:
return (str(u), str(v), str(data.get("relation", "")))


def _edge_set(G: nx.Graph) -> set[tuple[str, str, str]]:
out: set[tuple[str, str, str]] = set()
if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)):
for u, v, data in G.edges(data=True):
out.add(_edge_key(G, u, v, data))
else:
for u, v, data in G.edges(data=True):
out.add(_edge_key(G, u, v, data))
return out


def _community_sizes(G: nx.Graph) -> Counter:
sizes: Counter = Counter()
for _nid, data in G.nodes(data=True):
name = data.get("community_name")
if name is None and data.get("community") is not None:
name = f"#{data.get('community')}"
if name is not None:
sizes[str(name)] += 1
return sizes


def diff_graphs(old: nx.Graph, new: nx.Graph, *, top_god: int = 15) -> dict:
"""Structural diff between two snapshots. Returns a JSON-friendly dict."""
old_ids, new_ids = set(old.nodes()), set(new.nodes())
added_ids = new_ids - old_ids
removed_ids = old_ids - new_ids

old_edges, new_edges = _edge_set(old), _edge_set(new)
added_edges = new_edges - old_edges
removed_edges = old_edges - new_edges

old_gods, new_gods = _god_node_ids(old, top_god), _god_node_ids(new, top_god)
emerged = [n for n in new_gods if n not in set(old_gods)]
vanished = [n for n in old_gods if n not in set(new_gods)]

old_sizes, new_sizes = _community_sizes(old), _community_sizes(new)
appeared = sorted(set(new_sizes) - set(old_sizes))
disappeared = sorted(set(old_sizes) - set(new_sizes))
resized = []
for name in sorted(set(old_sizes) & set(new_sizes)):
delta = new_sizes[name] - old_sizes[name]
if delta:
resized.append({"name": name, "old": old_sizes[name], "new": new_sizes[name], "delta": delta})
resized.sort(key=lambda r: (-abs(r["delta"]), r["name"]))

return {
"nodes": {
"old_count": len(old_ids),
"new_count": len(new_ids),
"delta": len(new_ids) - len(old_ids),
"added": sorted(
({"id": n, "label": _label(new, n)} for n in added_ids),
key=lambda d: d["label"],
),
"removed": sorted(
({"id": n, "label": _label(old, n)} for n in removed_ids),
key=lambda d: d["label"],
),
},
"edges": {
"old_count": len(old_edges),
"new_count": len(new_edges),
"delta": len(new_edges) - len(old_edges),
"added": sorted(f"{u} --{r}--> {v}" for (u, v, r) in added_edges),
"removed": sorted(f"{u} --{r}--> {v}" for (u, v, r) in removed_edges),
},
"god_nodes": {
"top_n": top_god,
"emerged": [{"id": n, "label": _label(new, n), "degree": new.degree(n)} for n in emerged],
"vanished": [{"id": n, "label": _label(old, n), "degree": old.degree(n)} for n in vanished],
},
"communities": {
"old_count": len(old_sizes),
"new_count": len(new_sizes),
"appeared": appeared,
"disappeared": disappeared,
"resized": resized,
},
}


def _plural(n: int, word: str) -> str:
return f"{n} {word}" + ("" if n == 1 else "s")


def format_diff(diff: dict, *, limit: int = 15) -> str:
n, e, g, c = diff["nodes"], diff["edges"], diff["god_nodes"], diff["communities"]
lines: list[str] = []
lines.append("graphify chronicle — structural diff")
lines.append("=" * 48)
lines.append(
f"Nodes: {n['old_count']} -> {n['new_count']} ({n['delta']:+d}) "
f"Edges: {e['old_count']} -> {e['new_count']} ({e['delta']:+d})"
)
lines.append(
f"Communities: {c['old_count']} -> {c['new_count']} "
f"({len(c['appeared'])} appeared, {len(c['disappeared'])} disappeared)"
)
lines.append("")

if g["emerged"]:
lines.append("God-nodes emerged (new structural hubs):")
for d in g["emerged"][:limit]:
lines.append(f" + {d['label']} (degree {d['degree']})")
if g["vanished"]:
lines.append("God-nodes vanished:")
for d in g["vanished"][:limit]:
lines.append(f" - {d['label']} (was degree {d['degree']})")
if g["emerged"] or g["vanished"]:
lines.append("")

if c["appeared"]:
lines.append("Communities appeared: " + ", ".join(c["appeared"][:limit]))
if c["disappeared"]:
lines.append("Communities disappeared: " + ", ".join(c["disappeared"][:limit]))
if c["resized"]:
lines.append("Largest community shifts:")
for r in c["resized"][:limit]:
lines.append(f" {r['name']}: {r['old']} -> {r['new']} ({r['delta']:+d})")
lines.append("")

lines.append(
f"Detail: {_plural(len(n['added']), 'node')} added, "
f"{_plural(len(n['removed']), 'node')} removed, "
f"{_plural(len(e['added']), 'edge')} added, "
f"{_plural(len(e['removed']), 'edge')} removed."
)
if n["added"]:
lines.append(" new nodes: " + ", ".join(d["label"] for d in n["added"][:limit]) + (" …" if len(n["added"]) > limit else ""))
if n["removed"]:
lines.append(" gone nodes: " + ", ".join(d["label"] for d in n["removed"][:limit]) + (" …" if len(n["removed"]) > limit else ""))
return "\n".join(lines)
Loading