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
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Each stage is a single function in its own module. They communicate through plai
| `security.py` | validation helpers | URL / path / label → validated or raises |
| `validate.py` | `validate_extraction(data)` | extraction dict → raises on schema errors |
| `serve.py` | `start_server(graph_path)` | graph file path → MCP stdio server |
| `cluster_graph.py` | `build_cluster(cluster_dir)` | cluster.json + member graph.json files → one linked cross-repo graph (not community detection — that's `cluster.py`) |
| `cluster_cli.py` | `cmd_cluster(argv)` | `graphify cluster <sub>` CLI for cluster_graph.py |
| `watch.py` | `watch(root, flag_path)` | directory → writes flag file on change |
| `benchmark.py` | `run_benchmark(graph_path)` | graph file → corpus vs subgraph token comparison |

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

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

## Unreleased

- New: `graphify cluster` — cluster graphs link multiple repos into one connected graph. A committable `cluster.json` names member repos by git URL (paths resolve per machine via a local override, the spec's path hint, or origin-remote auto-discovery) and declares the cross-repo contracts auto-detection can't see (`api_call`, `shared_resource`, `mirrored_file`, `depends_on`, `references`, each optionally `direction: "both"`); `cluster build` composes member graphs under `tag::` namespaces into a directed graph (reusing the global-graph external dedup) and resolves declared links into `EXTRACTED` edges, writing a standard `graphify-out/graph.json` so query/path/explain/affected/export work unchanged; `cluster check` dry-runs resolution for CI.
- New: cluster member back-references. `cluster build` writes a portable `cluster-ref.json` into each member's `graphify-out/` recording every membership (cluster name + git URL + roster, no absolute paths; `--no-refs` to skip, `cluster remove` drops only its own entry). Inside a member repo, `query`/`path`/`explain`/`affected` accept `--cluster` (or `--cluster NAME`), no-match failures note the membership, and the search-nudge hook + installed skills surface it to assistants; marker fields are sanitized before reaching any assistant-facing output. Without the cluster locally, `--cluster` explains exactly what to clone and build.
- New: `auto_links.packages` connects a member's direct package dependencies to their unique provider in another member (ambiguous matches are warned and skipped; declared links take precedence). Package-manifest nodes now carry normalized `package_key`/`dependency_keys` identities.
- New: `affected` traverses `calls_api`/`mirrors` relations by default, so impact analysis crosses repo boundaries.

## 0.9.25 (2026-07-22)

- License: the project is now licensed under the Apache License, Version 2.0 (previously MIT). Apache 2.0 adds an explicit patent grant and patent-retaliation clause and explicit contribution terms. Contributions made before the relicensing were submitted under MIT and remain available under those terms; the original MIT license text is retained in `LICENSE-MIT` and referenced from `NOTICE`.
Expand Down
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ graphify export callflow-html # Mermaid architecture/call-flow HTML (auto-r

graphify hook install # auto-rebuild on git commit
graphify merge-graphs a.json b.json # combine two graphs
graphify cluster build # cluster graph: link multiple repos with real cross-repo edges

graphify prs # PR dashboard: CI state, review status, worktree mapping
graphify prs 42 # deep dive on PR #42 with graph impact
Expand Down Expand Up @@ -436,6 +437,79 @@ graphify-out/cost.json # local only

---

## Cluster graphs (multi-repo)

A **cluster graph** links several repos' graphs into one connected graph with real cross-repo edges — for ecosystems where the coupling lives in contracts a single-repo scan can't see: service A calls service B's HTTP API via an env-var URL, two repos share a database table, a wire-format type file is copy-mirrored between a client and a worker. (`merge-graphs` and `global` union graphs side by side; a cluster also *connects* them. For community detection on a single graph, see `cluster-only`.)

A cluster is a directory with a `cluster.json` spec:

```json
{
"schema_version": 1,
"name": "my-stack",
"members": [
{"tag": "web", "url": "https://github.com/org/web", "path": "../web"},
{"tag": "worker", "url": "https://github.com/org/worker"}
],
"links": [
{
"type": "api_call",
"name": "ingest-api",
"from": {"repo": "web", "file": "src/lib/api-client.ts"},
"to": {"repo": "worker", "file": "src/index.ts"}
},
{
"type": "shared_resource",
"kind": "db_table",
"name": "events.pings",
"referents": [
{"repo": "web", "label": "pingSync"},
{"repo": "worker", "file": "src/sync.ts"}
]
}
],
"defaults": {"on_missing": "warn"},
"auto_links": {"externals": true, "packages": true}
}
```

YAML specs remain available when PyYAML is installed, but initialization and
documentation use JSON consistently.

Node selectors are `{repo, file|label|id}` — `file` suffix-matches `source_file` (preferring the file node), `label` matches exactly then case-insensitively, `id` matches the member-local node id. You never write raw graph ids. Externals (library nodes with no `source_file`) are deduplicated cluster-wide, so a `label` selector for one resolves under any member's tag regardless of spec order.

Direction: `from` is the dependent side (`from` depends on / calls / copies `to`), matching how `imports` and `calls` edges point. So `graphify affected <changed-node>` seeded on a link's `to` side reports the `from` side — "I changed the worker's payload type; the web client is affected." `direction: "both"` on a link (e.g. a mirrored file kept in sync by hand in both directions) materializes the reverse edge too, so `affected` works from either endpoint; the declared link still owns the node pair in simple mode.

Because members are identified by `url`, the spec commits cleanly and works on any machine: paths resolve via a gitignored `cluster.local.json` override (`graphify cluster locate <tag> <path>`), then the spec's `path` hint, then auto-discovery — scanning sibling directories for a checkout whose `origin` remote matches. A resolved checkout whose origin *doesn't* match the declared url gets a warning, so a same-named directory of the wrong repo can't sneak in.

```bash
graphify cluster init ~/clusters/my-stack --name my-stack
graphify cluster add ../web && graphify cluster add ../worker
# ...declare links in cluster.json, then:
graphify cluster build
cd ~/clusters/my-stack
graphify query "how does a ping reach the database?" # all existing commands work
graphify affected "payload.ts" # impact traverses calls_api/mirrors across repos
graphify path "api-client" "index.ts"
```

The build composes each member's `graphify-out/graph.json` under a `tag::` namespace, dedups external-library nodes by label across members (same behavior as the global graph), resolves the declared links into `EXTRACTED`-confidence edges, and writes a standard `graphify-out/graph.json` plus a `CLUSTER_REPORT.md` documenting every resolved/skipped link. Rebuilds are incremental-aware: unchanged members and spec skip the rebuild entirely. `graphify cluster check` dry-runs the whole thing (exit 1 on errors) — useful in CI to catch selector drift when a member repo refactors.

`cluster check` and `cluster build` reject a declared link that would overwrite another relation on the same pair. `auto_links.packages` connects direct package dependencies to a unique provider in another member repo; external, same-repo, and ambiguous dependencies are skipped, and declared links take precedence.

Each member needs its own graph first (`graphify extract .` in that repo); `build` names exactly which members are missing one.

**Member back-references.** `cluster build` also writes a portable `cluster-ref.json` into each member's `graphify-out/` (skip with `--no-refs`; `cluster remove` cleans it up). Since `graphify-out/` is committed, the marker travels with each member repo: it records the cluster's name and git URL, this member's tag, and the full member roster — no absolute paths. Inside a member repo:

- `graphify query/path/explain/affected --cluster` selects the only membership; `--cluster NAME` selects one explicitly when the repo belongs to several clusters. Both forms are mutually exclusive with `--graph`.
- When a lookup on the local graph comes up empty, the failure message notes the repo is a cluster member and suggests `--cluster` — so an assistant hitting "No node matching 'verifyJwt'" learns the answer may live one repo over.
- If the cluster isn't available on a machine, `--cluster` fails safely with instructions: clone the marker's `cluster_url` and run `graphify cluster build` there (or how to create the cluster when no remote is recorded).
- The search-nudge hook and the installed skill mention cluster membership too, so LLM assistants are aware without running anything.

Note the asymmetry: member markers are committed and travel, while the **cluster directory's own `graphify-out/` stays gitignored** (each machine builds its own composed graph). The marker stores all cluster memberships and each build updates only its own entry.

---

## Using the graph directly

```bash
Expand Down Expand Up @@ -751,6 +825,17 @@ graphify global remove myrepo # remove a project from th
graphify global list # show all registered repos + node/edge counts
graphify global path # print path to the global graph file

graphify cluster init ~/clusters/my-stack --name my-stack # start a cluster (multi-repo linked graph)
graphify cluster add ../frontend # add a member repo (url derived from its origin remote)
graphify cluster add https://github.com/org/backend --as api # or add by URL; path resolved per machine
graphify cluster locate api ~/work/backend # machine-local checkout override (cluster.local.json)
graphify cluster build # compose member graphs + resolve declared links
graphify cluster check # validate the spec + dry-run link resolution (CI-friendly)
graphify cluster status # member resolution + staleness vs last build
graphify query "..." --cluster # from inside a member repo: query the cluster graph
graphify query "..." --cluster my-stack # select by name when the member belongs to several
graphify path "A" "B" --cluster # (also explain/affected; uses graphify-out/cluster-ref.json)

graphify prs # PR dashboard: CI, review, worktree, graph impact
graphify prs 42 # deep dive on PR #42
graphify prs --triage # AI triage ranking (auto-detects backend from env)
Expand Down
14 changes: 11 additions & 3 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,10 @@ def _run_cli() -> None:
print(" --purge also delete graphify-out/ directory")
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(" --cluster [NAME] query this repo's cluster graph instead (from a member repo)")
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(" --cluster [NAME] query this repo's cluster graph instead (from a member repo)")
print(" diagnose multigraph report same-endpoint edge collapse risk in graph.json")
print(" --graph <path> path to graph/extraction JSON")
print(" (default graphify-out/graph.json)")
Expand Down Expand Up @@ -558,10 +560,12 @@ def _run_cli() -> None:
print(" --context C explicit edge-context filter (repeatable)")
print(" --budget N cap output at N tokens (default 2000)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" --cluster [NAME] query this repo's cluster graph instead (from a member repo)")
print(" affected \"X\" reverse traversal to find nodes impacted by X")
print(" --relation R edge relation to traverse in reverse (repeatable)")
print(" --depth N reverse traversal depth (default 2)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" --cluster [NAME] query this repo's cluster graph instead (from a member repo)")
print(" god-nodes list the most connected nodes (architectural hubs)")
print(" --top N how many to show (default 10)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
Expand Down Expand Up @@ -623,6 +627,9 @@ def _run_cli() -> None:
print(" global remove <tag> remove a repo's nodes from the global graph")
print(" global list list repos in the global graph")
print(" global path print path to the global graph file")
print(" cluster <subcommand> cluster graphs: link multiple repos into one connected graph")
print(" (init/add/remove/locate/build/check/status; `graphify cluster` for details)")
print(" (community detection on a single graph is `cluster-only`, above)")
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
print(" export callflow-html emit Mermaid-based architecture/call-flow HTML")
print(" hook install install post-commit/post-checkout git hooks (all platforms)")
Expand Down Expand Up @@ -700,9 +707,10 @@ def _run_cli() -> None:
# Universal help guard: -h/--help/-? anywhere after the command shows help
# and stops — prevents flags from silently triggering destructive subcommands
# (e.g. "cursor install --help" was silently installing into Cursor, #821).
# Exempt: free-text commands (user string may contain these tokens), and
# "install"/"uninstall" which have their own per-subcommand help handlers.
_FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"}
# Exempt: free-text commands (user string may contain these tokens),
# "install"/"uninstall" which have their own per-subcommand help handlers,
# and "cluster" whose dispatcher answers help tokens with its own USAGE.
_FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall", "cluster"}
if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]):
print(f"Run 'graphify --help' for full usage.")
return
Expand Down
7 changes: 7 additions & 0 deletions graphify/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
"uses",
"mixes_in",
"embeds",
# Cluster-graph relations (declared cross-repo links, graphify cluster):
# traversing them by default is what makes `affected` cross repo
# boundaries. `depends_on` is deliberately NOT here — package edges exist
# in single-repo graphs too, and including it would change single-repo
# affected behavior.
"calls_api",
"mirrors",
)


Expand Down
66 changes: 66 additions & 0 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -1297,3 +1297,69 @@ def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int:
to_remove = [n for n, d in G.nodes(data=True) if d.get("repo") == repo_tag]
G.remove_nodes_from(to_remove)
return len(to_remove)


def load_graph_json(path: Path, *, directed: bool = False) -> nx.Graph:
"""Load a persisted graph.json into a plain ``nx.Graph`` (or ``DiGraph``).

Shared by merge-graphs, the global graph, and cluster graphs. Applies the
graph-file size cap, normalizes the legacy ``edges`` key to ``links``
(#738), and coerces DiGraph/MultiGraph/MultiDiGraph inputs to one simple
type so ``nx.compose`` never sees mixed types (#1606).

directed=True loads the stored source/target order into a directed graph.
Persisted simple graphs say ``"directed": false`` even though their edge
order is meaningful (export restores it from _src/_tgt and pops the
attrs), so an undirected round-trip re-emits endpoints by node insertion
order and silently flips caller/callee — the #760 failure mode. Callers
that re-serialize a composed graph must load members directed.
"""
from networkx.readwrite import json_graph as _jg
from .security import check_graph_file_size_cap

check_graph_file_size_cap(path)
data = json.loads(path.read_text(encoding="utf-8"))
if "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
if directed:
data = dict(data, directed=True)
try:
G = _jg.node_link_graph(data, edges="links")
except TypeError:
G = _jg.node_link_graph(data)
simple_type = nx.DiGraph if directed else nx.Graph
if type(G) is not simple_type:
G = simple_type(G)
return G


def merge_prefixed_into(G: nx.Graph, prefixed: nx.Graph) -> int:
"""Merge a repo_tag::-prefixed graph into G in-place. Returns nodes added.

External-library nodes (no ``source_file``) are deduplicated by label
against G's existing externals, with incident edges rewired onto the
shared node instead of dropped — the one place cross-repo identity is
established. Self-loops introduced by the rewiring are skipped.
"""
external_labels = {
d.get("label", ""): n
for n, d in G.nodes(data=True)
if not d.get("source_file") and d.get("label")
}
# Map each deduplicated external onto the existing node so that edges
# incident to it can be rewired instead of dropped.
remap = {}
for node, data in prefixed.nodes(data=True):
if not data.get("source_file") and data.get("label") in external_labels:
remap[node] = external_labels[data["label"]]

for node, data in prefixed.nodes(data=True):
if node not in remap:
G.add_node(node, **data)
for u, v, data in prefixed.edges(data=True):
u = remap.get(u, u)
v = remap.get(v, v)
if u != v: # don't introduce self-loops via remapping
G.add_edge(u, v, **data)

return prefixed.number_of_nodes() - len(remap)
Loading