From 70639e321bebde066e5e7eb33a1c18f8ecb6da19 Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Tue, 21 Jul 2026 14:14:27 -0400 Subject: [PATCH 01/11] refactor(build): share the graph-json loader and prefixed-merge across multi-repo paths Extract two helpers into build.py, both extracted verbatim from existing call sites (no behavior change): - load_graph_json: size cap + legacy "edges"->"links" normalization (#738) + coercion of directed/multi inputs to a plain undirected Graph (#1606), previously triplicated across merge-graphs, global_graph, and the global-graph loader. - merge_prefixed_into: the external-library dedup-by-label + edge rewiring from global_add (the one existing cross-repo identity behavior). merge-graphs and graphify global now call the shared helpers. Groundwork for cluster graphs, which need the same compose semantics. --- graphify/build.py | 56 ++++++++++++++++++++++++++++++++++++ graphify/cli.py | 41 +++++++++------------------ graphify/global_graph.py | 61 +++++++++------------------------------- 3 files changed, 82 insertions(+), 76 deletions(-) diff --git a/graphify/build.py b/graphify/build.py index 44e8c441c..625e9db5e 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -1297,3 +1297,59 @@ 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) -> nx.Graph: + """Load a persisted graph.json into a plain undirected ``nx.Graph``. + + 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 a simple + Graph so ``nx.compose`` never sees mixed types (#1606). + """ + 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"]) + try: + G = _jg.node_link_graph(data, edges="links") + except TypeError: + G = _jg.node_link_graph(data) + if type(G) is not nx.Graph: + G = nx.Graph(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) diff --git a/graphify/cli.py b/graphify/cli.py index 91df09672..0ea538382 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1993,38 +1993,24 @@ def _load_graph(p: str): sys.exit(1) import networkx as _nx from networkx.readwrite import json_graph as _jg - from graphify.build import prefix_graph_for_global as _prefix, distinct_repo_tags as _repo_tags + from graphify.build import ( + prefix_graph_for_global as _prefix, + distinct_repo_tags as _repo_tags, + load_graph_json as _load_graph, + ) graphs = [] for gp in graph_paths: if not gp.exists(): print(f"error: not found: {gp}", file=sys.stderr) sys.exit(1) - _enforce_graph_size_cap_or_exit(gp) - data = json.loads(gp.read_text(encoding="utf-8")) - # Normalize edges/links key before loading — graphify writes "links" - # via node_link_data but older runs may have used "edges" (#738). - if "links" not in data and "edges" in data: - data = dict(data, links=data["edges"]) + # load_graph_json enforces the size cap, normalizes the legacy + # "edges" key (#738), and coerces directed/multi inputs to a plain + # undirected Graph so nx.compose never sees mixed types (#1606). try: - G = _jg.node_link_graph(data, edges="links") - except TypeError: - G = _jg.node_link_graph(data) - graphs.append(G) - # nx.compose requires all graphs to be the same type. When input graphs - # come from different sources (e.g. an AST-only run vs a full LLM run) one - # may be a MultiGraph and another a Graph. Normalise everything to Graph - # (the graphify default) by converting MultiGraphs with nx.Graph(). - def _to_simple(g: "_nx.Graph") -> "_nx.Graph": - # nx.compose requires every graph to be the same type. Inputs may - # disagree on BOTH axes — directed vs undirected, and multi vs simple - # — because per-repo graph.json files are written by different extract - # paths at different times. Normalise everything to a plain undirected - # Graph (the merged cross-repo view is undirected anyway), which covers - # DiGraph / MultiGraph / MultiDiGraph. Without this a directed input - # crashed compose with "All graphs must be directed or undirected" (#1606). - if type(g) is not _nx.Graph: - return _nx.Graph(g) - return g + graphs.append(_load_graph(gp)) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) # Unique repo tag per graph. The bare `graphify-out/..` dir name is not # unique across inputs (src/graphify-out and frontend/src/graphify-out both # → "src"), which collides same-stem node ids and silently merges unrelated @@ -2035,8 +2021,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": print(f" note: repo dir names collide; using distinct tags: {', '.join(repo_tags)}") merged = _nx.Graph() for G, repo_tag in zip(graphs, repo_tags): - prefixed = _to_simple(_prefix(G, repo_tag)) - merged = _nx.compose(merged, prefixed) + merged = _nx.compose(merged, _prefix(G, repo_tag)) try: out_data = _jg.node_link_data(merged, edges="links") except TypeError: diff --git a/graphify/global_graph.py b/graphify/global_graph.py index eddd0c92a..647afb447 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -48,15 +48,8 @@ def _save_manifest(manifest: dict) -> None: def _load_global_graph() -> nx.Graph: if _GLOBAL_GRAPH.exists(): - from graphify.security import check_graph_file_size_cap - check_graph_file_size_cap(_GLOBAL_GRAPH) - data = json.loads(_GLOBAL_GRAPH.read_text(encoding="utf-8")) - if "links" not in data and "edges" in data: - data = dict(data, links=data["edges"]) - try: - return _jg.node_link_graph(data, edges="links") - except TypeError: - return _jg.node_link_graph(data) + from graphify.build import load_graph_json + return load_graph_json(_GLOBAL_GRAPH) return nx.Graph() @@ -82,7 +75,12 @@ def global_add(source_path: Path, repo_tag: str) -> dict: Returns a summary dict with keys: repo_tag, nodes_added, nodes_removed, skipped. Skipped=True means the source graph hasn't changed since last add. """ - from graphify.build import prefix_graph_for_global, prune_repo_from_graph + from graphify.build import ( + load_graph_json, + merge_prefixed_into, + prefix_graph_for_global, + prune_repo_from_graph, + ) if not source_path.exists(): raise FileNotFoundError(f"graph not found: {source_path}") @@ -102,48 +100,15 @@ def global_add(source_path: Path, repo_tag: str) -> dict: if existing.get("source_hash") == src_hash: return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True} - # Load source graph - from graphify.security import check_graph_file_size_cap - check_graph_file_size_cap(source_path) - data = json.loads(source_path.read_text(encoding="utf-8")) - if "links" not in data and "edges" in data: - data = dict(data, links=data["edges"]) - try: - src_G = _jg.node_link_graph(data, edges="links") - except TypeError: - src_G = _jg.node_link_graph(data) - - # Prefix IDs for cross-project isolation + # Load source graph, prefix IDs for cross-project isolation + src_G = load_graph_json(source_path) prefixed = prefix_graph_for_global(src_G, repo_tag) - # Load global graph and prune stale nodes for this repo + # Load global graph, prune stale nodes for this repo, merge with + # external-library dedup-by-label (shared helper in build.py). G = _load_global_graph() removed = prune_repo_from_graph(G, repo_tag) - - # Merge external-library nodes (no source_file) by label to avoid duplication - 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 global 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"]] - - # Compose: add prefixed nodes (except deduplicated externals) into global graph - 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) - - added = prefixed.number_of_nodes() - len(remap) + added = merge_prefixed_into(G, prefixed) _save_global_graph(G) manifest["repos"][repo_tag] = { From beef26ff86ae97f7c011e00de472b723439194a6 Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:49:36 -0400 Subject: [PATCH 02/11] =?UTF-8?q?feat(cluster):=20cluster=20graphs=20?= =?UTF-8?q?=E2=80=94=20link=20multiple=20repos=20into=20one=20connected=20?= =?UTF-8?q?graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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", which materializes a real reverse edge). cluster build composes member graphs under tag:: namespaces into a DIRECTED graph (members load with the stored source/target order — an undirected round-trip re-emits endpoints by node insertion order and silently flips caller/callee, the #760 failure mode), dedups externals by label cluster-wide (selectors resolve them under any member's tag), renumbers member community ids onto a global range, and resolves declared links into EXTRACTED edges in a standard graphify-out/graph.json; query/path/explain/affected/export work unchanged. cluster check dry-runs resolution for CI. auto_links.packages connects manifest dependencies to their unique cross-repo provider. Guards: corrupt/missing member graphs, empty clusters, self-composition, and same-name cluster collisions (URL-tracked and URL-less) all fail with actionable errors before any output is written; flags missing values and unknown options are hard CLI errors; help tokens anywhere in a cluster invocation print USAGE and never reach a handler. --- graphify/__main__.py | 14 +- graphify/build.py | 22 +- graphify/cli.py | 272 ++++++- graphify/cluster_cli.py | 426 +++++++++++ graphify/cluster_graph.py | 1350 +++++++++++++++++++++++++++++++++++ tests/test_cluster_build.py | 341 +++++++++ tests/test_cluster_cli.py | 294 ++++++++ tests/test_cluster_links.py | 558 +++++++++++++++ tests/test_cluster_spec.py | 246 +++++++ 9 files changed, 3498 insertions(+), 25 deletions(-) create mode 100644 graphify/cluster_cli.py create mode 100644 graphify/cluster_graph.py create mode 100644 tests/test_cluster_build.py create mode 100644 tests/test_cluster_cli.py create mode 100644 tests/test_cluster_links.py create mode 100644 tests/test_cluster_spec.py diff --git a/graphify/__main__.py b/graphify/__main__.py index 924ae986d..2f976c41f 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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 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 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 to graph/extraction JSON") print(" (default graphify-out/graph.json)") @@ -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 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 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 to graph.json (default graphify-out/graph.json)") @@ -623,6 +627,9 @@ def _run_cli() -> None: print(" global remove 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 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)") @@ -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 diff --git a/graphify/build.py b/graphify/build.py index 625e9db5e..8946c0fdb 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -1299,13 +1299,20 @@ def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int: return len(to_remove) -def load_graph_json(path: Path) -> nx.Graph: - """Load a persisted graph.json into a plain undirected ``nx.Graph``. +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 a simple - Graph so ``nx.compose`` never sees mixed types (#1606). + (#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 @@ -1314,12 +1321,15 @@ def load_graph_json(path: Path) -> nx.Graph: 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) - if type(G) is not nx.Graph: - G = nx.Graph(G) + simple_type = nx.DiGraph if directed else nx.Graph + if type(G) is not simple_type: + G = simple_type(G) return G diff --git a/graphify/cli.py b/graphify/cli.py index 0ea538382..a860c0be2 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -81,10 +81,154 @@ ) +def _hook_cluster_line() -> str: + """Member-of-cluster line for hook nudges, or ''. Never raises. + + Reads graphify-out/cluster-ref.json (written by `graphify cluster build`) + via the stdlib-only cluster_ref module — the hook path must stay light. + """ + try: + from graphify.cluster_ref import load_cluster_refs + from graphify.paths import GRAPHIFY_OUT + from graphify.security import sanitize_label + + refs = load_cluster_refs(Path(GRAPHIFY_OUT)) + if not refs: + return "" + # The marker is a committed file that travels with clones — its fields + # are untrusted input to assistant-facing hook context, so they pass + # sanitize_label (stdlib-only; the hook path stays light). + if len(refs) > 1: + names = ", ".join(sorted(sanitize_label(str(ref["cluster_name"])) for ref in refs)) + return ( + f" This repo belongs to {len(refs)} clusters ({names}); for " + "cross-repo questions add --cluster NAME to graphify " + "query/path/explain/affected." + ) + ref = refs[0] + try: + member_count: "int | str" = int(ref.get("member_count", 0)) or "?" + except (TypeError, ValueError): + member_count = "?" + return ( + f" This repo is member '{sanitize_label(str(ref['self_tag']))}' of cluster " + f"'{sanitize_label(str(ref['cluster_name']))}' ({member_count} members); " + f"for cross-repo questions add --cluster to graphify " + f"query/path/explain/affected." + ) + except Exception: + return "" + + +def _nudge_with_cluster(nudge_json: str) -> str: + """Append the cluster line to a pre-serialized nudge payload, or return it as-is.""" + line = _hook_cluster_line() + if not line: + return nudge_json + try: + payload = json.loads(nudge_json) + payload["hookSpecificOutput"]["additionalContext"] += line + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + except Exception: + return nudge_json + + def _default_graph_path() -> str: return str(Path(_GRAPHIFY_OUT) / "graph.json") +def _resolve_cluster_graph_or_exit(cluster_name: str | None = None) -> str: + """--cluster: member marker -> local cluster dir -> its graph.json. + + Every failure mode exits 1 with an actionable message — the user asked + for the cluster explicitly, so unlike the passive hints this is loud. + """ + from graphify.cluster_ref import ( + load_cluster_refs, + select_cluster_ref, + unresolvable_message, + ) + + out_dir = Path(_GRAPHIFY_OUT) + refs = load_cluster_refs(out_dir) + if not refs: + if (out_dir / "cluster-ref.json").is_file(): + print( + f"error: {out_dir}/cluster-ref.json is unreadable; re-run " + f"'graphify cluster build' in the cluster to rewrite it", + file=sys.stderr, + ) + else: + print( + f"error: no cluster-ref.json in {out_dir}/ — this repo is not a " + f"known cluster member (or run this from the repo root); " + f"'graphify cluster build' writes the marker", + file=sys.stderr, + ) + sys.exit(1) + try: + ref = select_cluster_ref(refs, cluster_name) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + from graphify.cluster_ref import resolve_cluster_dir + + member_root = out_dir.resolve().parent + cluster_dir = resolve_cluster_dir(ref, member_root) + if cluster_dir is None: + print(f"error: {unresolvable_message(ref)}", file=sys.stderr) + sys.exit(1) + from graphify.cluster_graph import cluster_out_dir + + cluster_graph = cluster_out_dir(cluster_dir) / "graph.json" + if not cluster_graph.is_file(): + print( + f"error: cluster '{ref['cluster_name']}' found at {cluster_dir} but has " + f"no built graph; run 'graphify cluster build' in {cluster_dir}", + file=sys.stderr, + ) + sys.exit(1) + return str(cluster_graph) + + +def _maybe_cluster_hint(graph_path: "Path | str") -> str: + """One-line member-of-cluster hint for no-match failures, or ''. + + Only fires when querying the DEFAULT graph (a hint on an explicit + --graph/--cluster run would be noise or wrong). Never raises. + """ + try: + if Path(graph_path).resolve() != Path(_default_graph_path()).resolve(): + return "" + from graphify.cluster_ref import cluster_hint_line, load_cluster_refs + + return cluster_hint_line(load_cluster_refs(Path(_GRAPHIFY_OUT))) + except Exception: + return "" + + +def _parse_cluster_option(args: list[str], index: int) -> tuple[bool, str | None, int] | None: + """Parse ``--cluster``, ``--cluster NAME``, or ``--cluster=NAME``. + + Returns ``(enabled, selected_name, consumed)`` when ``args[index]`` is a + cluster option, otherwise ``None``. Command positional arguments are + consumed before this helper is used, so a following non-option is safely a + cluster name. + """ + arg = args[index] + if arg.startswith("--cluster="): + name = arg.split("=", 1)[1] + if not name: + print("error: --cluster= requires a cluster name", file=sys.stderr) + sys.exit(1) + return True, name, 1 + if arg != "--cluster": + return None + if index + 1 < len(args) and not args[index + 1].startswith("--"): + return True, args[index + 1], 2 + return True, None, 1 + + def _stamped_manifest_files( files_by_type: dict[str, list[str]], sem_result: dict, @@ -427,7 +571,7 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: payload = {"decision": "allow"} try: if out_path("graph.json").is_file(): - payload["additionalContext"] = _GEMINI_NUDGE_TEXT + payload["additionalContext"] = _GEMINI_NUDGE_TEXT + _hook_cluster_line() except Exception: pass sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) @@ -456,7 +600,7 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: is_bash_search = any(tok in cmd_str for tok in ( "grep", "ripgrep", "rg ", "find ", "fd ", "ack ", "ag ")) if (is_grep_tool or is_bash_search) and out_path("graph.json").is_file(): - sys.stdout.write(_SEARCH_NUDGE) + sys.stdout.write(_nudge_with_cluster(_SEARCH_NUDGE)) elif kind == "read": vals = [str(t.get("file_path") or ""), str(t.get("pattern") or ""), str(t.get("path") or "")] j = " ".join(vals).lower().replace("\\", "/") @@ -744,6 +888,11 @@ def dispatch_command(cmd: str) -> None: elif cmd == "prs": from graphify.prs import cmd_prs cmd_prs(sys.argv[2:]) + elif cmd == "cluster": + # Cluster graphs (multi-repo). Community detection on a single graph + # is `cluster-only`, not this. + from graphify.cluster_cli import cmd_cluster + cmd_cluster(sys.argv[2:]) elif cmd == "hook": from graphify.hooks import ( install as hook_install, @@ -763,7 +912,7 @@ def dispatch_command(cmd: str) -> None: sys.exit(1) elif cmd == "query": if len(sys.argv) < 3: - print("Usage: graphify query \"\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr) + print("Usage: graphify query \"\" [--dfs] [--context C] [--budget N] [--graph path] [--cluster [NAME]]", file=sys.stderr) sys.exit(1) from graphify.serve import _query_graph_text from graphify.security import sanitize_label @@ -774,6 +923,9 @@ def dispatch_command(cmd: str) -> None: use_dfs = "--dfs" in sys.argv budget = 2000 graph_path = _default_graph_path() + graph_given = False + use_cluster = False + cluster_name: str | None = None context_filters: list[str] = [] args = sys.argv[3:] i = 0 @@ -800,9 +952,18 @@ def dispatch_command(cmd: str) -> None: i += 1 elif args[i] == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_given = True i += 2 + elif (parsed_cluster := _parse_cluster_option(args, i)) is not None: + use_cluster, cluster_name, consumed = parsed_cluster + i += consumed else: i += 1 + if use_cluster and graph_given: + print("error: --graph and --cluster are mutually exclusive", file=sys.stderr) + sys.exit(1) + if use_cluster: + graph_path = _resolve_cluster_graph_or_exit(cluster_name) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -826,8 +987,11 @@ def dispatch_command(cmd: str) -> None: # a seed with no outgoing edges. Direction is instead preserved # per-edge below (mirrors graphify/build.py's _src/_tgt pattern) # so the *rendering* stays correct without narrowing traversal. + # Force the flag too: cluster graphs persist "directed": true, and + # honoring it here would narrow traversal exactly that way. _raw = dict( _raw, + directed=False, links=[ {**link, "_src": link.get("source"), "_tgt": link.get("target")} for link in _raw.get("links", []) @@ -862,6 +1026,13 @@ def dispatch_command(cmd: str) -> None: token_budget=budget, context_filters=context_filters, ) + if _result.startswith("No matching nodes found."): + # Same cluster-membership breadcrumb the other query surfaces get + # (the hook text promises it for query too); logged with the + # result so the query log matches what was printed. + hint = _maybe_cluster_hint(gp) + if hint: + _result += "\n" + hint querylog.log_query( kind="query", question=question, @@ -876,11 +1047,14 @@ def dispatch_command(cmd: str) -> None: print(_result) elif cmd == "affected": if len(sys.argv) < 3: - print("Usage: graphify affected \"\" [--relation R] [--depth N] [--graph path]", file=sys.stderr) + print("Usage: graphify affected \"\" [--relation R] [--depth N] [--graph path] [--cluster [NAME]]", file=sys.stderr) sys.exit(1) from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_affected, load_graph query = sys.argv[2] graph_path = _default_graph_path() + graph_given = False + use_cluster = False + cluster_name: str | None = None depth = 2 relations: list[str] = [] args = sys.argv[3:] @@ -888,10 +1062,15 @@ def dispatch_command(cmd: str) -> None: while i < len(args): if args[i] == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_given = True i += 2 elif args[i].startswith("--graph="): graph_path = args[i].split("=", 1)[1] + graph_given = True i += 1 + elif (parsed_cluster := _parse_cluster_option(args, i)) is not None: + use_cluster, cluster_name, consumed = parsed_cluster + i += consumed elif args[i] == "--depth" and i + 1 < len(args): try: depth = int(args[i + 1]) @@ -914,6 +1093,11 @@ def dispatch_command(cmd: str) -> None: i += 1 else: i += 1 + if use_cluster and graph_given: + print("error: --graph and --cluster are mutually exclusive", file=sys.stderr) + sys.exit(1) + if use_cluster: + graph_path = _resolve_cluster_graph_or_exit(cluster_name) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -926,14 +1110,17 @@ def dispatch_command(cmd: str) -> None: except Exception as exc: print(f"error: could not load graph: {exc}", file=sys.stderr) sys.exit(1) - print( - format_affected( - graph, - query, - relations=relations or DEFAULT_AFFECTED_RELATIONS, - depth=depth, - ) + out = format_affected( + graph, + query, + relations=relations or DEFAULT_AFFECTED_RELATIONS, + depth=depth, ) + if out.startswith("No unique node match") or out.rstrip().endswith("No affected nodes found."): + hint = _maybe_cluster_hint(gp) + if hint: + out = f"{out.rstrip()}\n{hint}" + print(out) elif cmd in ("god-nodes", "god_nodes"): # god_nodes has long been an analyzer (analyze.py), an MCP tool, and a # README-advertised capability, but never a CLI subcommand — `graphify @@ -1080,7 +1267,7 @@ def dispatch_command(cmd: str) -> None: elif cmd == "path": if len(sys.argv) < 4: print( - 'Usage: graphify path "" "" [--graph path]', + 'Usage: graphify path "" "" [--graph path] [--cluster [NAME]]', file=sys.stderr, ) sys.exit(1) @@ -1091,10 +1278,29 @@ def dispatch_command(cmd: str) -> None: source_label = sys.argv[2] target_label = sys.argv[3] graph_path = _default_graph_path() + graph_given = False + use_cluster = False + cluster_name: str | None = None args = sys.argv[4:] - for i, a in enumerate(args): + i = 0 + while i < len(args): + a = args[i] if a == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_given = True + i += 2 + continue + parsed_cluster = _parse_cluster_option(args, i) + if parsed_cluster is not None: + use_cluster, cluster_name, consumed = parsed_cluster + i += consumed + continue + i += 1 + if use_cluster and graph_given: + print("error: --graph and --cluster are mutually exclusive", file=sys.stderr) + sys.exit(1) + if use_cluster: + graph_path = _resolve_cluster_graph_or_exit(cluster_name) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -1116,11 +1322,16 @@ def dispatch_command(cmd: str) -> None: 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()]) + _hint = _maybe_cluster_hint(gp) if not src_scored: print(f"No node matching '{source_label}' found.", file=sys.stderr) + if _hint: + print(_hint, file=sys.stderr) sys.exit(1) if not tgt_scored: print(f"No node matching '{target_label}' found.", file=sys.stderr) + if _hint: + print(_hint, file=sys.stderr) sys.exit(1) src_nid = _pick_scored_endpoint(G, src_scored, source_label) tgt_nid = _pick_scored_endpoint(G, tgt_scored, target_label) @@ -1161,6 +1372,8 @@ def dispatch_command(cmd: str) -> None: path_nodes = _nx.shortest_path(_und, src_nid, tgt_nid) except (_nx.NetworkXNoPath, _nx.NodeNotFound): print(f"No path found between '{source_label}' and '{target_label}'.") + if _hint: + print(_hint) sys.exit(0) hops = len(path_nodes) - 1 segments = [] @@ -1199,17 +1412,36 @@ def dispatch_command(cmd: str) -> None: elif cmd == "explain": if len(sys.argv) < 3: - print('Usage: graphify explain "" [--graph path]', file=sys.stderr) + print('Usage: graphify explain "" [--graph path] [--cluster [NAME]]', file=sys.stderr) sys.exit(1) from graphify.serve import _find_node from networkx.readwrite import json_graph label = sys.argv[2] graph_path = _default_graph_path() + graph_given = False + use_cluster = False + cluster_name: str | None = None args = sys.argv[3:] - for i, a in enumerate(args): + i = 0 + while i < len(args): + a = args[i] if a == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + graph_given = True + i += 2 + continue + parsed_cluster = _parse_cluster_option(args, i) + if parsed_cluster is not None: + use_cluster, cluster_name, consumed = parsed_cluster + i += consumed + continue + i += 1 + if use_cluster and graph_given: + print("error: --graph and --cluster are mutually exclusive", file=sys.stderr) + sys.exit(1) + if use_cluster: + graph_path = _resolve_cluster_graph_or_exit(cluster_name) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -1227,6 +1459,9 @@ def dispatch_command(cmd: str) -> None: matches = _find_node(G, label) if not matches: print(f"No node matching '{label}' found.") + hint = _maybe_cluster_hint(gp) + if hint: + print(hint) sys.exit(0) nid = matches[0] d = G.nodes[nid] @@ -3368,7 +3603,12 @@ def _invalidate_file_manifest_for_db_graph() -> None: root=target, ) else: - G = _build([merged], dedup=True, dedup_llm_backend=dedup_backend, root=target) + G = _build( + [merged], + dedup=True, + dedup_llm_backend=dedup_backend, + root=target, + ) stages.mark("build") if G.number_of_nodes() == 0: print( diff --git a/graphify/cluster_cli.py b/graphify/cluster_cli.py new file mode 100644 index 000000000..29acb01ae --- /dev/null +++ b/graphify/cluster_cli.py @@ -0,0 +1,426 @@ +"""CLI for cluster graphs (multi-repo): `graphify cluster `. + +Delegated from cli.dispatch_command in the same style as `graphify prs`. +For community detection on a single graph, see `cluster-only` / `--no-cluster`. +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +from .cluster_graph import ( + ClusterMember, + ClusterSpec, + ClusterSpecError, + build_cluster, + check_cluster, + cluster_out_dir, + find_spec_file, + load_local_config, + load_spec, + member_graph_path, + normalize_git_url, + origin_url, + resolve_all_members, + resolve_member_path, + save_local_config, + save_spec, + validate_member_tag, +) + +USAGE = """\ +Usage: graphify cluster + +Manage cluster graphs: link multiple repos' graphs into one connected graph. +(For community detection on a single graph, see `graphify cluster-only`.) + +Subcommands: + init [DIR] --name NAME create a cluster spec skeleton + add [--as TAG] [--dir DIR] + add a member repo to the spec + remove [--dir DIR] remove a member from the spec + locate [--dir DIR] + record a machine-local checkout path override + build [--dir DIR] [--force] [--no-links] [--no-refs] + compose member graphs + resolve declared links + (writes a cluster-ref.json back-reference into + each member's graphify-out/ unless --no-refs) + check [--dir DIR] validate spec and dry-run link resolution + status [--dir DIR] members, resolution, staleness vs last build + +The cluster graph is written to /graphify-out/graph.json, so query/path/ +explain/affected/export all work from inside the cluster directory (or via +--graph). Declare cross-repo links in cluster.json; see README for the format. +""" + + +def _fail(msg: str) -> None: + print(f"error: {msg}", file=sys.stderr) + sys.exit(1) + + +def _take_flag_value(args: list[str], i: int, flag: str) -> tuple[str, int]: + """Consume ``--flag VALUE`` or ``--flag=VALUE`` at args[i]. + + A missing value (end of args, empty ``=`` form, or another ``--option`` + next) is a hard error — falling through would turn the next token (or the + flag itself, in the caller's positional handling) into a positional and + silently do the wrong thing, e.g. `cluster init --name` creating a + directory literally named ``--name``. + """ + a = args[i] + if a.startswith(flag + "="): + value = a.split("=", 1)[1] + if not value: + _fail(f"{flag} requires a value") + return value, i + 1 + if i + 1 >= len(args) or args[i + 1].startswith("--"): + _fail(f"{flag} requires a value") + return args[i + 1], i + 2 + + +def _parse_dir(args: list[str]) -> tuple[Path, list[str]]: + """Pop --dir DIR (default: cwd) from args.""" + rest: list[str] = [] + cluster_dir = Path(".") + i = 0 + while i < len(args): + if args[i] == "--dir" or args[i].startswith("--dir="): + value, i = _take_flag_value(args, i, "--dir") + cluster_dir = Path(value) + else: + rest.append(args[i]) + i += 1 + return cluster_dir, rest + + +def _reject_unknown_flags(rest: list[str], usage: str) -> None: + unknown = [a for a in rest if a.startswith("--")] + if unknown: + _fail(f"unknown option {unknown[0]!r} (usage: {usage})") + + +def _looks_like_url(s: str) -> bool: + return "://" in s or (s.startswith("git@") and ":" in s) + + +def _cmd_init(args: list[str]) -> None: + usage = "graphify cluster init [DIR] --name NAME" + cluster_dir, rest = _parse_dir(args) + name = "" + positional: list[str] = [] + i = 0 + while i < len(rest): + if rest[i] == "--name" or rest[i].startswith("--name="): + name, i = _take_flag_value(rest, i, "--name") + elif rest[i].startswith("--"): + _fail(f"unknown option {rest[i]!r} (usage: {usage})") + else: + positional.append(rest[i]) + i += 1 + if len(positional) > 1: + _fail(f"usage: {usage}") + if positional: + cluster_dir = Path(positional[0]) + cluster_dir.mkdir(parents=True, exist_ok=True) + if find_spec_file(cluster_dir): + _fail(f"a cluster spec already exists in {cluster_dir}") + spec = ClusterSpec(name=name or cluster_dir.resolve().name) + target = save_spec(spec, cluster_dir) + + # Keep machine-local files and build output out of a committed cluster dir. + gitignore = cluster_dir / ".gitignore" + wanted = ["cluster.local.*", "graphify-out/"] + existing = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.is_file() else [] + missing = [w for w in wanted if w not in existing] + if missing: + from .paths import write_text_atomic + write_text_atomic(gitignore, "\n".join(existing + missing) + "\n") + + print(f"Initialized cluster '{spec.name}' at {target}") + print("Next: graphify cluster add [--as TAG]") + + +def _cmd_add(args: list[str]) -> None: + usage = "graphify cluster add [--as TAG] [--dir DIR]" + cluster_dir, rest = _parse_dir(args) + tag = "" + positional = [] + i = 0 + while i < len(rest): + if rest[i] == "--as" or rest[i].startswith("--as="): + tag, i = _take_flag_value(rest, i, "--as") + elif rest[i].startswith("--"): + _fail(f"unknown option {rest[i]!r} (usage: {usage})") + else: + positional.append(rest[i]) + i += 1 + if len(positional) != 1: + _fail(f"usage: {usage}") + target = positional[0] + + spec = load_spec(cluster_dir) + if _looks_like_url(target): + url, path = target, "" + default_tag = normalize_git_url(url).rstrip("/").rsplit("/", 1)[-1] + else: + repo_dir = Path(target).expanduser() + if not repo_dir.is_dir(): + _fail(f"not a directory: {repo_dir}") + # Make the input independent of the invocation cwd without resolving + # symlinks; member path resolution deliberately preserves user-written + # symlinked checkout layouts. + repo_dir = Path(os.path.abspath(repo_dir)) + if repo_dir == Path(os.path.abspath(cluster_dir)): + _fail( + "cannot add the cluster directory as its own member; a cluster " + "composes OTHER repos' graphs (run this from the cluster dir " + "and pass the member repo's path)" + ) + url = origin_url(repo_dir) or "" + try: + # abspath (not resolve) on BOTH sides: the hint is later re-joined + # against the unresolved cluster dir with normpath, so `..` hops + # computed against a symlink-resolved base would land in the wrong + # tree (e.g. macOS /tmp -> /private/tmp). + path = os.path.relpath(repo_dir, os.path.abspath(cluster_dir)) + except ValueError: # Windows cross-drive paths cannot be relative. + path = str(repo_dir) + default_tag = repo_dir.name + if not url: + print( + f"warning: {repo_dir} has no origin remote; this member will only " + f"resolve via its recorded path", + file=sys.stderr, + ) + tag = tag or default_tag + if tag in spec.tags(): + _fail(f"member tag '{tag}' already exists (use --as to pick another)") + try: + validate_member_tag(tag, where=spec.spec_path.name if spec.spec_path else "cluster spec") + except ClusterSpecError as exc: + _fail(str(exc)) + + spec.members.append(ClusterMember(tag=tag, url=url, path=path)) + save_spec(spec, cluster_dir) + print(f"Added member '{tag}'" + (f" ({url})" if url else "")) + + +def _cmd_remove(args: list[str]) -> None: + cluster_dir, rest = _parse_dir(args) + _reject_unknown_flags(rest, "graphify cluster remove [--dir DIR]") + if len(rest) != 1: + _fail("usage: graphify cluster remove [--dir DIR]") + tag = rest[0] + spec = load_spec(cluster_dir) + if tag not in spec.tags(): + _fail(f"no member with tag '{tag}'") + referencing = [ + i for i, l in enumerate(spec.links) + for sel in ([l.from_, l.to] if l.from_ else []) + l.referents + if sel and sel["repo"] == tag + ] + if referencing: + _fail( + f"member '{tag}' is referenced by links {sorted(set(referencing))}; " + f"remove or update those links first" + ) + # Resolve before mutating the spec so this cluster's membership can be + # removed from the member marker too; cleanup never blocks removal. + member = next(m for m in spec.members if m.tag == tag) + resolved_path, _warnings = resolve_member_path(member, cluster_dir, load_local_config(cluster_dir)) + spec.members = [m for m in spec.members if m.tag != tag] + save_spec(spec, cluster_dir) + print(f"Removed member '{tag}'") + if resolved_path is not None: + from .cluster_ref import ( + CLUSTER_REF_NAME, + CLUSTER_REF_VERSION, + load_cluster_refs, + ) + from .paths import GRAPHIFY_OUT_NAME, write_json_atomic + + out_dir = resolved_path / GRAPHIFY_OUT_NAME + marker = out_dir / CLUSTER_REF_NAME + try: + if marker.is_file(): + refs = [ + ref for ref in load_cluster_refs(out_dir) + if ref["cluster_name"] != spec.name + ] + if refs: + write_json_atomic( + marker, + {"version": CLUSTER_REF_VERSION, "clusters": refs}, + indent=2, + ) + print(f" also removed its '{spec.name}' membership") + else: + marker.unlink() + print(f" also removed its {CLUSTER_REF_NAME} marker") + except OSError as exc: + print(f" note: could not remove {marker}: {exc}", file=sys.stderr) + else: + print( + f" note: could not resolve '{tag}' locally; its cluster-ref.json " + f"(if any) was left in place", + file=sys.stderr, + ) + + +def _cmd_locate(args: list[str]) -> None: + cluster_dir, rest = _parse_dir(args) + _reject_unknown_flags(rest, "graphify cluster locate [--dir DIR]") + if len(rest) != 2: + _fail("usage: graphify cluster locate [--dir DIR]") + tag, path_str = rest + spec = load_spec(cluster_dir) + if tag not in spec.tags(): + _fail(f"no member with tag '{tag}'") + path = Path(path_str).expanduser() + if not path.is_dir(): + _fail(f"not a directory: {path}") + member = next(m for m in spec.members if m.tag == tag) + if member.url: + found = origin_url(path) + if found and normalize_git_url(found) != normalize_git_url(member.url): + print( + f"warning: {path} has origin {found!r}, but the spec declares " + f"{member.url!r} for '{tag}'", + file=sys.stderr, + ) + cfg = load_local_config(cluster_dir) + cfg.setdefault("paths", {})[tag] = str(path.resolve()) + target = save_local_config(cluster_dir, cfg) + print(f"Recorded {tag} -> {path.resolve()} in {target.name}") + + +def _cmd_build(args: list[str]) -> None: + cluster_dir, rest = _parse_dir(args) + force = "--force" in rest + no_links = "--no-links" in rest + no_refs = "--no-refs" in rest + unknown = [a for a in rest if a not in ("--force", "--no-links", "--no-refs")] + if unknown: + _fail(f"unknown arguments: {' '.join(unknown)}") + summary = build_cluster(cluster_dir, force=force, no_links=no_links, write_refs=not no_refs) + if summary["skipped"]: + print(f"Cluster '{summary['name']}' unchanged; skipped (use --force to rebuild)") + if summary.get("refs_written"): + print(f" cluster-refs: backfilled {summary['refs_written']} member marker(s)") + return + report = summary["links"] + members = summary["members"] + print( + f"Cluster '{summary['name']}': {len(members)} members -> " + f"{summary['nodes']} nodes, {summary['edges']} edges" + ) + print(f" links: {report.edges_added} edges, {report.hubs_added} shared-resource hubs") + if report.auto_package_edges: + print(f" automatic package links: {report.auto_package_edges}") + if report.nodes_created: + print(f" created {len(report.nodes_created)} concept nodes (on_missing: create)") + if not no_refs: + print(f" cluster-refs: wrote {summary.get('refs_written', 0)} member marker(s)") + print(f"Written to: {summary['out']}") + print(f"Query it with: cd {cluster_dir} && graphify query \"...\"") + + +def _cmd_check(args: list[str]) -> None: + cluster_dir, rest = _parse_dir(args) + if rest: + _fail(f"unknown arguments: {' '.join(rest)}") + report, errors = check_cluster(cluster_dir) + for r in report.resolved: + print(f" ok: {r}") + for w in report.warnings: + print(f" warning: {w}") + for e in errors: + print(f" error: {e}", file=sys.stderr) + if errors: + sys.exit(1) + print( + f"Spec OK: {report.edges_added} edges " + f"({report.auto_package_edges} automatic package), " + f"{report.hubs_added} hubs would be created" + ) + + +def _cmd_status(args: list[str]) -> None: + cluster_dir, rest = _parse_dir(args) + if rest: + _fail(f"unknown arguments: {' '.join(rest)}") + spec = load_spec(cluster_dir) + local_cfg = load_local_config(cluster_dir) + resolved, warnings, errors = resolve_all_members(spec, cluster_dir, local_cfg) + + manifest = {} + manifest_path = cluster_out_dir(cluster_dir) / "cluster-manifest.json" + if manifest_path.is_file(): + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + pass + built = manifest.get("members") or {} + + print( + f"Cluster '{spec.name}' ({len(spec.members)} members, " + f"{len(spec.links)} links, graph mode: {spec.graph_mode})" + ) + from .cluster_graph import _file_hash + for member in spec.members: + path = resolved.get(member.tag) + if path is None: + print(f" {member.tag}: UNRESOLVED" + (f" ({member.url})" if member.url else "")) + continue + gp = member_graph_path(member, path) + if not gp.is_file(): + state = "no graph (run graphify extract there)" + elif member.tag not in built: + state = "not in last build" + elif built[member.tag].get("source_hash") != _file_hash(gp): + state = "stale (graph changed since last build)" + else: + state = f"ok ({built[member.tag].get('node_count', '?')} nodes)" + print(f" {member.tag}: {path} — {state}") + for w in warnings: + print(f" warning: {w}", file=sys.stderr) + for e in errors: + print(f" error: {e}", file=sys.stderr) + if manifest: + print(f"Last build: {manifest.get('built_at', '?')} — " + f"{manifest.get('node_count', '?')} nodes, {manifest.get('edge_count', '?')} edges") + sys.exit(1 if errors else 0) + + +def cmd_cluster(argv: list[str]) -> None: + sub = argv[0] if argv else "" + # Help tokens ANYWHERE in argv print USAGE and stop — `cluster init --help` + # must never fall through to a handler and mkdir/init as a side effect. + # (This dispatcher is exempted from __main__'s universal help guard so the + # user gets the cluster USAGE instead of the generic pointer.) + help_requested = sub in ("", "help") or any( + a in ("-h", "--help", "-?") for a in argv + ) + handlers = { + "init": _cmd_init, + "add": _cmd_add, + "remove": _cmd_remove, + "locate": _cmd_locate, + "build": _cmd_build, + "check": _cmd_check, + "status": _cmd_status, + } + if help_requested: + print(USAGE) + sys.exit(0) + handler = handlers.get(sub) + if handler is None: + print(USAGE, file=sys.stderr) + sys.exit(1) + try: + handler(argv[1:]) + except ClusterSpecError as exc: + _fail(str(exc)) diff --git a/graphify/cluster_graph.py b/graphify/cluster_graph.py new file mode 100644 index 000000000..a4682a809 --- /dev/null +++ b/graphify/cluster_graph.py @@ -0,0 +1,1350 @@ +"""Cluster graphs: link multiple repos' graphs into one connected graph. + +A *cluster* is a directory holding a ``cluster.json`` (or optional YAML spec) +spec that names member repos and declares the cross-repo contracts between +them (API calls, shared resources, mirrored files, dependencies). Building a +cluster composes each member's ``graphify-out/graph.json`` under a +``repo_tag::`` namespace (the same mechanism as merge-graphs and the global +graph) and then resolves the declared links into real edges — the piece the +other multi-repo commands don't do. + +Not to be confused with ``graphify/cluster.py`` (community detection). Docs +use "repo cluster" for this feature and "community detection" for that one. + +Portability: member identity is the git ``url``; local paths are resolved +per machine (``cluster.local.json`` override → spec ``path`` hint → origin- +remote auto-discovery under ``search_roots``), so one committed spec works +across machines with different checkout layouts. + +Output is a standard node-link ``graph.json`` under the cluster directory's +``graphify-out/``, so every existing command (query, path, explain, affected, +export) works on a cluster via ``cd `` or ``--graph``. +""" +from __future__ import annotations + +import json +import hashlib +import os +import re +import sys +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +import networkx as nx + +from .ids import normalize_id + +SPEC_NAMES = ("cluster.json", "cluster.yaml", "cluster.yml") +LOCAL_NAMES = ("cluster.local.json", "cluster.local.yaml", "cluster.local.yml") +SCHEMA_VERSION = 1 + +# Pseudo repo tag for synthetic hub nodes; reserved, never a member tag. +CLUSTER_TAG = "cluster" + +_TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + +# Direct (point-to-point) link types and the edge relation they produce. +DIRECT_LINK_RELATIONS = { + "api_call": "calls_api", + "mirrored_file": "mirrors", + "depends_on": "depends_on", + "references": "references", +} +LINK_TYPES = (*DIRECT_LINK_RELATIONS, "shared_resource") + +_ON_MISSING = ("warn", "create", "error") +_GRAPH_MODES = ("simple",) + + +class ClusterSpecError(ValueError): + """The cluster spec is invalid or a declared link cannot be applied.""" + + +class AmbiguousSelectorError(ClusterSpecError): + """A node selector matched more than one node.""" + + +@dataclass +class ClusterMember: + tag: str + url: str = "" + path: str = "" # optional local hint, may use ~; relative to cluster dir + graph: str = "" # optional graph.json override, relative to the repo + + +@dataclass +class ClusterLink: + type: str + name: str = "" + kind: str = "" # shared_resource only + from_: dict | None = None # selector {repo, file|label|id} + to: dict | None = None + referents: list[dict] = field(default_factory=list) # shared_resource only + on_missing: str = "" # per-link override of defaults + direction: str = "" # "" (from->to) or "both" + note: str = "" + + +@dataclass +class ClusterSpec: + name: str + members: list[ClusterMember] = field(default_factory=list) + links: list[ClusterLink] = field(default_factory=list) + on_missing: str = "warn" + auto_externals: bool = True + auto_packages: bool = False + graph_mode: str = "simple" + spec_path: Path | None = None + + def tags(self) -> set[str]: + return {m.tag for m in self.members} + + +@dataclass +class LinkReport: + edges_added: int = 0 + auto_package_edges: int = 0 + hubs_added: int = 0 + nodes_created: list[str] = field(default_factory=list) + resolved: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Spec load / save +# --------------------------------------------------------------------------- + +def _read_structured(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + if path.suffix == ".json": + data = json.loads(text) + else: + try: + import yaml + except ImportError: + raise ClusterSpecError( + f"{path.name} requires pyyaml (`pip install pyyaml`), " + f"or use the JSON spec form ({path.stem}.json)." + ) + data = yaml.safe_load(text) + if not isinstance(data, dict): + raise ClusterSpecError(f"{path}: expected a mapping at the top level") + return data + + +def find_spec_file(cluster_dir: Path) -> Path | None: + for name in SPEC_NAMES: + p = cluster_dir / name + if p.is_file(): + return p + return None + + +def _parse_selector(raw, *, where: str) -> dict: + if not isinstance(raw, dict) or "repo" not in raw: + raise ClusterSpecError(f"{where}: selector must be a mapping with a 'repo' key, got {raw!r}") + keys = [k for k in ("id", "file", "label") if raw.get(k)] + if len(keys) != 1: + raise ClusterSpecError( + f"{where}: selector needs exactly one of id/file/label, got {sorted(raw)}" + ) + return {"repo": str(raw["repo"]), keys[0]: str(raw[keys[0]])} + + +def validate_member_tag(tag: str, *, where: str = "cluster spec") -> None: + """Validate a member tag before it is persisted or used as a namespace.""" + if not _TAG_RE.match(tag): + raise ClusterSpecError( + f"{where}: member tag {tag!r} is invalid " + f"(letters/digits/._- only, must not contain '::')" + ) + if tag == CLUSTER_TAG: + raise ClusterSpecError( + f"{where}: member tag '{CLUSTER_TAG}' is reserved for synthetic hub nodes" + ) + + +def load_spec(cluster_dir: Path) -> ClusterSpec: + cluster_dir = Path(cluster_dir) + spec_path = find_spec_file(cluster_dir) + if spec_path is None: + raise ClusterSpecError( + f"no cluster spec found in {cluster_dir} " + f"(expected one of: {', '.join(SPEC_NAMES)}). Run `graphify cluster init` first." + ) + data = _read_structured(spec_path) + + version = data.get("schema_version", SCHEMA_VERSION) + if version != SCHEMA_VERSION: + raise ClusterSpecError( + f"{spec_path.name}: schema_version {version} is not supported " + f"(this graphify understands version {SCHEMA_VERSION})" + ) + + members: list[ClusterMember] = [] + seen_tags: set[str] = set() + for i, m in enumerate(data.get("members") or []): + if not isinstance(m, dict) or not m.get("tag"): + raise ClusterSpecError(f"{spec_path.name}: members[{i}] needs a 'tag'") + tag = str(m["tag"]) + validate_member_tag(tag, where=spec_path.name) + if tag in seen_tags: + raise ClusterSpecError(f"{spec_path.name}: duplicate member tag {tag!r}") + seen_tags.add(tag) + members.append(ClusterMember( + tag=tag, + url=str(m.get("url") or ""), + path=str(m.get("path") or ""), + graph=str(m.get("graph") or ""), + )) + + defaults = data.get("defaults") or {} + on_missing = str(defaults.get("on_missing") or "warn") + if on_missing not in _ON_MISSING: + raise ClusterSpecError( + f"{spec_path.name}: defaults.on_missing must be one of {_ON_MISSING}, got {on_missing!r}" + ) + + links: list[ClusterLink] = [] + for i, raw in enumerate(data.get("links") or []): + where = f"{spec_path.name}: links[{i}]" + if not isinstance(raw, dict) or not raw.get("type"): + raise ClusterSpecError(f"{where} needs a 'type'") + ltype = str(raw["type"]) + if ltype not in LINK_TYPES: + raise ClusterSpecError(f"{where}: unknown type {ltype!r} (known: {', '.join(LINK_TYPES)})") + link_missing = str(raw.get("on_missing") or "") + if link_missing and link_missing not in _ON_MISSING: + raise ClusterSpecError(f"{where}: on_missing must be one of {_ON_MISSING}") + link_direction = str(raw.get("direction") or "") + if link_direction and link_direction != "both": + raise ClusterSpecError( + f"{where}: direction must be \"both\" or omitted (default: " + f"from -> to), got {link_direction!r}" + ) + link = ClusterLink( + type=ltype, + name=str(raw.get("name") or ""), + kind=str(raw.get("kind") or ""), + on_missing=link_missing, + direction=link_direction, + note=str(raw.get("note") or ""), + ) + if ltype == "shared_resource": + if not link.name: + raise ClusterSpecError(f"{where}: shared_resource needs a 'name'") + referents = raw.get("referents") or [] + if not isinstance(referents, list) or not referents: + raise ClusterSpecError(f"{where}: shared_resource needs a non-empty 'referents' list") + link.referents = [ + _parse_selector(r, where=f"{where}.referents[{j}]") for j, r in enumerate(referents) + ] + else: + link.from_ = _parse_selector(raw.get("from"), where=f"{where}.from") + link.to = _parse_selector(raw.get("to"), where=f"{where}.to") + for sel in ([link.from_, link.to] if link.from_ else []) + link.referents: + if sel and sel["repo"] not in seen_tags: + raise ClusterSpecError( + f"{where}: selector references unknown member {sel['repo']!r}" + ) + links.append(link) + + auto = data.get("auto_links") or {} + graph_mode = str(data.get("graph_mode") or "simple") + if graph_mode not in _GRAPH_MODES: + raise ClusterSpecError( + f"{spec_path.name}: graph_mode must be one of {_GRAPH_MODES}, " + f"got {graph_mode!r}" + ) + return ClusterSpec( + name=str(data.get("name") or cluster_dir.name), + members=members, + links=links, + on_missing=on_missing, + auto_externals=bool(auto.get("externals", True)), + auto_packages=bool(auto.get("packages", False)), + graph_mode=graph_mode, + spec_path=spec_path, + ) + + +def spec_to_dict(spec: ClusterSpec) -> dict: + """Serializable form of the spec (used by init/add/remove).""" + members = [] + for m in spec.members: + entry: dict = {"tag": m.tag} + if m.url: + entry["url"] = m.url + if m.path: + entry["path"] = m.path + if m.graph: + entry["graph"] = m.graph + members.append(entry) + links = [] + for l in spec.links: + entry = {"type": l.type} + for key, val in ( + ("name", l.name), ("kind", l.kind), ("from", l.from_), ("to", l.to), + ("referents", l.referents or None), ("on_missing", l.on_missing), + ("direction", l.direction), ("note", l.note), + ): + if val: + entry[key] = val + links.append(entry) + return { + "schema_version": SCHEMA_VERSION, + "name": spec.name, + "graph_mode": spec.graph_mode, + "members": members, + "links": links, + "defaults": {"on_missing": spec.on_missing}, + "auto_links": {"externals": spec.auto_externals, "packages": spec.auto_packages}, + } + + +def save_spec(spec: ClusterSpec, cluster_dir: Path) -> Path: + """Write the spec back, preserving existing YAML but creating JSON.""" + from .paths import write_text_atomic + + cluster_dir = Path(cluster_dir) + target = spec.spec_path or find_spec_file(cluster_dir) + data = spec_to_dict(spec) + if target is None: + target = cluster_dir / "cluster.json" + if target.suffix == ".json": + write_text_atomic(target, json.dumps(data, indent=2) + "\n") + else: + import yaml + write_text_atomic(target, yaml.safe_dump(data, sort_keys=False, default_flow_style=False)) + spec.spec_path = target + return target + + +def load_local_config(cluster_dir: Path) -> dict: + """Machine-local overrides: {"paths": {tag: path}, "search_roots": [...]}""" + for name in LOCAL_NAMES: + p = Path(cluster_dir) / name + if p.is_file(): + data = _read_structured(p) + data["_path"] = p + return data + return {} + + +def save_local_config(cluster_dir: Path, cfg: dict) -> Path: + from .paths import write_text_atomic + + cfg = {k: v for k, v in cfg.items() if not k.startswith("_")} + target = None + for name in LOCAL_NAMES: + p = Path(cluster_dir) / name + if p.is_file(): + target = p + break + if target is None: + target = Path(cluster_dir) / "cluster.local.json" + if target.suffix == ".json": + write_text_atomic(target, json.dumps(cfg, indent=2) + "\n") + else: + import yaml + write_text_atomic(target, yaml.safe_dump(cfg, sort_keys=False, default_flow_style=False)) + return target + + +# --------------------------------------------------------------------------- +# Member path resolution (URL is identity, path is machine-local) +# --------------------------------------------------------------------------- + +def normalize_git_url(url: str) -> str: + """Canonical `host/org/repo` form so https/ssh/.git variants compare equal.""" + u = url.strip() + if not u: + return "" + u = re.sub(r"\.git/?$", "", u) + m = re.match(r"^(?:ssh://)?(?:[\w.-]+@)?([\w.-]+)[:/](.+)$", u) if "://" not in u or u.startswith("ssh://") else None + if m: + return f"{m.group(1)}/{m.group(2)}".casefold().rstrip("/") + m = re.match(r"^[a-z][a-z0-9+.-]*://(?:[\w.-]+@)?([\w.-]+)/(.+)$", u, re.I) + if m: + return f"{m.group(1)}/{m.group(2)}".casefold().rstrip("/") + return u.casefold().rstrip("/") + + +def origin_url(repo_dir: Path) -> str | None: + """The `origin` remote URL of a checkout, read from .git/config (no subprocess). + + Handles worktree-style `.git` files via their `gitdir:` pointer. + """ + git = Path(repo_dir) / ".git" + if git.is_file(): + try: + pointer = git.read_text(encoding="utf-8").strip() + except OSError: + return None + if not pointer.startswith("gitdir:"): + return None + gitdir = Path(pointer.split(":", 1)[1].strip()) + if not gitdir.is_absolute(): + gitdir = (repo_dir / gitdir).resolve() + common = gitdir / "commondir" + if common.is_file(): + rel = common.read_text(encoding="utf-8").strip() + gitdir = (gitdir / rel).resolve() + config = gitdir / "config" + else: + config = git / "config" + if not config.is_file(): + return None + in_origin = False + try: + lines = config.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return None + for line in lines: + s = line.strip() + if s.startswith("["): + in_origin = s.replace("'", '"') == '[remote "origin"]' + elif in_origin: + key, sep, value = s.partition("=") + if sep and key.strip() == "url": + return value.strip() + return None + + +def _expand(path_str: str, cluster_dir: Path) -> Path: + p = Path(path_str).expanduser() + if not p.is_absolute(): + p = Path(cluster_dir) / p + # normpath (not resolve) collapses `../` segments without dereferencing + # symlinks — a member checked out behind a symlink should keep the path + # the user wrote. + return Path(os.path.normpath(p)) + + +def resolve_member_path( + member: ClusterMember, cluster_dir: Path, local_cfg: dict +) -> tuple[Path | None, list[str]]: + """Resolve a member to a local checkout. Returns (path or None, warnings). + + Order: cluster.local.* override → spec `path` hint → auto-discovery by + matching each candidate dir's origin remote against the member `url`. + Whenever a path resolves and both URLs are known, a mismatch is a warning + (guards against same-named dirs pointing at a different repo). + """ + warnings: list[str] = [] + want = normalize_git_url(member.url) + + def _check(path: Path, source: str) -> Path: + if want: + found = origin_url(path) + if found and normalize_git_url(found) != want: + warnings.append( + f"{member.tag}: {source} {path} has origin {found!r}, " + f"but the spec declares {member.url!r}" + ) + return path + + override = (local_cfg.get("paths") or {}).get(member.tag) + if override: + p = _expand(str(override), cluster_dir) + if p.is_dir(): + return _check(p, "local override"), warnings + warnings.append(f"{member.tag}: local override path does not exist: {p}") + + if member.path: + p = _expand(member.path, cluster_dir) + if p.is_dir(): + return _check(p, "spec path"), warnings + + if want: + roots = [_expand(str(r), cluster_dir) for r in (local_cfg.get("search_roots") or [])] + roots.append(Path(cluster_dir).resolve().parent) + seen: set[Path] = set() + for root in roots: + if root in seen or not root.is_dir(): + continue + seen.add(root) + try: + children = sorted(c for c in root.iterdir() if c.is_dir()) + except OSError: + continue + for child in children: + found = origin_url(child) + if found and normalize_git_url(found) == want: + return child, warnings + + return None, warnings + + +def member_graph_path(member: ClusterMember, repo_dir: Path) -> Path: + from .paths import GRAPHIFY_OUT_NAME + + rel = member.graph or f"{GRAPHIFY_OUT_NAME}/graph.json" + return Path(repo_dir) / rel + + +# --------------------------------------------------------------------------- +# Compose + link +# --------------------------------------------------------------------------- + +def compose_members( + spec: ClusterSpec, resolved: dict[str, Path] +) -> tuple[nx.Graph, dict[str, dict]]: + """Union all member graphs under repo_tag:: namespaces. + + ``resolved`` maps member tag -> repo checkout dir. Returns the composed + graph and per-member stats. Externals dedup-by-label follows + ``spec.auto_externals``. + """ + from .build import ( + load_graph_json, + merge_prefixed_into, + prefix_graph_for_global, + ) + + # Composed directed: the composed graph is re-serialized, and an + # undirected round-trip re-emits edge endpoints by node insertion order, + # silently flipping caller/callee (#760). Members load directed so their + # stored source/target order is what the cluster graph.json persists. + G: nx.Graph = nx.DiGraph() + stats: dict[str, dict] = {} + cid_base = 0 + for member in spec.members: + gp = member_graph_path(member, resolved[member.tag]) + if not gp.is_file(): + raise ClusterSpecError( + f"member '{member.tag}' has no graph at {gp}. " + f"Run `graphify extract .` (or your usual build) in {resolved[member.tag]} first." + ) + try: + member_graph = load_graph_json(gp, directed=True) + except ValueError as exc: # JSONDecodeError and the size cap + raise ClusterSpecError( + f"member '{member.tag}' has an unreadable graph at {gp} ({exc}). " + f"Re-run `graphify extract . --force` in {resolved[member.tag]} to rebuild it." + ) from exc + prefixed = prefix_graph_for_global(member_graph, member.tag) + cid_base = _renumber_member_communities(prefixed, cid_base) + total = prefixed.number_of_nodes() + if spec.auto_externals: + added = merge_prefixed_into(G, prefixed) + else: + G = nx.compose(G, prefixed) + added = total + stats[member.tag] = { + "graph_path": str(gp), + "node_count": total, + "edge_count": prefixed.number_of_edges(), + "externals_merged": total - added, + } + return G, stats + + +def _renumber_member_communities(H: "nx.Graph", next_cid: int) -> int: + """Remap one member's community ids onto a cluster-global range, in place. + + Every member numbers its communities from 0, so composing them verbatim + merges unrelated "community 0" groups across repos in every consumer that + groups by the integer (MCP get_community, NODE lines, explain). Ids are + assigned in node order (deterministic: node_link_graph preserves the + member graph.json's order). Placeholder names ("Community N") track the + new id; real LLM-assigned names are preserved. Returns the next free id. + """ + mapping: dict = {} + for _, data in H.nodes(data=True): + cid = data.get("community") + if cid is None: + continue + if cid not in mapping: + mapping[cid] = next_cid + next_cid += 1 + if data.get("community_name") == f"Community {cid}": + data["community_name"] = f"Community {mapping[cid]}" + data["community"] = mapping[cid] + return next_cid + + +def _selector_str(sel: dict) -> str: + key = next(k for k in ("id", "file", "label") if k in sel) + return f"{sel['repo']}:{key}={sel[key]}" + + +def _norm_source_file(sf: str) -> str: + # removeprefix, not lstrip: lstrip("./") strips *characters*, eating the + # dot off ".env" / ".github/..." and aliasing them onto unrelated paths. + p = PurePosixPath(sf.replace("\\", "/")).as_posix() + return p.removeprefix("./").lstrip("/") + + +def resolve_selector( + nodes_by_repo: dict[str, list[tuple[str, dict]]], sel: dict +) -> str | None: + """Resolve a spec selector to a composed-graph node id, or None. + + Selectors never reference raw prefixed ids, so users are insulated from + ID normalization: `id` matches the member-local id, `file` suffix-matches + source_file (preferring the file node when a file contains many symbols), + `label` matches exactly and then case/punctuation-insensitively. + """ + candidates = nodes_by_repo.get(sel["repo"], []) + + if "id" in sel: + want = sel["id"] + matches = [n for n, d in candidates if d.get("local_id") == want] + if not matches: + want_n = normalize_id(want) + matches = [n for n, d in candidates if d.get("local_id") == want_n] + elif "file" in sel: + rel = _norm_source_file(sel["file"]) + matches = [ + n for n, d in candidates + if d.get("source_file") + and (_norm_source_file(d["source_file"]) == rel + or _norm_source_file(d["source_file"]).endswith("/" + rel)) + ] + if len(matches) > 1: + # Prefer the file-level node. Two signals, strongest first: + # 1. The file-node ID spec (#1504): the node's local_id equals + # normalize_id() — deterministic and holds + # for both AST and LLM extractions regardless of labeling. + # 2. Label == the file's basename (AST file nodes; LLM graphs may + # relabel file nodes descriptively, so this is the fallback). + by_id = dict(candidates) + stem = rel.rsplit(".", 1)[0] if "." in rel.rsplit("/", 1)[-1] else rel + spec_ids = {normalize_id(stem)} + # A shorter selector path (suffix match) can't reproduce the full + # repo-relative id, so also accept any matched node whose own + # source_file round-trips to its local_id. + for n in matches: + sf = _norm_source_file(by_id[n].get("source_file", "")) + base = sf.rsplit("/", 1)[-1] + sf_stem = sf.rsplit(".", 1)[0] if "." in base else sf + if by_id[n].get("local_id") == normalize_id(sf_stem): + spec_ids.add(by_id[n]["local_id"]) + id_nodes = [n for n in matches if by_id[n].get("local_id") in spec_ids] + if len(id_nodes) == 1: + matches = id_nodes + else: + def _label_is_basename(n: str) -> bool: + label = by_id[n].get("label") or "" + sf = _norm_source_file(by_id[n].get("source_file", "")) + return bool(label) and (sf == label or sf.endswith("/" + label)) + + file_nodes = [n for n in matches if _label_is_basename(n)] + if len(file_nodes) == 1: + matches = file_nodes + else: + want = sel["label"] + want_n = normalize_id(want) + matches = [n for n, d in candidates if d.get("label") == want] + if not matches: + matches = [n for n, d in candidates if normalize_id(d.get("label") or "") == want_n] + if not matches: + # External-library nodes dedupe by label onto the FIRST member that + # references them, keeping that member's `repo` attr — a selector + # naming any other referencing member would silently break when the + # spec's member order changes. Externals are cluster-wide by + # construction, so label selectors fall back to matching them + # regardless of repo attribution. + externals = [ + (n, d) + for bucket in nodes_by_repo.values() + for n, d in bucket + if not d.get("source_file") and d.get("label") + ] + matches = [n for n, d in externals if d["label"] == want] + if not matches: + matches = [n for n, d in externals if normalize_id(d["label"]) == want_n] + if matches: + candidates = externals # keep the ambiguity listing resolvable + + if not matches: + return None + if len(matches) > 1: + by_id = dict(candidates) + listing = ", ".join( + f"{n} ({by_id[n].get('source_file', '?')})" for n in sorted(matches)[:8] + ) + raise AmbiguousSelectorError( + f"selector {_selector_str(sel)} matches {len(matches)} nodes: {listing}" + + (" …" if len(matches) > 8 else "") + ) + return matches[0] + + +def _hub_id(kind: str, name: str) -> str: + return f"{CLUSTER_TAG}::{normalize_id(kind or 'resource')}_{normalize_id(name)}" + + +def apply_spec_links(G: nx.Graph, spec: ClusterSpec, *, dry_run: bool = False) -> LinkReport: + """Turn declared links into edges (and hub/concept nodes) on the composed graph.""" + report = LinkReport() + spec_file = spec.spec_path.name if spec.spec_path else "cluster.json" + + nodes_by_repo: dict[str, list[tuple[str, dict]]] = {} + for n, d in G.nodes(data=True): + nodes_by_repo.setdefault(d.get("repo", ""), []).append((n, d)) + + # Simple mode rejects occupied pairs to prevent NetworkX overwrites. Multi + # mode permits distinct keyed relations and rejects only an exact duplicate + # declared-link identity. + occupied_pairs: dict[tuple[str, str], str] = {} + for u, v, data in G.edges(data=True): + pair = (min(u, v), max(u, v)) + relation = data.get("relation") or "unknown" + occupied_pairs[pair] = f"existing relation {relation!r}" + + def _resolve(link: ClusterLink, sel: dict, link_label: str) -> str | None: + try: + node = resolve_selector(nodes_by_repo, sel) + except AmbiguousSelectorError as exc: + report.errors.append(f"{link_label}: {exc}") + return None + if node is not None: + return node + mode = link.on_missing or spec.on_missing + desc = _selector_str(sel) + if mode == "error": + report.errors.append(f"{link_label}: no node matches {desc}") + elif mode == "create": + key = next(k for k in ("id", "file", "label") if k in sel) + concept = f"{sel['repo']}::concept_{normalize_id(sel[key])}" + if not dry_run and concept not in G: + G.add_node( + concept, + label=sel[key], + file_type="concept", + source_file="", + repo=sel["repo"], + local_id=concept.split("::", 1)[1], + origin="cluster_spec", + ) + nodes_by_repo.setdefault(sel["repo"], []).append((concept, G.nodes[concept])) + report.nodes_created.append(concept) + return concept + else: + report.warnings.append(f"{link_label}: no node matches {desc}; link skipped") + return None + + def _add_edge( + u: str, v: str, link: ClusterLink, relation: str, link_label: str + ) -> bool: + if u == v: + report.warnings.append(f"link '{link.name or link.type}' resolved to a self-loop; skipped") + return False + pair = (min(u, v), max(u, v)) + prior = occupied_pairs.get(pair) + if prior is not None: + report.errors.append( + f"{link_label}: cannot add relation {relation!r} between {u} and {v}; " + f"the pair already has {prior}. Simple cluster graphs allow only " + f"one relation per node pair" + ) + return False + occupied_pairs[pair] = f"{link_label} relation {relation!r}" + # direction: "both" materializes as a real reverse edge — traversal + # (affected's in_edges, query BFS) reads topology, not attrs, so a + # metadata-only flag would silently traverse one way. The declared + # link owns the unordered pair: its own reverse edge is exempt from + # the one-relation-per-pair guard, a separate reverse link is not. + endpoint_pairs = [(u, v)] + ([(v, u)] if link.direction == "both" else []) + if not dry_run: + for src, tgt in endpoint_pairs: + attrs = { + "relation": relation, + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "weight": 1.0, + "source_file": spec_file, + "origin": "cluster_spec", + "_src": src, + "_tgt": tgt, + } + if link.name: + attrs["link_name"] = link.name + if link.direction == "both": + attrs["direction"] = "both" + if link.note: + attrs["note"] = link.note + G.add_edge(src, tgt, **attrs) + report.edges_added += len(endpoint_pairs) + return True + + for i, link in enumerate(spec.links): + link_label = f"links[{i}] ({link.name or link.type})" + if link.type == "shared_resource": + hub = _hub_id(link.kind, link.name) + resolved = [ + node for sel in link.referents + if (node := _resolve(link, sel, link_label)) is not None + ] + if not resolved: + report.warnings.append(f"{link_label}: no referents resolved; hub not created") + continue + if not dry_run and hub not in G: + G.add_node( + hub, + label=link.name, + file_type="concept", + source_file="", + repo=CLUSTER_TAG, + local_id=hub.split("::", 1)[1], + resource_kind=link.kind or "resource", + origin="cluster_spec", + ) + report.hubs_added += 1 + for node in resolved: + _add_edge(node, hub, link, "uses", link_label) + report.resolved.append( + f"{link_label}: hub {hub} <- {len(resolved)}/{len(link.referents)} referents" + ) + else: + assert link.from_ is not None and link.to is not None # enforced by load_spec + src = _resolve(link, link.from_, link_label) + tgt = _resolve(link, link.to, link_label) + if src is None or tgt is None: + continue + relation = DIRECT_LINK_RELATIONS[link.type] + if _add_edge(src, tgt, link, relation, link_label): + report.resolved.append(f"{link_label}: {src} -[{relation}]-> {tgt}") + + return report + + +def apply_auto_package_links( + G: nx.Graph, + spec: ClusterSpec, + report: LinkReport | None = None, + *, + dry_run: bool = False, +) -> LinkReport: + """Link package definitions to unique providers in other member repos.""" + report = report or LinkReport() + package_nodes = sorted( + ( + (node, data) + for node, data in G.nodes(data=True) + if data.get("type") == "package" + ), + key=lambda item: str(item[0]), + ) + providers: dict[str, list[tuple[str, dict]]] = {} + stale_repos: set[str] = set() + for node, data in package_nodes: + key = data.get("package_key") + if isinstance(key, str) and key: + providers.setdefault(key, []).append((node, data)) + if "dependency_keys" not in data: + stale_repos.add(str(data.get("repo") or "unknown")) + + for repo in sorted(stale_repos): + report.warnings.append( + f"member '{repo}' has package nodes without dependency metadata; " + "re-run `graphify extract --force` in that member to enable " + "auto_links.packages" + ) + + occupied = { + (min(str(u), str(v)), max(str(u), str(v))) for u, v in G.edges() + } + spec_file = spec.spec_path.name if spec.spec_path else "cluster.json" + for source, data in package_nodes: + source_repo = data.get("repo") + dep_keys = data.get("dependency_keys") + if not isinstance(dep_keys, list): + continue + for dep_key in sorted({str(key) for key in dep_keys if key}): + candidates = [ + (node, provider) + for node, provider in providers.get(dep_key, []) + if provider.get("repo") != source_repo + ] + if not candidates: + continue + if len(candidates) > 1: + listing = ", ".join(str(node) for node, _data in candidates) + report.warnings.append( + f"package dependency {dep_key!r} from {source} has " + f"{len(candidates)} cross-repo providers ({listing}); skipped" + ) + continue + target, _provider = candidates[0] + pair = (min(str(source), str(target)), max(str(source), str(target))) + if pair in occupied: + report.warnings.append( + f"package dependency {dep_key!r} from {source} is already " + "connected by a member or declared link; automatic link skipped" + ) + continue + occupied.add(pair) + if not dry_run: + attrs = { + "relation": "depends_on", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "weight": 1.0, + "source_file": spec_file, + "origin": "cluster_auto_package", + "package_key": dep_key, + "_src": source, + "_tgt": target, + } + G.add_edge(source, target, **attrs) + report.edges_added += 1 + report.auto_package_edges += 1 + report.resolved.append( + f"auto package: {source} -[depends_on]-> {target} ({dep_key})" + ) + return report + + +# --------------------------------------------------------------------------- +# Build orchestration +# --------------------------------------------------------------------------- + +def _file_hash(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest()[:16] + + +def cluster_out_dir(cluster_dir: Path) -> Path: + from .paths import GRAPHIFY_OUT + + out = Path(GRAPHIFY_OUT) + return out if os.path.isabs(GRAPHIFY_OUT) else Path(cluster_dir) / out + + +def _manifest_path(out_dir: Path) -> Path: + return out_dir / "cluster-manifest.json" + + +def resolve_all_members( + spec: ClusterSpec, cluster_dir: Path, local_cfg: dict +) -> tuple[dict[str, Path], list[str], list[str]]: + """Resolve every member. Returns (tag -> dir, warnings, errors).""" + resolved: dict[str, Path] = {} + warnings: list[str] = [] + errors: list[str] = [] + for member in spec.members: + path, w = resolve_member_path(member, cluster_dir, local_cfg) + warnings.extend(w) + if path is None: + hint = f"graphify cluster locate {member.tag} /path/to/checkout" + errors.append( + f"member '{member.tag}' could not be resolved to a local checkout" + + (f" (url: {member.url})" if member.url else "") + + f". Fix: {hint}" + ) + else: + resolved[member.tag] = path + return resolved, warnings, errors + + +def _render_report(spec: ClusterSpec, stats: dict, report: LinkReport, built_at: str) -> str: + lines = [ + f"# Cluster report: {spec.name}", + "", + f"Built: {built_at}", + "", + "## Members", + "", + "| tag | nodes | edges | externals merged |", + "|---|---|---|---|", + ] + for tag, s in stats.items(): + lines.append(f"| {tag} | {s['node_count']} | {s['edge_count']} | {s['externals_merged']} |") + lines += [ + "", + "## Links", + "", + f"- edges added: {report.edges_added}", + f"- automatic package edges: {report.auto_package_edges}", + f"- shared-resource hubs: {report.hubs_added}", + ] + if report.resolved: + lines += [""] + [f"- {r}" for r in report.resolved] + if report.nodes_created: + lines += ["", "### Created concept nodes (on_missing: create)", ""] + lines += [f"- {n}" for n in report.nodes_created] + if report.warnings: + lines += ["", "### Warnings", ""] + [f"- {w}" for w in report.warnings] + if report.errors: + lines += ["", "### Errors", ""] + [f"- {e}" for e in report.errors] + return "\n".join(lines) + "\n" + + +def check_member_ref_conflicts( + spec: ClusterSpec, resolved: dict[str, Path], cluster_dir: Path +) -> None: + """Raise when a member's marker shows a *different* cluster owns this name. + + A git-URL mismatch proves a genuine collision (two distinct remotes, same + cluster name). For URL-less clusters, identity is best-effort: an existing + ref that carries a URL always owns the name, and two URL-less clusters + collide when the old ref's ``dir_hint`` resolves to a different existing + cluster directory bearing the same name. A stale/unresolvable ``dir_hint`` + alone never errors — hints are machine- and layout-dependent, and a moved + cluster directory is the common benign cause (write_member_refs warns and + refreshes the hint). Runs in build_cluster BEFORE any output is written, + on every build path including the unchanged-inputs skip, so a real + conflict fails cleanly and keeps failing until resolved. + """ + from .cluster_ref import load_cluster_refs + from .paths import GRAPHIFY_OUT_NAME + + new_url = normalize_git_url(origin_url(cluster_dir) or "") + for member in spec.members: + repo_dir = resolved.get(member.tag) + if repo_dir is None: + continue + refs = load_cluster_refs(Path(repo_dir) / GRAPHIFY_OUT_NAME) + old = next((r for r in refs if r["cluster_name"] == spec.name), None) + if old is None: + continue + old_url = normalize_git_url(str(old.get("cluster_url") or "")) + if new_url: + if old_url and old_url != new_url: + raise ClusterSpecError( + f"member '{member.tag}' already belongs to a different cluster " + f"named '{spec.name}' ({old.get('cluster_url')}); cluster " + f"names must be unique per member" + ) + continue + if old_url: + raise ClusterSpecError( + f"member '{member.tag}' already belongs to a cluster named " + f"'{spec.name}' tracked at {old.get('cluster_url')}, and this " + f"cluster directory has no origin remote to prove it is the same " + f"one. Rename this cluster, or add the matching remote." + ) + hint = str(old.get("dir_hint") or "") + if hint: + candidate = Path(os.path.normpath(Path(repo_dir) / hint)) + try: + is_other = ( + find_spec_file(candidate) is not None + and load_spec(candidate).name == spec.name + and candidate.resolve() != Path(cluster_dir).resolve() + ) + except Exception: + is_other = False + if is_other: + raise ClusterSpecError( + f"member '{member.tag}' already belongs to a cluster named " + f"'{spec.name}' at {candidate}; cluster names must be unique " + f"per member. Rename one of the clusters." + ) + + +def write_member_refs( + spec: ClusterSpec, + resolved: dict[str, Path], + cluster_dir: Path, + built_at: str, + *, + only_missing: bool = False, +) -> int: + """Upsert this cluster in each member's portable cluster-ref collection. + + The marker is committable (graphify-out/ travels with the member repo), so + it carries no absolute paths — only the cluster's git URL, the member + roster, and a machine-derived relative ``dir_hint`` that fails soft on + other machines. ``only_missing`` (the skipped-rebuild path) backfills + memberships for freshly cloned members without churning existing entries. + A member whose ``graph`` field points outside graphify-out/ still gets its + marker in graphify-out/ — that is where member-side readers look. + Failures are warnings, never build errors; name collisions with a + *different* cluster are ``check_member_ref_conflicts``'s job, which + ``build_cluster`` runs before writing any output. Returns the count + written. + """ + from .cluster_ref import ( + CLUSTER_REF_NAME, + CLUSTER_REF_VERSION, + load_cluster_refs, + ) + from .paths import GRAPHIFY_OUT_NAME, write_json_atomic + + roster = [{"tag": m.tag, "url": m.url} for m in spec.members] + cluster_url = origin_url(cluster_dir) or "" + pending: list[tuple[ClusterMember, Path, Path, list[dict], dict]] = [] + for member in spec.members: + repo_dir = resolved.get(member.tag) + if repo_dir is None: + continue + out_dir = Path(repo_dir) / GRAPHIFY_OUT_NAME + target = out_dir / CLUSTER_REF_NAME + existing = load_cluster_refs(out_dir) + old = next((r for r in existing if r["cluster_name"] == spec.name), None) + if only_missing and old is not None: + continue + try: + dir_hint = os.path.relpath(Path(cluster_dir).resolve(), Path(repo_dir).resolve()) + except ValueError: # Windows cross-drive + dir_hint = "" + ref = { + "cluster_name": spec.name, + "cluster_url": cluster_url, + "self_tag": member.tag, + "member_count": len(spec.members), + "members": roster, + "built_at": built_at, + "dir_hint": dir_hint, + } + if old is not None: + old_url = normalize_git_url(str(old.get("cluster_url") or "")) + new_url = normalize_git_url(cluster_url) + same_by_url = bool(old_url and new_url and old_url == new_url) + if ( + not same_by_url + and old.get("dir_hint") and dir_hint + and os.path.normpath(str(old["dir_hint"])) != os.path.normpath(dir_hint) + ): + # A hint mismatch alone can't distinguish a moved cluster (or a + # different checkout layout) from a same-named other cluster, so + # it never blocks the build — genuine collisions are caught by + # URL in check_member_ref_conflicts before any output is + # written. Warn and refresh the entry: last build wins. + print( + f"[graphify cluster] warning: member '{member.tag}' marker " + f"for cluster '{spec.name}' pointed at {old['dir_hint']}; " + f"updating it to this cluster's location", + file=sys.stderr, + ) + pending.append((member, out_dir, target, existing, ref)) + + written = 0 + for member, out_dir, target, existing, ref in pending: + try: + out_dir.mkdir(parents=True, exist_ok=True) + refs = [r for r in existing if r["cluster_name"] != spec.name] + refs.append(ref) + refs.sort(key=lambda r: r["cluster_name"]) + write_json_atomic( + target, + {"version": CLUSTER_REF_VERSION, "clusters": refs}, + indent=2, + ) + written += 1 + except OSError as exc: + print( + f"[graphify cluster] warning: could not write {CLUSTER_REF_NAME} " + f"for member '{member.tag}' ({exc}); build continues", + file=sys.stderr, + ) + return written + + +def _check_self_composition( + spec: ClusterSpec, resolved: dict[str, Path], cluster_dir: Path +) -> None: + """Refuse a cluster that lists itself (or its own output) as a member. + + Composing reads each member's graphify-out/graph.json and writes the + cluster's own graphify-out/graph.json; if those coincide, the build + consumes its own prior output and overwrites it, and the next build + re-prefixes already-prefixed ids (``a::a::x``), snowballing. + """ + own_dir = Path(cluster_dir).resolve() + own_graph = (cluster_out_dir(cluster_dir) / "graph.json").resolve() + for member in spec.members: + repo = resolved.get(member.tag) + if repo is None: + continue + if ( + Path(repo).resolve() == own_dir + or member_graph_path(member, repo).resolve() == own_graph + ): + raise ClusterSpecError( + f"member '{member.tag}' resolves to the cluster directory itself " + f"({repo}); a cluster cannot compose its own output. Remove it with " + f"`graphify cluster remove {member.tag}`, or point it at the real " + f"checkout with `graphify cluster locate {member.tag} `." + ) + + +def build_cluster( + cluster_dir: Path, *, force: bool = False, no_links: bool = False, write_refs: bool = True +) -> dict: + """Compose member graphs and resolve links; write graph.json + manifest + report. + + Also writes a cluster-ref.json back-reference into each member's + graphify-out/ (see write_member_refs) unless ``write_refs`` is False. + Returns a summary dict: {name, nodes, edges, members, links: LinkReport, + skipped, refs_written, out}. + """ + from networkx.readwrite import json_graph as _jg + from .paths import write_json_atomic, write_text_atomic + + cluster_dir = Path(cluster_dir) + spec = load_spec(cluster_dir) + if not spec.members: + raise ClusterSpecError( + "cluster has no members; add repos with `graphify cluster add ` " + "(an empty build would write an empty graph.json)" + ) + local_cfg = load_local_config(cluster_dir) + resolved, warnings, errors = resolve_all_members(spec, cluster_dir, local_cfg) + for w in warnings: + print(f"[graphify cluster] warning: {w}", file=sys.stderr) + if errors: + raise ClusterSpecError("; ".join(errors)) + _check_self_composition(spec, resolved, cluster_dir) + + if write_refs: + check_member_ref_conflicts(spec, resolved, cluster_dir) + + out_dir = cluster_out_dir(cluster_dir) + graph_path = out_dir / "graph.json" + manifest_path = _manifest_path(out_dir) + + spec_hash = _file_hash(spec.spec_path) if spec.spec_path else "" + links_enabled = not no_links + member_hashes = {} + for member in spec.members: + gp = member_graph_path(member, resolved[member.tag]) + member_hashes[member.tag] = _file_hash(gp) if gp.is_file() else "" + + if not force and graph_path.is_file() and manifest_path.is_file(): + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + manifest = {} + prior = {t: m.get("source_hash", "") for t, m in (manifest.get("members") or {}).items()} + if ( + manifest.get("spec_hash") == spec_hash + and prior == member_hashes + and manifest.get("links_enabled") == links_enabled + and manifest.get("graph_mode") == spec.graph_mode + ): + # Backfill markers for members that don't have one yet (e.g. a + # freshly cloned checkout) without churning existing files. + refs = 0 + if write_refs: + refs = write_member_refs( + spec, resolved, cluster_dir, + manifest.get("built_at", ""), only_missing=True, + ) + return { + "name": spec.name, + "skipped": True, + "out": str(graph_path), + "nodes": manifest.get("node_count", 0), + "edges": manifest.get("edge_count", 0), + "refs_written": refs, + } + + G, stats = compose_members(spec, resolved) + report = LinkReport() if no_links else apply_spec_links(G, spec) + if not no_links and spec.auto_packages: + apply_auto_package_links(G, spec, report) + if report.errors: + raise ClusterSpecError("link resolution failed: " + "; ".join(report.errors)) + for w in report.warnings: + print(f"[graphify cluster] warning: {w}", file=sys.stderr) + + out_dir.mkdir(parents=True, exist_ok=True) + try: + data = _jg.node_link_data(G, edges="links") + except TypeError: + data = _jg.node_link_data(G) + # The graph is composed directed, so source/target already carry the true + # direction; drop the _src/_tgt persistence markers like export.to_json. + for link in data.get("links", data.get("edges", [])): + link.pop("_src", None) + link.pop("_tgt", None) + write_json_atomic(graph_path, data, indent=2) + + built_at = datetime.now(timezone.utc).isoformat() + manifest = { + "version": 1, + "name": spec.name, + "built_at": built_at, + "spec_hash": spec_hash, + "links_enabled": links_enabled, + "graph_mode": spec.graph_mode, + "node_count": G.number_of_nodes(), + "edge_count": G.number_of_edges(), + "members": { + tag: { + "source_path": str(resolved[tag]), + "graph_path": stats[tag]["graph_path"], + "source_hash": member_hashes[tag], + "node_count": stats[tag]["node_count"], + "edge_count": stats[tag]["edge_count"], + } + for tag in stats + }, + } + write_json_atomic(manifest_path, manifest, indent=2) + write_text_atomic(out_dir / "CLUSTER_REPORT.md", _render_report(spec, stats, report, built_at)) + + refs = write_member_refs(spec, resolved, cluster_dir, built_at) if write_refs else 0 + + return { + "name": spec.name, + "skipped": False, + "out": str(graph_path), + "nodes": G.number_of_nodes(), + "edges": G.number_of_edges(), + "members": stats, + "links": report, + "refs_written": refs, + } + + +def check_cluster(cluster_dir: Path) -> tuple[LinkReport, list[str]]: + """Validate the spec and dry-run link resolution. Returns (report, errors). + + Errors cover unresolvable members, missing member graphs, and (per + on_missing: error) unresolvable selectors — anything that would make + ``build`` fail. + """ + cluster_dir = Path(cluster_dir) + spec = load_spec(cluster_dir) + local_cfg = load_local_config(cluster_dir) + resolved, warnings, errors = resolve_all_members(spec, cluster_dir, local_cfg) + + report = LinkReport(warnings=list(warnings), errors=list(errors)) + if not spec.members: + report.errors.append( + "cluster has no members; add repos with `graphify cluster add `" + ) + return report, report.errors + try: + _check_self_composition(spec, resolved, cluster_dir) + except ClusterSpecError as exc: + report.errors.append(str(exc)) + missing_graphs = [ + member.tag for member in spec.members + if member.tag in resolved and not member_graph_path(member, resolved[member.tag]).is_file() + ] + for tag in missing_graphs: + report.errors.append( + f"member '{tag}' has no graph at {member_graph_path(next(m for m in spec.members if m.tag == tag), resolved[tag])}" + ) + if report.errors: + return report, report.errors + + try: + G, _stats = compose_members(spec, resolved) + except ClusterSpecError as exc: # e.g. a corrupt member graph.json + report.errors.append(str(exc)) + return report, report.errors + # This graph exists only for validation, so materialize declared links in + # memory. Auto-package precedence then sees the same occupied pairs as a + # real build without writing any files. + link_report = apply_spec_links(G, spec, dry_run=False) + if spec.auto_packages: + apply_auto_package_links(G, spec, link_report, dry_run=False) + report.edges_added = link_report.edges_added + report.auto_package_edges = link_report.auto_package_edges + report.hubs_added = link_report.hubs_added + report.nodes_created = link_report.nodes_created + report.resolved = link_report.resolved + report.warnings.extend(link_report.warnings) + report.errors.extend(link_report.errors) + return report, report.errors diff --git a/tests/test_cluster_build.py b/tests/test_cluster_build.py new file mode 100644 index 000000000..ffa01f6ca --- /dev/null +++ b/tests/test_cluster_build.py @@ -0,0 +1,341 @@ +"""Composing member graphs into a cluster graph (`graphify cluster build`).""" +import json + +import networkx as nx +import pytest +from networkx.readwrite import json_graph as _jg + +from graphify.cluster_graph import ( + ClusterSpecError, + build_cluster, +) + + +def make_member(base, name, nodes, edges=(), url=""): + """Write a mini member repo: //graphify-out/graph.json.""" + repo = base / name + out = repo / "graphify-out" + out.mkdir(parents=True) + G = nx.Graph() + for node in nodes: + G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"}) + for u, v, attrs in edges: + G.add_edge(u, v, **attrs) + (out / "graph.json").write_text( + json.dumps(_jg.node_link_data(G, edges="links")), encoding="utf-8" + ) + return repo + + +def _node(nid, label=None, source_file=None, **extra): + d = {"id": nid, "label": label or nid, "file_type": "code"} + if source_file is not None: + d["source_file"] = source_file + d.update(extra) + return d + + +def write_cluster(cluster_dir, members, links=(), **extra): + cluster_dir.mkdir(parents=True, exist_ok=True) + data = { + "schema_version": 1, + "name": "test-cluster", + "members": members, + "links": list(links), + } + data.update(extra) + (cluster_dir / "cluster.json").write_text(json.dumps(data), encoding="utf-8") + + +def _load_out(cluster_dir): + data = json.loads((cluster_dir / "graphify-out" / "graph.json").read_text(encoding="utf-8")) + return _jg.node_link_graph(data, edges="links") + + +@pytest.fixture() +def two_members(tmp_path): + make_member(tmp_path, "alpha", [ + _node("app", source_file="src/app.ts"), + _node("react", label="react"), # external: no source_file + ], edges=[("app", "react", {"relation": "imports"})]) + make_member(tmp_path, "beta", [ + _node("server", source_file="src/server.ts"), + _node("react", label="react"), + ], edges=[("server", "react", {"relation": "imports"})]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [ + {"tag": "alpha", "path": "../alpha"}, + {"tag": "beta", "path": "../beta"}, + ]) + return cluster + + +def test_build_composes_with_repo_prefixes(two_members): + summary = build_cluster(two_members) + assert not summary["skipped"] + G = _load_out(two_members) + assert "alpha::app" in G and "beta::server" in G + assert G.nodes["alpha::app"]["repo"] == "alpha" + assert G.nodes["alpha::app"]["local_id"] == "app" + + +def test_build_merges_externals_by_label(two_members): + build_cluster(two_members) + G = _load_out(two_members) + # One shared `react` node, not one per member — and both import edges + # were rewired onto it, connecting the repos through the shared external. + react_nodes = [n for n, d in G.nodes(data=True) if d.get("label") == "react"] + assert len(react_nodes) == 1 + (react,) = react_nodes + # The cluster graph is directed; import edges point importer -> external. + importers = set(G.predecessors(react)) + assert {"alpha::app", "beta::server"} <= importers + + +def test_build_without_externals_merge(tmp_path, two_members): + spec = json.loads((two_members / "cluster.json").read_text(encoding="utf-8")) + spec["auto_links"] = {"externals": False} + (two_members / "cluster.json").write_text(json.dumps(spec), encoding="utf-8") + build_cluster(two_members) + G = _load_out(two_members) + react_nodes = [n for n, d in G.nodes(data=True) if d.get("label") == "react"] + assert len(react_nodes) == 2 + + +def test_rebuild_skips_when_unchanged_and_force_rebuilds(two_members): + first = build_cluster(two_members) + assert not first["skipped"] + second = build_cluster(two_members) + assert second["skipped"] + assert second["nodes"] == first["nodes"] + forced = build_cluster(two_members, force=True) + assert not forced["skipped"] + + +def test_rebuild_when_link_mode_changes(two_members): + spec_path = two_members / "cluster.json" + spec = json.loads(spec_path.read_text(encoding="utf-8")) + spec["links"] = [{ + "type": "api_call", + "from": {"repo": "alpha", "file": "src/app.ts"}, + "to": {"repo": "beta", "file": "src/server.ts"}, + }] + spec_path.write_text(json.dumps(spec), encoding="utf-8") + + linked = build_cluster(two_members) + assert not linked["skipped"] + assert any( + data.get("origin") == "cluster_spec" + for _u, _v, data in _load_out(two_members).edges(data=True) + ) + + unlinked = build_cluster(two_members, no_links=True) + assert not unlinked["skipped"] + assert not any( + data.get("origin") == "cluster_spec" + for _u, _v, data in _load_out(two_members).edges(data=True) + ) + assert build_cluster(two_members, no_links=True)["skipped"] + + relinked = build_cluster(two_members) + assert not relinked["skipped"] + assert any( + data.get("origin") == "cluster_spec" + for _u, _v, data in _load_out(two_members).edges(data=True) + ) + + +def test_legacy_manifest_without_link_mode_rebuilds(two_members): + build_cluster(two_members) + manifest_path = two_members / "graphify-out" / "cluster-manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert manifest["links_enabled"] is True + del manifest["links_enabled"] + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + assert not build_cluster(two_members)["skipped"] + + +def test_rebuild_after_member_change_is_idempotent(tmp_path, two_members): + first = build_cluster(two_members) + # Change a member graph: rebuild must pick it up and not duplicate anything. + make_member(tmp_path, "gamma", [_node("extra", source_file="x.ts")]) + gp = tmp_path / "alpha" / "graphify-out" / "graph.json" + data = json.loads(gp.read_text(encoding="utf-8")) + data["nodes"].append({"id": "helper", "label": "helper", "file_type": "code", + "source_file": "src/helper.ts"}) + gp.write_text(json.dumps(data), encoding="utf-8") + + second = build_cluster(two_members) + assert not second["skipped"] + assert second["nodes"] == first["nodes"] + 1 + third = build_cluster(two_members, force=True) + assert third["nodes"] == second["nodes"] + + +def test_missing_member_graph_is_actionable(tmp_path): + (tmp_path / "empty-repo").mkdir() + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "empty", "path": "../empty-repo"}]) + with pytest.raises(ClusterSpecError, match="graphify extract"): + build_cluster(cluster) + + +def test_unresolvable_member_is_actionable(tmp_path): + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "ghost", "url": "https://github.com/org/ghost"}]) + with pytest.raises(ClusterSpecError, match="cluster locate"): + build_cluster(cluster) + + +def test_build_writes_manifest_and_report(two_members): + build_cluster(two_members) + out = two_members / "graphify-out" + manifest = json.loads((out / "cluster-manifest.json").read_text(encoding="utf-8")) + assert set(manifest["members"]) == {"alpha", "beta"} + assert manifest["members"]["alpha"]["source_hash"] + assert manifest["links_enabled"] is True + report = (out / "CLUSTER_REPORT.md").read_text(encoding="utf-8") + assert "test-cluster" in report and "alpha" in report + + + + +def _write_member_json(base, name, graph): + """Write a member graph.json exactly as a real export would persist it.""" + out = base / name / "graphify-out" + out.mkdir(parents=True) + (out / "graph.json").write_text(json.dumps(graph), encoding="utf-8") + return base / name + + +def test_build_preserves_edge_direction_regardless_of_node_order(tmp_path): + # Real member graphs say "directed": false but their source/target order IS + # the caller->callee direction (export restores it from _src/_tgt and pops + # the attrs). The callee node deliberately precedes the caller here — the + # exact case where an undirected compose re-emits the edge flipped by node + # insertion order (#760 class). The cluster graph must keep caller->callee. + _write_member_json(tmp_path, "alpha", { + "directed": False, + "multigraph": False, + "graph": {}, + "nodes": [ + {"id": "callee", "label": "callee", "file_type": "code", + "source_file": "src/callee.ts"}, + {"id": "caller", "label": "caller", "file_type": "code", + "source_file": "src/caller.ts"}, + ], + "links": [{"source": "caller", "target": "callee", "relation": "calls"}], + }) + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "alpha", "path": "../alpha"}]) + build_cluster(cluster) + + data = json.loads( + (cluster / "graphify-out" / "graph.json").read_text(encoding="utf-8") + ) + assert data["directed"] is True + (link,) = [l for l in data["links"] if l.get("relation") == "calls"] + assert link["source"] == "alpha::caller" + assert link["target"] == "alpha::callee" + + # affected traverses in_edges on the loaded graph: changing the callee + # must report the caller, never the reverse. + from graphify.affected import affected_nodes, load_graph + + G = load_graph(cluster / "graphify-out" / "graph.json") + from_callee = {h.node_id for h in affected_nodes(G, "alpha::callee", relations=["calls"])} + from_caller = {h.node_id for h in affected_nodes(G, "alpha::caller", relations=["calls"])} + assert "alpha::caller" in from_callee + assert "alpha::callee" not in from_caller + + +def test_corrupt_member_graph_is_actionable(two_members): + (two_members.parent / "alpha" / "graphify-out" / "graph.json").write_text( + "{oops", encoding="utf-8" + ) + with pytest.raises(ClusterSpecError) as exc: + build_cluster(two_members) + msg = str(exc.value) + assert "alpha" in msg and "unreadable" in msg and "graphify extract" in msg + + from graphify.cluster_graph import check_cluster + + report, errors = check_cluster(two_members) + assert any("unreadable" in e for e in errors) + + +def test_cluster_cannot_compose_itself(two_members): + spec_path = two_members / "cluster.json" + spec = json.loads(spec_path.read_text(encoding="utf-8")) + spec["members"].append({"tag": "selfie", "path": "."}) + spec_path.write_text(json.dumps(spec), encoding="utf-8") + (two_members / "graphify-out").mkdir(exist_ok=True) + (two_members / "graphify-out" / "graph.json").write_text( + json.dumps({"directed": False, "multigraph": False, "graph": {}, + "nodes": [], "links": []}), + encoding="utf-8", + ) + with pytest.raises(ClusterSpecError, match="compose its own output"): + build_cluster(two_members) + + from graphify.cluster_graph import check_cluster + + report, errors = check_cluster(two_members) + assert any("compose its own output" in e for e in errors) + + +def test_member_communities_are_renumbered_per_member(tmp_path): + # Both members number their communities from 0; composing verbatim would + # merge unrelated "community 0" groups across repos. + make_member(tmp_path, "alpha", [ + _node("a1", source_file="a1.ts", community=0, community_name="Community 0"), + _node("a2", source_file="a2.ts", community=0, community_name="Community 0"), + ]) + make_member(tmp_path, "beta", [ + _node("b1", source_file="b1.ts", community=0, community_name="Auth Layer"), + ]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [ + {"tag": "alpha", "path": "../alpha"}, + {"tag": "beta", "path": "../beta"}, + ]) + build_cluster(cluster) + G = _load_out(cluster) + cids = {n: d.get("community") for n, d in G.nodes(data=True) if d.get("community") is not None} + assert cids["alpha::a1"] == cids["alpha::a2"] + assert cids["alpha::a1"] != cids["beta::b1"] + # Placeholder names track the new id; real LLM names are preserved. + assert G.nodes["alpha::a1"]["community_name"] == f"Community {cids['alpha::a1']}" + assert G.nodes["beta::b1"]["community_name"] == "Auth Layer" + + +def test_empty_cluster_build_is_actionable(tmp_path): + cluster = tmp_path / "cluster" + write_cluster(cluster, []) + with pytest.raises(ClusterSpecError, match="no members"): + build_cluster(cluster) + assert not (cluster / "graphify-out").exists() + + from graphify.cluster_graph import check_cluster + + report, errors = check_cluster(cluster) + assert any("no members" in e for e in errors) + + +def test_yaml_spec_still_loads(tmp_path): + yaml = pytest.importorskip("yaml") + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + cluster = tmp_path / "cluster" + cluster.mkdir() + (cluster / "cluster.yaml").write_text( + yaml.safe_dump({ + "schema_version": 1, + "name": "yaml-cluster", + "members": [{"tag": "alpha", "path": "../alpha"}], + }), + encoding="utf-8", + ) + summary = build_cluster(cluster) + assert summary["name"] == "yaml-cluster" + assert "alpha::app" in _load_out(cluster) diff --git a/tests/test_cluster_cli.py b/tests/test_cluster_cli.py new file mode 100644 index 000000000..db41ead9a --- /dev/null +++ b/tests/test_cluster_cli.py @@ -0,0 +1,294 @@ +"""`graphify cluster` CLI surface (init/add/remove/locate/build/check/status).""" +import json + +import pytest + +from graphify.cluster_cli import cmd_cluster +from graphify.cluster_graph import load_local_config, load_spec, resolve_member_path +from tests.test_cluster_build import make_member, write_cluster, _node + + +def _run(argv, capsys): + """Run cmd_cluster, returning (exit_code, stdout, stderr).""" + code = 0 + try: + cmd_cluster(argv) + except SystemExit as exc: + code = exc.code or 0 + out, err = capsys.readouterr() + return code, out, err + + +def _fake_checkout(path, url): + (path / ".git").mkdir(parents=True) + (path / ".git" / "config").write_text( + f'[remote "origin"]\n\turl = {url}\n', encoding="utf-8" + ) + + +def test_usage_on_no_subcommand(capsys): + code, out, _err = _run([], capsys) + assert code == 0 + # Requested help goes to stdout (like `graphify --help`). + assert "cluster-only" in out # disambiguation from community detection + + +def test_unknown_subcommand_exits_1(capsys): + code, _out, err = _run(["frobnicate"], capsys) + assert code == 1 + assert "Usage" in err + + +def test_init_add_remove_flow(tmp_path, capsys): + cluster = tmp_path / "my-cluster" + code, out, _err = _run(["init", str(cluster), "--name", "demo"], capsys) + assert code == 0 and "demo" in out + # init is guarded against clobbering an existing spec + code, _out, err = _run(["init", str(cluster)], capsys) + assert code == 1 and "already exists" in err + # .gitignore keeps local overrides and build output uncommitted + gitignore = (cluster / ".gitignore").read_text(encoding="utf-8") + assert "cluster.local.*" in gitignore and "graphify-out/" in gitignore + + repo = tmp_path / "alpha" + _fake_checkout(repo, "https://github.com/org/alpha") + code, out, _err = _run(["add", str(repo), "--dir", str(cluster)], capsys) + assert code == 0 + spec = load_spec(cluster) + assert spec.members[0].tag == "alpha" + assert spec.members[0].url == "https://github.com/org/alpha" + + code, _out, err = _run(["add", str(repo), "--dir", str(cluster)], capsys) + assert code == 1 and "already exists" in err + + code, _out, _err = _run(["remove", "alpha", "--dir", str(cluster)], capsys) + assert code == 0 + assert load_spec(cluster).members == [] + + +def test_add_relative_path_with_separate_cluster_dir(tmp_path, monkeypatch, capsys): + invocation = tmp_path / "invocation" + repo = invocation / "repos" / "alpha" + _fake_checkout(repo, "https://github.com/org/alpha") + cluster = tmp_path / "clusters" / "demo" + write_cluster(cluster, []) + monkeypatch.chdir(invocation) + + code, _out, err = _run(["add", "repos/alpha", "--dir", str(cluster)], capsys) + assert code == 0, err + member = load_spec(cluster).members[0] + resolved, warnings = resolve_member_path(member, cluster, {}) + assert not warnings + assert resolved == repo.resolve() + + +def test_add_via_symlinked_cluster_dir_stores_usable_hint(tmp_path, capsys): + """relpath must use the UNRESOLVED cluster dir. A hint computed against the + symlink-RESOLVED base carries that tree's `..` depth, but resolution later + re-joins it against the unresolved dir with normpath (which does not follow + symlinks) — with a symlink to a deeper real path, the hint climbs out of + the wrong tree entirely (e.g. macOS /tmp -> /private/tmp).""" + repo = tmp_path / "alpha" + _fake_checkout(repo, "https://github.com/org/alpha") + real_cluster = tmp_path / "d1" / "d2" / "cluster" + write_cluster(real_cluster, []) + (tmp_path / "s").symlink_to(tmp_path / "d1" / "d2") + linked_cluster = tmp_path / "s" / "cluster" + + code, _out, err = _run(["add", str(repo), "--dir", str(linked_cluster)], capsys) + assert code == 0, err + member = load_spec(linked_cluster).members[0] + resolved, _warnings = resolve_member_path(member, linked_cluster, {}) + assert resolved is not None and resolved.resolve() == repo.resolve() + + +def test_add_relative_path_falls_back_to_absolute_across_drives( + tmp_path, monkeypatch, capsys +): + repo = tmp_path / "alpha" + _fake_checkout(repo, "https://github.com/org/alpha") + cluster = tmp_path / "cluster" + write_cluster(cluster, []) + + def _cross_drive(*_args): + raise ValueError + + monkeypatch.setattr("graphify.cluster_cli.os.path.relpath", _cross_drive) + + code, _out, err = _run(["add", str(repo), "--dir", str(cluster)], capsys) + assert code == 0, err + assert load_spec(cluster).members[0].path == str(repo.resolve()) + + +def test_add_invalid_tag_does_not_modify_spec(tmp_path, capsys): + repo = tmp_path / "alpha" + _fake_checkout(repo, "https://github.com/org/alpha") + cluster = tmp_path / "cluster" + write_cluster(cluster, []) + spec_path = cluster / "cluster.json" + before = spec_path.read_bytes() + + code, _out, err = _run( + ["add", str(repo), "--as", "bad::tag", "--dir", str(cluster)], capsys + ) + assert code == 1 and "invalid" in err + assert spec_path.read_bytes() == before + assert load_spec(cluster).members == [] + + +def test_remove_blocks_when_links_reference_member(tmp_path, capsys): + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "a", "path": "../a"}], links=[{ + "type": "api_call", + "from": {"repo": "a", "label": "x"}, + "to": {"repo": "a", "label": "y"}, + }]) + code, _out, err = _run(["remove", "a", "--dir", str(cluster)], capsys) + assert code == 1 and "referenced by links" in err + + +def test_locate_writes_local_override(tmp_path, capsys): + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "a", "url": "https://github.com/org/a"}]) + checkout = tmp_path / "somewhere" + _fake_checkout(checkout, "https://github.com/org/a") + code, out, _err = _run(["locate", "a", str(checkout), "--dir", str(cluster)], capsys) + assert code == 0 + cfg = load_local_config(cluster) + assert cfg["paths"]["a"] == str(checkout.resolve()) + + # mismatched origin still records, but warns + other = tmp_path / "other" + _fake_checkout(other, "https://github.com/org/unrelated") + code, _out, err = _run(["locate", "a", str(other), "--dir", str(cluster)], capsys) + assert code == 0 and "origin" in err + + +def test_build_and_status_end_to_end(tmp_path, capsys): + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + make_member(tmp_path, "beta", [_node("server", source_file="src/server.ts")]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [ + {"tag": "alpha", "path": "../alpha"}, + {"tag": "beta", "path": "../beta"}, + ], links=[{ + "type": "api_call", + "from": {"repo": "alpha", "file": "src/app.ts"}, + "to": {"repo": "beta", "file": "src/server.ts"}, + }]) + + code, out, _err = _run(["build", "--dir", str(cluster)], capsys) + assert code == 0 + assert "2 members" in out + assert "links: 1 edges" in out + assert (cluster / "graphify-out" / "graph.json").is_file() + + code, out, _err = _run(["build", "--dir", str(cluster)], capsys) + assert code == 0 and "skipped" in out + + code, out, _err = _run(["status", "--dir", str(cluster)], capsys) + assert code == 0 + assert "alpha" in out and "ok" in out + + +def test_check_reports_and_exit_codes(tmp_path, capsys): + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "alpha", "path": "../alpha"}], links=[{ + "type": "api_call", + "on_missing": "error", + "from": {"repo": "alpha", "label": "missing-thing"}, + "to": {"repo": "alpha", "file": "src/app.ts"}, + }]) + code, _out, err = _run(["check", "--dir", str(cluster)], capsys) + assert code == 1 + assert "no node matches" in err + + # Downgrade to warn -> check passes + data = json.loads((cluster / "cluster.json").read_text(encoding="utf-8")) + data["links"][0]["on_missing"] = "warn" + (cluster / "cluster.json").write_text(json.dumps(data), encoding="utf-8") + code, out, _err = _run(["check", "--dir", str(cluster)], capsys) + assert code == 0 and "Spec OK" in out + + +def test_dispatch_routes_cluster_command(tmp_path, monkeypatch, capsys): + """`graphify cluster ...` reaches cmd_cluster through dispatch_command.""" + import sys as _sys + from graphify.cli import dispatch_command + + monkeypatch.setattr(_sys, "argv", ["graphify", "cluster", "help"]) + with pytest.raises(SystemExit) as exc: + dispatch_command("cluster") + assert exc.value.code == 0 + out, _err = capsys.readouterr() + assert "Manage cluster graphs" in out + + +def test_flag_missing_value_is_a_hard_error(tmp_path, monkeypatch, capsys): + """A value flag with no value must error, never fall through to a + positional (`cluster init --name` used to create a dir named `--name`).""" + monkeypatch.chdir(tmp_path) + for argv in ( + ["init", "--name"], + ["init", "--name="], + ["init", "--dir"], + ["init", "--name", "--dir", "d"], + ): + code, _out, err = _run(argv, capsys) + assert code == 1, argv + assert "requires a value" in err, argv + assert not (tmp_path / "--name").exists() + assert not (tmp_path / "--dir").exists() + + repo = tmp_path / "repo" + repo.mkdir() + assert _run(["init", "cl", "--name", "x"], capsys)[0] == 0 + code, _out, err = _run(["add", str(repo), "--as", "--dir", str(tmp_path / "cl")], capsys) + assert code == 1 and "requires a value" in err + + +def test_unknown_flags_are_rejected(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + code, _out, err = _run(["init", "--nmae", "typo"], capsys) + assert code == 1 and "unknown option" in err + assert not (tmp_path / "--nmae").exists() and not (tmp_path / "typo").exists() + + assert _run(["init", "cl", "--name", "x"], capsys)[0] == 0 + code, _out, err = _run(["remove", "tag", "--frob", "--dir", "cl"], capsys) + assert code == 1 and "unknown option" in err + + +def test_help_tokens_never_reach_handlers(tmp_path, monkeypatch, capsys): + """`cluster add --help` (any position) prints USAGE and exits 0 — it must + never fall through to a handler and cause side effects.""" + monkeypatch.chdir(tmp_path) + for argv in (["add", "--help"], ["init", "-h"], ["--help"], ["build", "-?"]): + code, out, _err = _run(argv, capsys) + assert code == 0, argv + assert "Manage cluster graphs" in out, argv + assert not any(p.is_dir() and p.name != "__pycache__" for p in tmp_path.iterdir()) + + +def test_main_entrypoint_routes_cluster_help(tmp_path, monkeypatch, capsys): + """The universal -h guard in __main__ defers to cluster's own USAGE.""" + import sys as _sys + from graphify.__main__ import main + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(_sys, "argv", ["graphify", "cluster", "add", "--help"]) + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 0 + out, _err = capsys.readouterr() + assert "Manage cluster graphs" in out + assert not (tmp_path / "graphify-out").exists() + + +def test_add_rejects_cluster_dir_itself(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + assert _run(["init", "cl", "--name", "x"], capsys)[0] == 0 + code, _out, err = _run(["add", "cl", "--dir", "cl"], capsys) + assert code == 1 + assert "own member" in err + assert load_spec(tmp_path / "cl").members == [] diff --git a/tests/test_cluster_links.py b/tests/test_cluster_links.py new file mode 100644 index 000000000..525ba146d --- /dev/null +++ b/tests/test_cluster_links.py @@ -0,0 +1,558 @@ +"""Spec-declared cross-repo link resolution (selectors, hubs, on_missing).""" +import json + +import networkx as nx +import pytest + +from graphify.cluster_graph import ( + AmbiguousSelectorError, + ClusterSpecError, + ClusterSpec, + build_cluster, + check_cluster, + load_spec, + apply_spec_links, + apply_auto_package_links, + compose_members, + resolve_selector, +) +from tests.test_cluster_build import make_member, write_cluster, _node, _load_out + + +@pytest.fixture() +def linked_cluster(tmp_path): + """Two members shaped like a client/service pair plus a mirrored type file.""" + make_member(tmp_path, "web", [ + _node("lib_cube_client", label="cube-client.ts", source_file="app/lib/cube/cube-client.ts"), + _node("lib_cube_client_getmeta", label="getMeta", source_file="app/lib/cube/cube-client.ts"), + _node("types_payload", label="payload.ts", source_file="src/types/payload.ts"), + ]) + make_member(tmp_path, "svc", [ + _node("cube", label="cube.js", source_file="cube.js"), + _node("sync", label="pingSync", source_file="src/sync.ts"), + _node("payload", label="payload.ts", source_file="src/payload.ts"), + ]) + cluster = tmp_path / "cluster" + return cluster + + +def _compose(cluster_dir): + spec = load_spec(cluster_dir) + from graphify.cluster_graph import load_local_config, resolve_all_members + resolved, _w, errors = resolve_all_members(spec, cluster_dir, load_local_config(cluster_dir)) + assert not errors + G, _stats = compose_members(spec, resolved) + return G, spec + + +def _package(nid, name, repo_key, dependencies=()): + return _node( + nid, + label=name, + source_file="pyproject.toml", + type="package", + ecosystem="python", + package_key=repo_key, + dependency_keys=list(dependencies), + ) + + +def test_auto_packages_links_unique_cross_repo_provider(tmp_path): + make_member(tmp_path, "app", [ + _package("pkg_app", "app", "python:app", ["python:shared-lib"]), + ]) + make_member(tmp_path, "lib", [ + _package("pkg_shared", "shared-lib", "python:shared-lib"), + ]) + cluster = tmp_path / "cluster" + write_cluster( + cluster, + [{"tag": "app", "path": "../app"}, {"tag": "lib", "path": "../lib"}], + auto_links={"packages": True}, + ) + + summary = build_cluster(cluster) + graph = _load_out(cluster) + edge = graph.get_edge_data("app::pkg_app", "lib::pkg_shared") + assert edge["relation"] == "depends_on" + assert edge["origin"] == "cluster_auto_package" + assert summary["links"].auto_package_edges == 1 + + +def test_auto_packages_skips_external_same_repo_and_ambiguous_providers(tmp_path): + make_member(tmp_path, "app", [ + _package( + "pkg_app", "app", "python:app", + ["python:local", "python:external", "python:shared"], + ), + _package("pkg_local", "local", "python:local"), + ]) + make_member(tmp_path, "lib1", [_package("pkg_shared1", "shared", "python:shared")]) + make_member(tmp_path, "lib2", [_package("pkg_shared2", "shared", "python:shared")]) + cluster = tmp_path / "cluster" + write_cluster( + cluster, + [ + {"tag": "app", "path": "../app"}, + {"tag": "lib1", "path": "../lib1"}, + {"tag": "lib2", "path": "../lib2"}, + ], + auto_links={"packages": True}, + ) + + summary = build_cluster(cluster) + assert summary["links"].auto_package_edges == 0 + assert any("2 cross-repo providers" in warning for warning in summary["links"].warnings) + + +def test_auto_packages_declared_link_takes_precedence(tmp_path): + make_member(tmp_path, "app", [ + _package("pkg_app", "app", "python:app", ["python:shared"]), + ]) + make_member(tmp_path, "lib", [_package("pkg_shared", "shared", "python:shared")]) + cluster = tmp_path / "cluster" + write_cluster( + cluster, + [{"tag": "app", "path": "../app"}, {"tag": "lib", "path": "../lib"}], + links=[{ + "type": "depends_on", + "name": "declared", + "from": {"repo": "app", "id": "pkg_app"}, + "to": {"repo": "lib", "id": "pkg_shared"}, + }], + auto_links={"packages": True}, + ) + + summary = build_cluster(cluster) + edge = _load_out(cluster).get_edge_data("app::pkg_app", "lib::pkg_shared") + assert edge["origin"] == "cluster_spec" + assert summary["links"].auto_package_edges == 0 + assert any("already connected" in warning for warning in summary["links"].warnings) + + +def test_auto_packages_warns_for_stale_member_graph(): + graph = nx.Graph() + graph.add_node( + "app::pkg", type="package", repo="app", package_key="python:app" + ) + report = apply_auto_package_links( + graph, ClusterSpec(name="test", auto_packages=True) + ) + assert any("re-run `graphify extract --force`" in warning for warning in report.warnings) + + +def test_api_call_link_by_file_selector(linked_cluster, tmp_path): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "api_call", + "name": "cube-rest", + "from": {"repo": "web", "file": "app/lib/cube/cube-client.ts"}, + "to": {"repo": "svc", "file": "cube.js"}, + "note": "JWT via env", + }]) + build_cluster(linked_cluster) + G = _load_out(linked_cluster) + data = G.get_edge_data("web::lib_cube_client", "svc::cube") + assert data is not None + assert data["relation"] == "calls_api" + assert data["confidence"] == "EXTRACTED" + assert data["origin"] == "cluster_spec" + assert data["link_name"] == "cube-rest" + assert data["source_file"] == "cluster.json" + # Direction is topological (the graph is directed) and the _src/_tgt + # persistence markers are popped at write time like export.to_json does. + assert "_src" not in data and "_tgt" not in data + assert G.get_edge_data("svc::cube", "web::lib_cube_client") is None + + +def test_file_selector_prefers_file_node(linked_cluster): + # app/lib/cube/cube-client.ts contains both the file node and a symbol + # node; the file selector must land on the file node. + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ]) + G, _spec = _compose(linked_cluster) + nodes_by_repo = {} + for n, d in G.nodes(data=True): + nodes_by_repo.setdefault(d.get("repo", ""), []).append((n, d)) + node = resolve_selector(nodes_by_repo, {"repo": "web", "file": "app/lib/cube/cube-client.ts"}) + assert node == "web::lib_cube_client" + # Suffix matching: a shorter repo-relative tail also resolves. + node = resolve_selector(nodes_by_repo, {"repo": "web", "file": "cube/cube-client.ts"}) + assert node == "web::lib_cube_client" + + +def test_file_selector_prefers_file_node_in_llm_labeled_graph(tmp_path): + """LLM extractions relabel file nodes descriptively ("PR Summary Generator"), + so the basename-label heuristic fails; the file-node ID spec (#1504 — + local_id == normalize_id(path minus extension)) must still disambiguate.""" + make_member(tmp_path, "plugin", [ + _node("scripts_generate_pr_summary", label="PR Summary Generator", + source_file="scripts/generate-pr-summary.js"), + _node("scripts_generate_pr_summary_buildprompt", label="buildPrompt", + source_file="scripts/generate-pr-summary.js"), + _node("scripts_generate_pr_summary_callclaudeapi", label="callClaudeApi", + source_file="scripts/generate-pr-summary.js"), + ]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "plugin", "path": "../plugin"}]) + G, _spec = _compose(cluster) + nodes_by_repo = {} + for n, d in G.nodes(data=True): + nodes_by_repo.setdefault(d.get("repo", ""), []).append((n, d)) + node = resolve_selector( + nodes_by_repo, {"repo": "plugin", "file": "scripts/generate-pr-summary.js"} + ) + assert node == "plugin::scripts_generate_pr_summary" + + +def test_label_selector_exact_then_normalized(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ]) + G, _spec = _compose(linked_cluster) + nodes_by_repo = {} + for n, d in G.nodes(data=True): + nodes_by_repo.setdefault(d.get("repo", ""), []).append((n, d)) + assert resolve_selector(nodes_by_repo, {"repo": "svc", "label": "pingSync"}) == "svc::sync" + # Normalized fallback: case-insensitive via normalize_id. + assert resolve_selector(nodes_by_repo, {"repo": "svc", "label": "PingSync"}) == "svc::sync" + + +def test_id_selector_uses_local_id(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ]) + G, _spec = _compose(linked_cluster) + nodes_by_repo = {} + for n, d in G.nodes(data=True): + nodes_by_repo.setdefault(d.get("repo", ""), []).append((n, d)) + assert resolve_selector(nodes_by_repo, {"repo": "svc", "id": "cube"}) == "svc::cube" + + +def test_ambiguous_selector_lists_candidates(tmp_path): + make_member(tmp_path, "twins", [ + _node("a_util", label="util", source_file="a/util.ts"), + _node("b_util", label="util", source_file="b/util.ts"), + ]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "twins", "path": "../twins"}]) + G, _spec = _compose(cluster) + nodes_by_repo = {} + for n, d in G.nodes(data=True): + nodes_by_repo.setdefault(d.get("repo", ""), []).append((n, d)) + with pytest.raises(AmbiguousSelectorError) as exc: + resolve_selector(nodes_by_repo, {"repo": "twins", "label": "util"}) + assert "a/util.ts" in str(exc.value) and "b/util.ts" in str(exc.value) + + +def test_shared_resource_creates_hub_with_uses_edges(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "shared_resource", + "kind": "supabase_table", + "name": "cro.pings", + "referents": [ + {"repo": "web", "file": "src/types/payload.ts"}, + {"repo": "svc", "label": "pingSync"}, + ], + }]) + build_cluster(linked_cluster) + G = _load_out(linked_cluster) + hub = "cluster::supabase_table_cro_pings" + assert hub in G + assert G.nodes[hub]["file_type"] == "concept" + assert G.nodes[hub]["label"] == "cro.pings" + assert G.nodes[hub]["repo"] == "cluster" + # Referents depend on the resource, so `uses` edges point referent -> hub. + referents = {"web::types_payload", "svc::sync"} + assert set(G.predecessors(hub)) == referents + for referent in referents: + assert G.get_edge_data(referent, hub)["relation"] == "uses" + + +def test_mirrored_file_link(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "mirrored_file", + "name": "payload", + "from": {"repo": "web", "file": "src/types/payload.ts"}, + "to": {"repo": "svc", "file": "src/payload.ts"}, + "direction": "both", + }]) + build_cluster(linked_cluster) + G = _load_out(linked_cluster) + data = G.get_edge_data("web::types_payload", "svc::payload") + assert data["relation"] == "mirrors" + assert data["direction"] == "both" + # direction: "both" materializes a real reverse edge, not just metadata — + # affected/query traverse topology, so both directions must exist. + reverse = G.get_edge_data("svc::payload", "web::types_payload") + assert reverse is not None + assert reverse["relation"] == "mirrors" + assert reverse["direction"] == "both" + + +def test_direction_both_traverses_from_either_endpoint(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "mirrored_file", + "name": "payload", + "from": {"repo": "web", "file": "src/types/payload.ts"}, + "to": {"repo": "svc", "file": "src/payload.ts"}, + "direction": "both", + }]) + build_cluster(linked_cluster) + from graphify.affected import affected_nodes + + G = _load_out(linked_cluster) + from_web = {h.node_id for h in affected_nodes(G, "web::types_payload", relations=["mirrors"])} + from_svc = {h.node_id for h in affected_nodes(G, "svc::payload", relations=["mirrors"])} + assert "svc::payload" in from_web + assert "web::types_payload" in from_svc + + +def test_direction_validation_rejects_unknown_values(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "mirrored_file", + "name": "payload", + "from": {"repo": "web", "file": "src/types/payload.ts"}, + "to": {"repo": "svc", "file": "src/payload.ts"}, + "direction": "sideways", + }]) + with pytest.raises(ClusterSpecError, match="direction"): + load_spec(linked_cluster) + + +def test_direction_both_still_owns_the_pair(linked_cluster): + # The declared link's own reverse edge is exempt from the + # one-relation-per-pair guard; a separate reverse-declaring link is not. + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[ + { + "type": "mirrored_file", + "name": "payload", + "from": {"repo": "web", "file": "src/types/payload.ts"}, + "to": {"repo": "svc", "file": "src/payload.ts"}, + "direction": "both", + }, + { + "type": "references", + "name": "reverse-decl", + "from": {"repo": "svc", "file": "src/payload.ts"}, + "to": {"repo": "web", "file": "src/types/payload.ts"}, + }, + ]) + with pytest.raises(ClusterSpecError, match="one relation per node pair"): + build_cluster(linked_cluster) + + +def test_duplicate_direct_links_fail_check_and_build(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[ + { + "type": "api_call", + "name": "first-contract", + "from": {"repo": "web", "file": "app/lib/cube/cube-client.ts"}, + "to": {"repo": "svc", "file": "cube.js"}, + }, + { + "type": "references", + "name": "second-contract", + "from": {"repo": "web", "file": "app/lib/cube/cube-client.ts"}, + "to": {"repo": "svc", "file": "cube.js"}, + }, + ]) + + report, errors = check_cluster(linked_cluster) + assert report.edges_added == 1 + assert any("one relation per node pair" in error for error in errors) + assert any("first-contract" in error and "second-contract" in error for error in errors) + with pytest.raises(ClusterSpecError, match="one relation per node pair"): + build_cluster(linked_cluster) + + +def test_repeated_shared_resource_referent_is_rejected(linked_cluster): + selector = {"repo": "svc", "label": "pingSync"} + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "shared_resource", + "kind": "table", + "name": "events", + "referents": [selector, selector], + }]) + + with pytest.raises(ClusterSpecError, match="one relation per node pair"): + build_cluster(linked_cluster) + + +def test_declared_link_cannot_overwrite_existing_edge(tmp_path): + make_member( + tmp_path, + "web", + [ + _node("client", label="client", source_file="client.py"), + _node("server", label="server", source_file="server.py"), + ], + edges=[("client", "server", {"relation": "calls"})], + ) + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "web", "path": "../web"}], links=[{ + "type": "references", + "from": {"repo": "web", "id": "client"}, + "to": {"repo": "web", "id": "server"}, + }]) + + with pytest.raises(ClusterSpecError, match="existing relation 'calls'"): + build_cluster(cluster) + + +def test_on_missing_warn_skips(linked_cluster, capsys): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "api_call", + "from": {"repo": "web", "label": "no-such-node"}, + "to": {"repo": "svc", "file": "cube.js"}, + }]) + summary = build_cluster(linked_cluster) + assert summary["links"].edges_added == 0 + assert any("no node matches" in w for w in summary["links"].warnings) + + +def test_on_missing_create_makes_concept_node(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "api_call", + "on_missing": "create", + "from": {"repo": "web", "label": "External Webhook"}, + "to": {"repo": "svc", "file": "cube.js"}, + }]) + build_cluster(linked_cluster) + G = _load_out(linked_cluster) + concept = "web::concept_external_webhook" + assert concept in G + assert G.nodes[concept]["file_type"] == "concept" + assert G.nodes[concept]["origin"] == "cluster_spec" + assert G.get_edge_data(concept, "svc::cube")["relation"] == "calls_api" + + +def test_on_missing_error_fails_build(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "api_call", + "on_missing": "error", + "from": {"repo": "web", "label": "no-such-node"}, + "to": {"repo": "svc", "file": "cube.js"}, + }]) + with pytest.raises(ClusterSpecError, match="no node matches"): + build_cluster(linked_cluster) + + +def test_dry_run_does_not_mutate(linked_cluster): + write_cluster(linked_cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "api_call", + "from": {"repo": "web", "file": "app/lib/cube/cube-client.ts"}, + "to": {"repo": "svc", "file": "cube.js"}, + }]) + G, spec = _compose(linked_cluster) + before_nodes, before_edges = G.number_of_nodes(), G.number_of_edges() + report = apply_spec_links(G, spec, dry_run=True) + assert report.edges_added == 1 + assert (G.number_of_nodes(), G.number_of_edges()) == (before_nodes, before_edges) + + +def test_norm_source_file_keeps_leading_dots(): + from graphify.cluster_graph import _norm_source_file + + assert _norm_source_file(".env") == ".env" + assert _norm_source_file(".github/workflows/ci.yml") == ".github/workflows/ci.yml" + assert _norm_source_file("./src/app.py") == "src/app.py" + assert _norm_source_file("/abs/src/app.py") == "abs/src/app.py" + + +def test_file_selector_matches_dotfile(tmp_path): + make_member(tmp_path, "web", [ + _node("env", label=".env", source_file=".env"), + _node("scripts_env", label="env", source_file="scripts/env"), + ]) + make_member(tmp_path, "svc", [_node("sync", source_file="src/sync.ts")]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [ + {"tag": "web", "path": "../web"}, + {"tag": "svc", "path": "../svc"}, + ], links=[{ + "type": "references", + "name": "env-contract", + "from": {"repo": "svc", "file": "src/sync.ts"}, + "to": {"repo": "web", "file": ".env"}, + }]) + build_cluster(cluster) + G = _load_out(cluster) + # ".env" must match the dotfile node, not alias onto "scripts/env". + assert G.get_edge_data("svc::sync", "web::env") is not None + assert G.get_edge_data("svc::sync", "web::scripts_env") is None + + +def test_external_label_selector_is_member_order_independent(tmp_path): + """Externals dedupe onto the FIRST member that references them; a selector + naming any other referencing member must still resolve, in either order.""" + make_member(tmp_path, "alpha", [ + _node("app", source_file="src/app.ts"), + _node("requests", label="requests"), # external + ], edges=[("app", "requests", {"relation": "imports"})]) + make_member(tmp_path, "beta", [ + _node("server", source_file="src/server.ts"), + _node("requests", label="requests"), + ], edges=[("server", "requests", {"relation": "imports"})]) + + for member_order in (["alpha", "beta"], ["beta", "alpha"]): + cluster = tmp_path / f"cluster-{'-'.join(member_order)}" + # Distinct names: same-named clusters sharing a member are (correctly) + # rejected by the marker conflict check. + write_cluster(cluster, [ + {"tag": tag, "path": f"../{tag}"} for tag in member_order + ], name=f"stack-{'-'.join(member_order)}", links=[{ + "type": "shared_resource", + "kind": "library", + "name": "requests-lib", + "referents": [ + {"repo": "beta", "label": "requests"}, + {"repo": "alpha", "file": "src/app.ts"}, + ], + }]) + build_cluster(cluster) + G = _load_out(cluster) + hub = "cluster::library_requests_lib" + assert hub in G, f"hub missing for member order {member_order}" + referents = set(G.predecessors(hub)) + assert any("requests" in r for r in referents), member_order + assert "alpha::app" in referents, member_order diff --git a/tests/test_cluster_spec.py b/tests/test_cluster_spec.py new file mode 100644 index 000000000..a3c27ce03 --- /dev/null +++ b/tests/test_cluster_spec.py @@ -0,0 +1,246 @@ +"""Cluster spec loading, validation, and member path resolution. + +`graphify cluster` (multi-repo cluster graphs) — see graphify/cluster_graph.py. +""" +import json + +import pytest + +from graphify.cluster_graph import ( + ClusterMember, + ClusterSpec, + ClusterSpecError, + load_local_config, + load_spec, + normalize_git_url, + origin_url, + resolve_member_path, + save_local_config, + save_spec, +) + + +def _write_spec(cluster_dir, data): + cluster_dir.mkdir(parents=True, exist_ok=True) + (cluster_dir / "cluster.json").write_text(json.dumps(data), encoding="utf-8") + + +def _minimal(members=None, links=None, **extra): + data = {"schema_version": 1, "name": "test", "members": members or [], "links": links or []} + data.update(extra) + return data + + +def _fake_checkout(path, url): + (path / ".git").mkdir(parents=True) + (path / ".git" / "config").write_text( + f'[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = {url}\n' + f'\tfetch = +refs/heads/*:refs/remotes/origin/*\n', + encoding="utf-8", + ) + + +def test_load_spec_round_trip(tmp_path): + _write_spec(tmp_path, _minimal( + members=[{"tag": "a", "url": "https://github.com/org/a", "path": "../a"}], + links=[{ + "type": "api_call", + "name": "the-api", + "from": {"repo": "a", "file": "src/client.ts"}, + "to": {"repo": "a", "label": "server"}, + }], + )) + spec = load_spec(tmp_path) + assert spec.name == "test" + assert spec.members[0].tag == "a" + assert spec.links[0].from_ == {"repo": "a", "file": "src/client.ts"} + + # save_spec preserves the JSON format and survives a reload + spec.members.append(ClusterMember(tag="b", url="git@github.com:org/b.git")) + target = save_spec(spec, tmp_path) + assert target.name == "cluster.json" + reloaded = load_spec(tmp_path) + assert reloaded.tags() == {"a", "b"} + + +def test_new_spec_and_local_config_are_json_first(tmp_path): + spec = ClusterSpec(name="fresh") + assert save_spec(spec, tmp_path).name == "cluster.json" + assert save_local_config(tmp_path, {"paths": {}}).name == "cluster.local.json" + + +def test_graph_mode_round_trip_and_validation(tmp_path): + # Only "simple" exists today; unknown modes fail LOUD rather than silently + # composing a simple graph. The plumbing (spec field, manifest key, status + # line) is in place so a future mode is a validation widening, not a + # format change. + _write_spec(tmp_path, _minimal(graph_mode="simple")) + spec = load_spec(tmp_path) + assert spec.graph_mode == "simple" + save_spec(spec, tmp_path) + assert json.loads((tmp_path / "cluster.json").read_text())["graph_mode"] == "simple" + + for unknown in ("multi", "hyper"): + _write_spec(tmp_path, _minimal(graph_mode=unknown)) + with pytest.raises(ClusterSpecError, match="graph_mode"): + load_spec(tmp_path) + + +def test_missing_spec_is_actionable(tmp_path): + with pytest.raises(ClusterSpecError, match="cluster init"): + load_spec(tmp_path) + + +def test_duplicate_tag_rejected(tmp_path): + _write_spec(tmp_path, _minimal(members=[{"tag": "a"}, {"tag": "a"}])) + with pytest.raises(ClusterSpecError, match="duplicate"): + load_spec(tmp_path) + + +def test_reserved_and_invalid_tags_rejected(tmp_path): + _write_spec(tmp_path, _minimal(members=[{"tag": "cluster"}])) + with pytest.raises(ClusterSpecError, match="reserved"): + load_spec(tmp_path) + _write_spec(tmp_path, _minimal(members=[{"tag": "a::b"}])) + with pytest.raises(ClusterSpecError, match="invalid"): + load_spec(tmp_path) + + +def test_unknown_link_type_and_member_rejected(tmp_path): + _write_spec(tmp_path, _minimal( + members=[{"tag": "a"}], + links=[{"type": "telepathy", "from": {"repo": "a", "label": "x"}, + "to": {"repo": "a", "label": "y"}}], + )) + with pytest.raises(ClusterSpecError, match="unknown type"): + load_spec(tmp_path) + + _write_spec(tmp_path, _minimal( + members=[{"tag": "a"}], + links=[{"type": "api_call", "from": {"repo": "ghost", "label": "x"}, + "to": {"repo": "a", "label": "y"}}], + )) + with pytest.raises(ClusterSpecError, match="unknown member"): + load_spec(tmp_path) + + +def test_selector_needs_exactly_one_key(tmp_path): + _write_spec(tmp_path, _minimal( + members=[{"tag": "a"}], + links=[{"type": "api_call", + "from": {"repo": "a", "label": "x", "file": "x.ts"}, + "to": {"repo": "a", "label": "y"}}], + )) + with pytest.raises(ClusterSpecError, match="exactly one"): + load_spec(tmp_path) + + +def test_schema_version_guard(tmp_path): + _write_spec(tmp_path, _minimal(schema_version=99)) + with pytest.raises(ClusterSpecError, match="schema_version 99"): + load_spec(tmp_path) + + +def test_shared_resource_needs_name_and_referents(tmp_path): + _write_spec(tmp_path, _minimal( + members=[{"tag": "a"}], + links=[{"type": "shared_resource", "kind": "table"}], + )) + with pytest.raises(ClusterSpecError, match="name"): + load_spec(tmp_path) + + +def test_bad_on_missing_rejected(tmp_path): + _write_spec(tmp_path, _minimal(defaults={"on_missing": "explode"})) + with pytest.raises(ClusterSpecError, match="on_missing"): + load_spec(tmp_path) + + +# --------------------------------------------------------------------------- +# URL normalization + path resolution +# --------------------------------------------------------------------------- + +def test_normalize_git_url_equivalences(): + forms = [ + "https://github.com/Org/Repo", + "https://github.com/org/repo.git", + "git@github.com:org/repo.git", + "ssh://git@github.com/org/repo", + "github.com/org/repo", + ] + assert {normalize_git_url(f) for f in forms} == {"github.com/org/repo"} + assert normalize_git_url("https://gitlab.com/org/repo") != "github.com/org/repo" + assert normalize_git_url("") == "" + + +def test_origin_url_reads_git_config(tmp_path): + repo = tmp_path / "checkout" + _fake_checkout(repo, "git@github.com:org/thing.git") + assert origin_url(repo) == "git@github.com:org/thing.git" + assert origin_url(tmp_path / "nope") is None + + +def test_resolve_prefers_local_override(tmp_path): + cluster = tmp_path / "cluster" + cluster.mkdir() + override = tmp_path / "elsewhere" / "a" + _fake_checkout(override, "https://github.com/org/a") + member = ClusterMember(tag="a", url="https://github.com/org/a", path="../missing") + path, warnings = resolve_member_path( + member, cluster, {"paths": {"a": str(override)}} + ) + assert path == override + assert warnings == [] + + +def test_resolve_falls_back_to_spec_path(tmp_path): + cluster = tmp_path / "cluster" + cluster.mkdir() + checkout = tmp_path / "a" + _fake_checkout(checkout, "https://github.com/org/a") + member = ClusterMember(tag="a", url="https://github.com/org/a", path="../a") + path, warnings = resolve_member_path(member, cluster, {}) + assert path == checkout + assert warnings == [] + + +def test_resolve_warns_on_origin_mismatch(tmp_path): + cluster = tmp_path / "cluster" + cluster.mkdir() + checkout = tmp_path / "a" + _fake_checkout(checkout, "https://github.com/someone-else/fork") + member = ClusterMember(tag="a", url="https://github.com/org/a", path="../a") + path, warnings = resolve_member_path(member, cluster, {}) + assert path == checkout + assert len(warnings) == 1 and "origin" in warnings[0] + + +def test_resolve_discovers_sibling_by_origin(tmp_path): + cluster = tmp_path / "cluster" + cluster.mkdir() + # Same dir name as another repo — discovery must match by origin URL, + # not by name. + decoy = tmp_path / "a-decoy" + _fake_checkout(decoy, "https://github.com/org/other") + checkout = tmp_path / "renamed-checkout" + _fake_checkout(checkout, "git@github.com:org/a.git") + member = ClusterMember(tag="a", url="https://github.com/org/a") + path, warnings = resolve_member_path(member, cluster, {}) + assert path == checkout + assert warnings == [] + + +def test_resolve_unresolvable_returns_none(tmp_path): + cluster = tmp_path / "cluster" + cluster.mkdir() + member = ClusterMember(tag="a", url="https://github.com/org/nowhere") + path, _warnings = resolve_member_path(member, cluster, {}) + assert path is None + + +def test_local_config_round_trip(tmp_path): + target = save_local_config(tmp_path, {"paths": {"a": "/x"}, "search_roots": ["/y"]}) + assert target.exists() + cfg = load_local_config(tmp_path) + assert cfg["paths"] == {"a": "/x"} + assert cfg["search_roots"] == ["/y"] From 4d98b14a8e429a237ce96f48c124db781de6b435 Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:49:36 -0400 Subject: [PATCH 03/11] feat(affected): traverse calls_api/mirrors cluster relations by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declared cluster links materialize as calls_api/mirrors edges; without them in the default relation set, affected stops at the repo boundary. depends_on stays out — package edges exist in single-repo graphs too, so including it would change single-repo affected behavior. --- graphify/affected.py | 7 ++++ tests/test_affected_cluster_relations.py | 43 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/test_affected_cluster_relations.py diff --git a/graphify/affected.py b/graphify/affected.py index 4fc7b8fbf..253253495 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -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", ) diff --git a/tests/test_affected_cluster_relations.py b/tests/test_affected_cluster_relations.py new file mode 100644 index 000000000..30951f82a --- /dev/null +++ b/tests/test_affected_cluster_relations.py @@ -0,0 +1,43 @@ +"""`affected` must traverse the cross-repo relations added by cluster links.""" +import networkx as nx + +from graphify.affected import DEFAULT_AFFECTED_RELATIONS, affected_nodes + + +def _cluster_shaped_graph(): + """A composed cluster graph in miniature: two repos + spec-declared links.""" + G = nx.Graph() + G.add_node("web::client", label="cube-client.ts", repo="web", + source_file="app/lib/cube/cube-client.ts") + G.add_node("svc::server", label="cube.js", repo="svc", source_file="cube.js") + G.add_node("web::payload", label="payload.ts", repo="web", + source_file="src/types/payload.ts") + G.add_node("svc::payload", label="payload.ts", repo="svc", + source_file="src/payload.ts") + # Stored orientation is (dependent, dependency), matching how + # apply_spec_links adds from->to edges. + G.add_edge("web::client", "svc::server", relation="calls_api", + origin="cluster_spec", _src="web::client", _tgt="svc::server") + G.add_edge("web::payload", "svc::payload", relation="mirrors", + origin="cluster_spec", _src="web::payload", _tgt="svc::payload") + return G + + +def test_default_relations_include_cluster_relations(): + assert "calls_api" in DEFAULT_AFFECTED_RELATIONS + assert "mirrors" in DEFAULT_AFFECTED_RELATIONS + # depends_on predates clusters and must stay out of the defaults (it would + # change single-repo affected behavior through manifest dependency edges). + assert "depends_on" not in DEFAULT_AFFECTED_RELATIONS + + +def test_affected_crosses_repo_boundary_via_calls_api(): + G = _cluster_shaped_graph() + hits = affected_nodes(G, "svc::server", depth=2) + assert any(h.node_id == "web::client" and h.via_relation == "calls_api" for h in hits) + + +def test_affected_crosses_repo_boundary_via_mirrors(): + G = _cluster_shaped_graph() + hits = affected_nodes(G, "svc::payload", depth=2) + assert any(h.node_id == "web::payload" and h.via_relation == "mirrors" for h in hits) From 07fb19d62675a059728974c60bb8a3cf266a8a35 Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:49:37 -0400 Subject: [PATCH 04/11] feat(manifest): normalized package identities on package nodes Package-manifest nodes carry package_key and dependency_keys (ecosystem-aware normalization) so cluster auto-linking can match a member's dependency to the member that provides it without re-parsing manifests. --- graphify/manifest_ingest.py | 31 +++++++++++++++++++++++++++---- tests/test_manifest_ingest.py | 13 +++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/graphify/manifest_ingest.py b/graphify/manifest_ingest.py index ae3aa61fc..053f3cde1 100644 --- a/graphify/manifest_ingest.py +++ b/graphify/manifest_ingest.py @@ -22,7 +22,12 @@ from graphify.ids import make_id -__all__ = ["is_package_manifest_path", "extract_package_manifest", "PACKAGE_MANIFEST_NAMES"] +__all__ = [ + "is_package_manifest_path", + "extract_package_manifest", + "normalize_package_identity", + "PACKAGE_MANIFEST_NAMES", +] # manifest filename (lowercased) -> ecosystem tag PACKAGE_MANIFEST_NAMES: dict[str, str] = { @@ -48,6 +53,19 @@ def _pkg_id(name: str) -> str: return make_id("pkg", name) +def normalize_package_identity(ecosystem: str, name: str) -> str: + """Return the cross-repository identity used by cluster auto-linking.""" + ecosystem = ecosystem.strip().casefold() + normalized = name.strip() + if ecosystem == "python": + # PEP 503: package names compare case-insensitively and runs of + # hyphen/underscore/dot are equivalent. + normalized = re.sub(r"[-_.]+", "-", normalized).casefold() + elif ecosystem == "apm": + normalized = normalized.casefold() + return f"{ecosystem}:{normalized}" + + def extract_package_manifest(path: Path) -> dict[str, Any]: """Parse a package manifest into a canonical package node + ``depends_on`` edges.""" try: @@ -76,6 +94,7 @@ def extract_package_manifest(path: Path) -> dict[str, Any]: "ecosystem": eco, "source_file": str_path, "source_location": "L1", + "package_key": normalize_package_identity(eco, name), } if info.get("version"): node["version"] = info["version"] @@ -83,13 +102,16 @@ def extract_package_manifest(path: Path) -> dict[str, Any]: edges: list[dict] = [] seen: set[str] = set() + dependency_keys: list[str] = [] for dep in info.get("deps", []): if not dep: continue - dep_nid = _pkg_id(dep) - if dep_nid == pkg_nid or dep_nid in seen: + dep_key = normalize_package_identity(eco, str(dep)) + if dep_key in seen or dep_key == node["package_key"]: continue - seen.add(dep_nid) + seen.add(dep_key) + dependency_keys.append(dep_key) + dep_nid = _pkg_id(dep) # The edge targets the dependency's canonical package id. If that package's # own manifest is in the corpus, the edge resolves to its (single) node; if # the dependency is external, build_from_json prunes the dangling edge. We @@ -106,6 +128,7 @@ def extract_package_manifest(path: Path) -> dict[str, Any]: "source_location": "L1", "weight": 1.0, }) + node["dependency_keys"] = dependency_keys return {"nodes": nodes, "edges": edges} diff --git a/tests/test_manifest_ingest.py b/tests/test_manifest_ingest.py index 2b97f5fb9..c43571e9a 100644 --- a/tests/test_manifest_ingest.py +++ b/tests/test_manifest_ingest.py @@ -8,6 +8,7 @@ from graphify.manifest_ingest import ( extract_package_manifest, is_package_manifest_path, + normalize_package_identity, ) @@ -52,6 +53,18 @@ def test_pyproject_parses_pep508_deps(tmp_path): assert _pkg_nodes(r)[0]["label"] == "cool-lib" deps = {e["target"] for e in r["edges"]} assert {"pkg_requests", "pkg_rich", "pkg_tomli"} <= deps # versions/extras/markers stripped + pkg = _pkg_nodes(r)[0] + assert pkg["package_key"] == "python:cool-lib" + assert pkg["dependency_keys"] == [ + "python:requests", "python:rich", "python:tomli" + ] + + +def test_package_identity_normalization_is_ecosystem_specific(): + assert normalize_package_identity("python", "My_Pkg.Name") == "python:my-pkg-name" + assert normalize_package_identity("apm", "Shared-Pkg") == "apm:shared-pkg" + assert normalize_package_identity("go", "Example.com/Org/Mod") == "go:Example.com/Org/Mod" + assert normalize_package_identity("maven", "Com.Acme:Core") == "maven:Com.Acme:Core" def test_gomod_parses_module_and_requires(tmp_path): From ab988c63293605fa671f48a9ffef116c3f070626 Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:49:37 -0400 Subject: [PATCH 05/11] feat(cluster): portable cluster-ref.json markers and --cluster on query surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cluster build writes a committable marker 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 [NAME], no-match failures note the membership (CLI and MCP), and the search-nudge hook surfaces it to assistants. Marker fields are sanitized before reaching any assistant-facing output — the marker travels with clones and is untrusted. Without the cluster locally, --cluster explains exactly what to clone and build; all passive surfaces are fail-open. --- graphify/cluster_ref.py | 162 +++++++++++ graphify/serve.py | 48 +++- tests/test_cluster_refs.py | 536 +++++++++++++++++++++++++++++++++++++ 3 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 graphify/cluster_ref.py create mode 100644 tests/test_cluster_refs.py diff --git a/graphify/cluster_ref.py b/graphify/cluster_ref.py new file mode 100644 index 000000000..1ca882628 --- /dev/null +++ b/graphify/cluster_ref.py @@ -0,0 +1,162 @@ +"""Member-side cluster memberships (``graphify-out/cluster-ref.json``). + +``graphify cluster build`` upserts one entry in this committable marker for +each member. A repository can belong to several clusters; every entry avoids +absolute paths and is resolved independently on the current machine. + +This module is deliberately stdlib-only because hook nudges import it on a hot, +fail-open path. Readers therefore return an empty list instead of raising. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +CLUSTER_REF_NAME = "cluster-ref.json" +CLUSTER_REF_VERSION = 1 +_MAX_REF_BYTES = 1_000_000 + + +def load_cluster_refs(out_dir: "Path | str") -> list[dict]: + """Return all valid memberships from the collection marker. + + The Cluster feature is unreleased, so this intentionally accepts only the + collection schema and does not carry a compatibility path for the former + single-membership draft — regenerate old markers with ``cluster build``. + """ + path = Path(out_dir) / CLUSTER_REF_NAME + try: + if not path.is_file() or path.stat().st_size > _MAX_REF_BYTES: + return [] + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return [] + if not isinstance(data, dict) or data.get("version") != CLUSTER_REF_VERSION: + return [] + raw_refs = data.get("clusters") + if not isinstance(raw_refs, list): + return [] + + refs: list[dict] = [] + names: set[str] = set() + for ref in raw_refs: + if not isinstance(ref, dict): + return [] + name = ref.get("cluster_name") + if not isinstance(name, str) or not name or name in names: + return [] + if not isinstance(ref.get("self_tag"), str) or not ref["self_tag"]: + return [] + names.add(name) + refs.append(ref) + return refs + + +def select_cluster_ref(refs: list[dict], name: str | None = None) -> dict: + """Select one membership or raise ``ValueError`` with an actionable error.""" + names = sorted(_clean(ref["cluster_name"]) for ref in refs) + if name is not None: + for ref in refs: + if ref["cluster_name"] == name: + return ref + available = ", ".join(names) or "none" + raise ValueError(f"unknown cluster {name!r}; available clusters: {available}") + if len(refs) == 1: + return refs[0] + if not refs: + raise ValueError("this repo has no cluster memberships") + raise ValueError( + "this repo belongs to multiple clusters; choose one with --cluster NAME " + f"({', '.join(names)})" + ) + + +def _clean(value) -> str: + """Sanitize one marker field for assistant/terminal-facing text. + + The marker is a committed file that travels with clones, so its fields are + untrusted. security.py is stdlib-only, so the lazy import preserves this + module's light-import contract for the hook path. + """ + from .security import sanitize_label + + return sanitize_label(str(value)) + + +def _member_count(ref: dict) -> "int | str": + try: + return int(ref.get("member_count", 0)) or "?" + except (TypeError, ValueError): + return "?" + + +def cluster_hint_line(refs: list[dict]) -> str: + """One-line member hint appended to no-match/empty results.""" + if not refs: + return "" + if len(refs) == 1: + ref = refs[0] + return ( + f"note: this repo is member '{_clean(ref['self_tag'])}' of cluster " + f"'{_clean(ref['cluster_name'])}' ({_member_count(ref)} members) — " + "cross-repo answers may need the cluster graph; re-run with --cluster" + ) + names = ", ".join(sorted(_clean(ref["cluster_name"]) for ref in refs)) + return ( + f"note: this repo belongs to {len(refs)} clusters ({names}) — cross-repo " + "answers may need a cluster graph; re-run with --cluster NAME" + ) + + +def unresolvable_message(ref: dict) -> str: + """Actionable message when one selected cluster is unavailable locally.""" + name = _clean(ref["cluster_name"]) + base = ( + f"this repo is member '{_clean(ref['self_tag'])}' of cluster " + f"'{name}' ({_member_count(ref)} members) " + "but the cluster isn't available locally" + ) + url = _clean(ref.get("cluster_url") or "") + if url: + return ( + f"{base}; clone {url} next to this repo and run " + f"'graphify cluster build' there, then re-run with " + f"--cluster {name}" + ) + return ( + f"{base} and has no recorded remote; create it with " + f"'graphify cluster init --name {name}', add the " + "members, and run 'graphify cluster build'" + ) + + +def _spec_name_at(candidate: Path, want_name: str) -> bool: + from .cluster_graph import find_spec_file, load_spec + + try: + return find_spec_file(candidate) is not None and load_spec(candidate).name == want_name + except Exception: + return False + + +def resolve_cluster_dir(ref: dict, member_root: "Path | str") -> Path | None: + """Resolve one membership via its hint, then sibling discovery.""" + member_root = Path(member_root) + want_name = ref["cluster_name"] + hint = ref.get("dir_hint") or "" + if hint: + candidate = Path(os.path.normpath(member_root / hint)) + if _spec_name_at(candidate, want_name): + return candidate + + try: + parent = member_root.resolve().parent + children = sorted(c for c in parent.iterdir() if c.is_dir()) + except OSError: + return None + resolved_root = member_root.resolve() + for child in children: + if child != resolved_root and _spec_name_at(child, want_name): + return child + return None diff --git a/graphify/serve.py b/graphify/serve.py index f32a91673..245f5905f 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1187,6 +1187,42 @@ def _select_graph(project_path) -> None: G, communities = _load_ctx(path) active_graph_path = path + def _cluster_note() -> str: + """Member-of-cluster hint appended to no-match answers, or ''. + + Computed per call — active_graph_path rebinds per project_path. MCP has + no --cluster flag, so the wording points at the cluster dir instead. + Never raises. + """ + try: + from graphify.cluster_ref import load_cluster_refs + + refs = load_cluster_refs(Path(active_graph_path).parent) + if not refs: + return "" + # The marker is a committed file: its fields are untrusted input to + # assistant-facing output, so they pass sanitize_label like every + # other non-literal field this server emits. + if len(refs) == 1: + ref = refs[0] + return ( + f"\nnote: this repo is member '{sanitize_label(str(ref['self_tag']))}' of cluster " + f"'{sanitize_label(str(ref['cluster_name']))}' " + f"({sanitize_label(str(ref.get('member_count', '?')))} members) — " + f"cross-repo answers live in the cluster graph (query it from the " + f"cluster directory, or via `graphify ... --cluster` on the CLI)." + ) + names = ", ".join( + sorted(sanitize_label(str(ref["cluster_name"])) for ref in refs) + ) + return ( + f"\nnote: this repo belongs to {len(refs)} clusters ({names}) — " + f"cross-repo answers live in a cluster graph (query it from that " + f"cluster's directory, or via `graphify ... --cluster NAME` on the CLI)." + ) + except Exception: + return "" + server = Server("graphify") @server.list_tools() @@ -1348,6 +1384,10 @@ def _tool_query_graph(arguments: dict) -> str: token_budget=budget, context_filters=context_filter, ) + if result.startswith("No matching nodes found."): + # Same cluster-membership note the other no-match tools append, + # added before logging so the query log matches the response. + result += _cluster_note() querylog.log_query( kind="mcp_query", question=question, @@ -1365,7 +1405,7 @@ def _tool_get_node(arguments: dict) -> str: matches = [(nid, d) for nid, d in G.nodes(data=True) if label in (d.get("label") or "").lower() or label == nid.lower()] if not matches: - return f"No node matching '{label}' found." + return f"No node matching '{label}' found." + _cluster_note() nid, d = matches[0] # Sanitise every LLM-derived field before concatenation (F-010). return "\n".join([ @@ -1382,7 +1422,7 @@ def _tool_get_neighbors(arguments: dict) -> str: rel_filter = arguments.get("relation_filter", "").lower() matches = _find_node(G, label) if not matches: - return f"No node matching '{label}' found." + return f"No node matching '{label}' found." + _cluster_note() nid = matches[0] lines = [f"Neighbors of {sanitize_label(G.nodes[nid].get('label', nid))}:"] def _edge_at(d: dict) -> str: @@ -1458,9 +1498,9 @@ def _tool_shortest_path(arguments: dict) -> str: src_scored = _score_nodes(G, [t.lower() for t in arguments["source"].split()]) tgt_scored = _score_nodes(G, [t.lower() for t in arguments["target"].split()]) if not src_scored: - return f"No node matching source '{arguments['source']}' found." + return f"No node matching source '{arguments['source']}' found." + _cluster_note() if not tgt_scored: - return f"No node matching target '{arguments['target']}' found." + return f"No node matching target '{arguments['target']}' found." + _cluster_note() src_nid = _pick_scored_endpoint(G, src_scored, arguments["source"]) tgt_nid = _pick_scored_endpoint(G, tgt_scored, arguments["target"]) # Ambiguity guard: when both queries resolve to the same node, the diff --git a/tests/test_cluster_refs.py b/tests/test_cluster_refs.py new file mode 100644 index 000000000..9fc6ed1f0 --- /dev/null +++ b/tests/test_cluster_refs.py @@ -0,0 +1,536 @@ +"""Member cluster-refs: the cluster-ref.json marker, --cluster flag, and hints.""" +import json +import sys + +import pytest + +from graphify.cluster_graph import ClusterSpecError, build_cluster +from graphify.cluster_ref import ( + CLUSTER_REF_NAME, + load_cluster_refs, + resolve_cluster_dir, + unresolvable_message, +) +from tests.test_cluster_build import make_member, write_cluster, _node +from tests.test_cluster_cli import _fake_checkout, _run + + +@pytest.fixture() +def built_cluster(tmp_path): + """Two members + one declared link, cluster built once.""" + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + make_member(tmp_path, "beta", [_node("server", source_file="src/server.ts")]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [ + {"tag": "alpha", "path": "../alpha"}, + {"tag": "beta", "path": "../beta"}, + ], links=[{ + "type": "api_call", + "from": {"repo": "alpha", "file": "src/app.ts"}, + "to": {"repo": "beta", "file": "src/server.ts"}, + }]) + build_cluster(cluster) + return cluster + + +def _marker(tmp_path, member): + return tmp_path / member / "graphify-out" / CLUSTER_REF_NAME + + +def _only_ref(out_dir): + refs = load_cluster_refs(out_dir) + assert len(refs) == 1 + return refs[0] + + +# --------------------------------------------------------------------------- +# Writing markers +# --------------------------------------------------------------------------- + +def test_build_writes_portable_markers(tmp_path, built_cluster): + for member, tag in (("alpha", "alpha"), ("beta", "beta")): + raw = _marker(tmp_path, member).read_text(encoding="utf-8") + marker = json.loads(raw) + assert marker["version"] == 1 + assert len(marker["clusters"]) == 1 + ref = marker["clusters"][0] + assert ref["cluster_name"] == "test-cluster" + assert ref["self_tag"] == tag + assert ref["member_count"] == 2 + assert [m["tag"] for m in ref["members"]] == ["alpha", "beta"] + assert ref["built_at"] + # Committable: no absolute paths anywhere in the marker. + assert str(tmp_path) not in raw + assert ref["dir_hint"] == "../cluster" + + +def test_cluster_url_recorded_from_cluster_dir_origin(tmp_path): + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "alpha", "path": "../alpha"}]) + _fake_checkout(cluster, "https://github.com/org/my-cluster") + build_cluster(cluster) + ref = _only_ref(tmp_path / "alpha" / "graphify-out") + assert ref["cluster_url"] == "https://github.com/org/my-cluster" + + +def test_cluster_url_empty_without_git(tmp_path, built_cluster): + ref = _only_ref(tmp_path / "alpha" / "graphify-out") + assert ref["cluster_url"] == "" + + +def test_skip_branch_backfills_missing_marker_only(tmp_path, built_cluster): + alpha_marker = _marker(tmp_path, "alpha") + beta_marker = _marker(tmp_path, "beta") + alpha_marker.unlink() + beta_before = beta_marker.read_bytes() + beta_mtime = beta_marker.stat().st_mtime_ns + + summary = build_cluster(built_cluster) + assert summary["skipped"] + assert summary["refs_written"] == 1 + assert alpha_marker.is_file() + assert beta_marker.read_bytes() == beta_before + assert beta_marker.stat().st_mtime_ns == beta_mtime + + +def test_member_can_keep_multiple_cluster_memberships(tmp_path, built_cluster): + make_member(tmp_path, "gamma", [_node("worker", source_file="src/worker.ts")]) + other = tmp_path / "other-cluster" + write_cluster(other, [ + {"tag": "alpha", "path": "../alpha"}, + {"tag": "gamma", "path": "../gamma"}, + ], links=[{ + "type": "references", + "from": {"repo": "alpha", "id": "app"}, + "to": {"repo": "gamma", "id": "worker"}, + }]) + spec_path = other / "cluster.json" + spec = json.loads(spec_path.read_text(encoding="utf-8")) + spec["name"] = "other-cluster" + spec_path.write_text(json.dumps(spec), encoding="utf-8") + build_cluster(other) + + refs = load_cluster_refs(tmp_path / "alpha" / "graphify-out") + assert [ref["cluster_name"] for ref in refs] == ["other-cluster", "test-cluster"] + + +def test_duplicate_cluster_name_across_remotes_is_rejected_before_writes(tmp_path): + """Two clusters with the same name but DIFFERENT git remotes are a genuine + collision: the build fails before any output is written, and keeps + failing on retry (the check runs ahead of the unchanged-inputs skip).""" + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + first = tmp_path / "first" + write_cluster(first, [{"tag": "alpha", "path": "../alpha"}]) + _fake_checkout(first, "https://github.com/org/first-cluster") + build_cluster(first) + + duplicate = tmp_path / "duplicate" + write_cluster(duplicate, [{"tag": "alpha", "path": "../alpha"}]) + _fake_checkout(duplicate, "https://github.com/org/other-cluster") + + for _attempt in range(2): # sticky: the second run must not skip-and-pass + with pytest.raises(ClusterSpecError, match="cluster names must be unique"): + build_cluster(duplicate) + assert not (duplicate / "graphify-out").exists() + assert _only_ref(tmp_path / "alpha" / "graphify-out")["cluster_url"] == ( + "https://github.com/org/first-cluster" + ) + + +def test_moved_cluster_without_remote_rebuilds_and_refreshes_hint(tmp_path, capsys): + """A dir_hint mismatch alone is not a name collision — a no-remote cluster + that was moved (or laid out differently on another machine) must rebuild + with a warning and refresh the marker, not hard-error.""" + import shutil + + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + old_home = tmp_path / "clusters-old" / "demo" + write_cluster(old_home, [{"tag": "alpha", "path": "../../alpha"}]) + build_cluster(old_home) + assert _only_ref(tmp_path / "alpha" / "graphify-out")["dir_hint"].startswith( + "../clusters-old" + ) + + new_home = tmp_path / "clusters-new" / "demo" + new_home.parent.mkdir() + shutil.move(str(old_home), str(new_home)) # same depth: the path hint still resolves + + summary = build_cluster(new_home, force=True) + assert not summary["skipped"] + assert "updating it" in capsys.readouterr().err + assert _only_ref(tmp_path / "alpha" / "graphify-out")["dir_hint"].startswith( + "../clusters-new" + ) + + +def test_named_cluster_selection_and_ambiguous_bare_flag( + tmp_path, built_cluster, monkeypatch, capsys +): + make_member(tmp_path, "gamma", [_node("worker", source_file="src/worker.ts")]) + other = tmp_path / "other-cluster" + write_cluster(other, [ + {"tag": "alpha", "path": "../alpha"}, + {"tag": "gamma", "path": "../gamma"}, + ], links=[{ + "type": "references", + "from": {"repo": "alpha", "id": "app"}, + "to": {"repo": "gamma", "id": "worker"}, + }]) + spec_path = other / "cluster.json" + spec = json.loads(spec_path.read_text(encoding="utf-8")) + spec["name"] = "other-cluster" + spec_path.write_text(json.dumps(spec), encoding="utf-8") + build_cluster(other) + + monkeypatch.chdir(tmp_path / "alpha") + code, out, err = _dispatch( + ["explain", "worker", "--cluster", "other-cluster"], monkeypatch, capsys + ) + assert code == 0 and "Node: worker" in out + + code, _out, err = _dispatch(["explain", "worker", "--cluster"], monkeypatch, capsys) + assert code == 1 + assert "belongs to multiple clusters" in err + assert "other-cluster" in err and "test-cluster" in err + + +def test_no_refs_opt_out(tmp_path): + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "alpha", "path": "../alpha"}]) + summary = build_cluster(cluster, write_refs=False) + assert summary["refs_written"] == 0 + assert not _marker(tmp_path, "alpha").exists() + + +def test_cli_build_no_refs_and_remove_cleanup(tmp_path, capsys): + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "alpha", "path": "../alpha"}]) + + code, out, _err = _run(["build", "--dir", str(cluster), "--no-refs"], capsys) + assert code == 0 + assert not _marker(tmp_path, "alpha").exists() + + code, out, _err = _run(["build", "--dir", str(cluster), "--force"], capsys) + assert code == 0 and "cluster-refs: wrote 1" in out + assert _marker(tmp_path, "alpha").is_file() + + code, out, err = _run(["remove", "alpha", "--dir", str(cluster)], capsys) + assert code == 0 and "also removed its cluster-ref.json" in out + assert not _marker(tmp_path, "alpha").exists() + + +def test_cli_remove_unresolvable_member_soft_note(tmp_path, capsys): + cluster = tmp_path / "cluster" + write_cluster(cluster, [{"tag": "ghost", "url": "https://github.com/org/ghost"}]) + code, _out, err = _run(["remove", "ghost", "--dir", str(cluster)], capsys) + assert code == 0 + assert "left in place" in err + + +# --------------------------------------------------------------------------- +# Reading + resolving markers +# --------------------------------------------------------------------------- + +def test_load_cluster_refs_fail_soft(tmp_path): + out = tmp_path / "graphify-out" + out.mkdir() + assert load_cluster_refs(out) == [] # missing + marker = out / CLUSTER_REF_NAME + marker.write_text("{not json", encoding="utf-8") + assert load_cluster_refs(out) == [] # corrupt + marker.write_text('["a list"]', encoding="utf-8") + assert load_cluster_refs(out) == [] # non-dict + marker.write_text('{"version": 1, "clusters": {}}', encoding="utf-8") + assert load_cluster_refs(out) == [] # clusters is not a list + marker.write_text( + '{"version": 99, "clusters": []}', encoding="utf-8" + ) + assert load_cluster_refs(out) == [] # unsupported version + marker.write_text( + '{"version": 1, "clusters": [{"cluster_name": "x", "self_tag": "a"}]}', + encoding="utf-8", + ) + assert load_cluster_refs(out)[0]["cluster_name"] == "x" + marker.write_text( # draft-era flat marker: clean break, regenerate via build + '{"version": 1, "cluster_name": "x", "self_tag": "a"}', encoding="utf-8" + ) + assert load_cluster_refs(out) == [] + + +def test_resolve_cluster_dir_via_hint_then_discovery(tmp_path, built_cluster): + ref = _only_ref(tmp_path / "alpha" / "graphify-out") + assert resolve_cluster_dir(ref, tmp_path / "alpha") == tmp_path / "cluster" + + # Stale hint: move the cluster; discovery over parent siblings finds it. + moved = tmp_path / "relocated-cluster" + (tmp_path / "cluster").rename(moved) + assert resolve_cluster_dir(ref, tmp_path / "alpha") == moved + + # Name mismatch is rejected everywhere. + spec = json.loads((moved / "cluster.json").read_text(encoding="utf-8")) + spec["name"] = "some-other-cluster" + (moved / "cluster.json").write_text(json.dumps(spec), encoding="utf-8") + assert resolve_cluster_dir(ref, tmp_path / "alpha") is None + + +def test_unresolvable_message_variants(): + with_url = {"cluster_name": "c", "self_tag": "a", "member_count": 3, + "cluster_url": "https://github.com/org/c"} + msg = unresolvable_message(with_url) + assert "clone https://github.com/org/c" in msg and "member 'a'" in msg + without = dict(with_url, cluster_url="") + msg = unresolvable_message(without) + assert "graphify cluster init" in msg and "no recorded remote" in msg + + +# --------------------------------------------------------------------------- +# --cluster flag + hints through the real CLI dispatch +# --------------------------------------------------------------------------- + +def _dispatch(argv, monkeypatch, capsys): + from graphify.cli import dispatch_command + + monkeypatch.setattr(sys, "argv", ["graphify"] + argv) + code = 0 + try: + dispatch_command(argv[0]) + except SystemExit as exc: + code = exc.code or 0 + out, err = capsys.readouterr() + return code, out, err + + +def test_cluster_flag_end_to_end(tmp_path, built_cluster, monkeypatch, capsys): + monkeypatch.chdir(tmp_path / "alpha") + # Local graph has no 'server' node; the cluster graph does. + code, out, err = _dispatch(["path", "app.ts", "server.ts", "--cluster"], monkeypatch, capsys) + assert code == 0, err + assert "calls_api" in out + + code, out, err = _dispatch(["explain", "server", "--cluster"], monkeypatch, capsys) + assert code == 0 + assert "No node matching" not in out + + # query --cluster is the README's headline example: the answer lives one + # repo over, so it must surface the other member's node. + code, out, err = _dispatch(["query", "server", "--cluster"], monkeypatch, capsys) + assert code == 0, err + assert "beta::server" in out or "server.ts" in out + assert "No matching nodes found." not in out + + +def test_cluster_flag_mutually_exclusive_with_graph(tmp_path, built_cluster, monkeypatch, capsys): + monkeypatch.chdir(tmp_path / "alpha") + code, _out, err = _dispatch( + ["path", "a", "b", "--cluster", "--graph", "x.json"], monkeypatch, capsys + ) + assert code == 1 and "mutually exclusive" in err + + +def test_cluster_flag_without_marker(tmp_path, monkeypatch, capsys): + make_member(tmp_path, "solo", [_node("app", source_file="src/app.ts")]) + monkeypatch.chdir(tmp_path / "solo") + code, _out, err = _dispatch(["explain", "app.ts", "--cluster"], monkeypatch, capsys) + assert code == 1 and "not a known cluster member" in err + + +def test_cluster_flag_unresolvable_names_clone_url(tmp_path, built_cluster, monkeypatch, capsys): + import shutil + + # Record a remote for the cluster, rebuild markers, then delete the cluster. + _fake_checkout(built_cluster, "https://github.com/org/the-cluster") + build_cluster(built_cluster, force=True) + shutil.rmtree(built_cluster) + monkeypatch.chdir(tmp_path / "alpha") + code, _out, err = _dispatch(["explain", "server.ts", "--cluster"], monkeypatch, capsys) + assert code == 1 + assert "clone https://github.com/org/the-cluster" in err + + +def test_cluster_found_but_unbuilt(tmp_path, built_cluster, monkeypatch, capsys): + import shutil + + shutil.rmtree(built_cluster / "graphify-out") + monkeypatch.chdir(tmp_path / "alpha") + code, _out, err = _dispatch(["explain", "server.ts", "--cluster"], monkeypatch, capsys) + assert code == 1 and "no built graph" in err + + +def test_hints_on_failures_with_marker(tmp_path, built_cluster, monkeypatch, capsys): + monkeypatch.chdir(tmp_path / "alpha") + code, _out, err = _dispatch(["path", "app.ts", "no-such-thing"], monkeypatch, capsys) + assert code == 1 + assert "member 'alpha' of cluster 'test-cluster'" in err + + code, out, _err = _dispatch(["explain", "no-such-thing"], monkeypatch, capsys) + assert code == 0 + assert "member 'alpha' of cluster 'test-cluster'" in out + + code, out, _err = _dispatch(["affected", "no-such-thing"], monkeypatch, capsys) + assert "member 'alpha' of cluster 'test-cluster'" in out + + # query gets the same breadcrumb — it is the surface the hook text + # explicitly promises it for. + code, out, _err = _dispatch(["query", "zz-no-such-thing"], monkeypatch, capsys) + assert "No matching nodes found." in out + assert "member 'alpha' of cluster 'test-cluster'" in out + + +def test_no_hint_without_marker_or_on_explicit_graph(tmp_path, built_cluster, monkeypatch, capsys): + make_member(tmp_path, "solo", [_node("app", source_file="src/app.ts")]) + monkeypatch.chdir(tmp_path / "solo") + code, out, err = _dispatch(["explain", "no-such-thing"], monkeypatch, capsys) + assert "cluster" not in out + err + + # Explicit --graph never hints, even when the CWD marker exists. + monkeypatch.chdir(tmp_path / "alpha") + other = tmp_path / "solo" / "graphify-out" / "graph.json" + code, out, err = _dispatch(["explain", "no-such-thing", "--graph", str(other)], monkeypatch, capsys) + assert "cluster" not in out + err + + +def test_corrupt_marker_never_hints_or_breaks(tmp_path, built_cluster, monkeypatch, capsys): + _marker_path = tmp_path / "alpha" / "graphify-out" / CLUSTER_REF_NAME + _marker_path.write_text("{corrupt", encoding="utf-8") + monkeypatch.chdir(tmp_path / "alpha") + code, out, err = _dispatch(["explain", "no-such-thing"], monkeypatch, capsys) + assert code == 0 + assert "cluster" not in out + err + + +# --------------------------------------------------------------------------- +# Hook nudge +# --------------------------------------------------------------------------- + +def _run_search_hook(monkeypatch, capsys): + from graphify.cli import _run_hook_guard + + monkeypatch.setattr( + sys, "stdin", + type("S", (), {"buffer": __import__("io").BytesIO( + json.dumps({"tool_input": {"command": "grep -r foo ."}}).encode() + )})(), + ) + _run_hook_guard("search", strict=False) + out, _err = capsys.readouterr() + return out + + +def test_hook_nudge_gains_cluster_line(tmp_path, built_cluster, monkeypatch, capsys): + monkeypatch.chdir(tmp_path / "alpha") + out = _run_search_hook(monkeypatch, capsys) + payload = json.loads(out) # still valid JSON + ctx = payload["hookSpecificOutput"]["additionalContext"] + assert "member 'alpha' of cluster 'test-cluster'" in ctx + + +def test_hook_nudge_unchanged_without_marker(tmp_path, monkeypatch, capsys): + from graphify.cli import _SEARCH_NUDGE + + make_member(tmp_path, "solo", [_node("app", source_file="src/app.ts")]) + monkeypatch.chdir(tmp_path / "solo") + out = _run_search_hook(monkeypatch, capsys) + assert out == _SEARCH_NUDGE # byte-identical + + +# --------------------------------------------------------------------------- +# MCP serve +# --------------------------------------------------------------------------- + +def test_serve_no_match_includes_cluster_note(tmp_path, built_cluster): + mcp_types = pytest.importorskip("mcp").types + import asyncio + from graphify.serve import _build_server + + server = _build_server(str(tmp_path / "alpha" / "graphify-out" / "graph.json")) + handler = server.request_handlers[mcp_types.CallToolRequest] + req = mcp_types.CallToolRequest( + method="tools/call", + params=mcp_types.CallToolRequestParams( + name="get_node", arguments={"label": "zz-no-such-node"} + ), + ) + text = asyncio.run(handler(req)).root.content[0].text + assert "No node matching" in text + assert "member 'alpha' of cluster 'test-cluster'" in text + + # query_graph no-match gets the same note. + req = mcp_types.CallToolRequest( + method="tools/call", + params=mcp_types.CallToolRequestParams( + name="query_graph", arguments={"question": "zz-no-such-thing"} + ), + ) + text = asyncio.run(handler(req)).root.content[0].text + assert "No matching nodes found." in text + assert "member 'alpha' of cluster 'test-cluster'" in text + + +def test_urlless_duplicate_cluster_name_is_rejected(tmp_path): + """Two URL-less clusters with the same name sharing a member collide when + the member's marker hint still resolves to the other (existing) cluster.""" + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + first = tmp_path / "first" + write_cluster(first, [{"tag": "alpha", "path": "../alpha"}]) + build_cluster(first) + + duplicate = tmp_path / "duplicate" + write_cluster(duplicate, [{"tag": "alpha", "path": "../alpha"}]) + with pytest.raises(ClusterSpecError, match="unique"): + build_cluster(duplicate) + assert not (duplicate / "graphify-out").exists() + + +def test_urlless_cluster_cannot_claim_url_tracked_name(tmp_path): + """An existing marker that carries a cluster_url owns the name; a URL-less + cluster directory cannot silently take it over (that overwrite would drop + the real cluster's URL from the member's marker).""" + make_member(tmp_path, "alpha", [_node("app", source_file="src/app.ts")]) + tracked = tmp_path / "tracked" + write_cluster(tracked, [{"tag": "alpha", "path": "../alpha"}]) + _fake_checkout(tracked, "https://github.com/org/tracked-cluster") + build_cluster(tracked) + + impostor = tmp_path / "impostor" + write_cluster(impostor, [{"tag": "alpha", "path": "../alpha"}]) + with pytest.raises(ClusterSpecError, match="no origin remote"): + build_cluster(impostor) + # The member's marker still points at the URL-tracked owner. + assert _only_ref(tmp_path / "alpha" / "graphify-out")["cluster_url"] == ( + "https://github.com/org/tracked-cluster" + ) + + +def test_marker_fields_are_sanitized_in_hook_and_hints(tmp_path, built_cluster, monkeypatch, capsys): + """The marker is committed and travels with clones: hostile field values + must not reach hook context, hints, or error messages unsanitized.""" + evil_name = "evil\x1b]0;pwned\x07" + "A" * 10_000 + marker_path = _marker(tmp_path, "alpha") + marker = json.loads(marker_path.read_text(encoding="utf-8")) + marker["clusters"][0]["cluster_name"] = evil_name + marker["clusters"][0]["self_tag"] = "tag\x1b[31m" + marker["clusters"][0]["member_count"] = "2; rm -rf /" + marker["clusters"][0]["cluster_url"] = "https://x.test/\x1b[0m" + marker_path.write_text(json.dumps(marker), encoding="utf-8") + + monkeypatch.chdir(tmp_path / "alpha") + hook_out = _run_search_hook(monkeypatch, capsys) + ctx = json.loads(hook_out)["hookSpecificOutput"]["additionalContext"] + assert "\x1b" not in ctx and "\x07" not in ctx + assert "A" * 300 not in ctx # long fields are capped + + from graphify.cluster_ref import ( + cluster_hint_line, + load_cluster_refs, + unresolvable_message, + ) + + refs = load_cluster_refs(tmp_path / "alpha" / "graphify-out") + for text in (cluster_hint_line(refs), unresolvable_message(refs[0])): + assert "\x1b" not in text and "\x07" not in text + assert "A" * 300 not in text + assert "rm -rf" not in text or "?" in text # count coerced to int-or-? + assert "(? members)" in cluster_hint_line(refs) From 31f19b65136cf585c55891536405c7c5569705fd Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:49:38 -0400 Subject: [PATCH 06/11] docs(cluster): README/ARCHITECTURE/CHANGELOG + cluster-membership awareness in skill bodies Skill fragments and every generated per-host skill gain the cluster-member note (sanctioned in skillgen's drift validator); README documents the spec format, selector semantics, direction, marker asymmetry, and CLI surface. --- ARCHITECTURE.md | 2 + CHANGELOG.md | 7 ++ README.md | 85 +++++++++++++++++++ graphify/skill-agents.md | 4 +- graphify/skill-aider.md | 2 + graphify/skill-amp.md | 4 +- graphify/skill-claw.md | 4 +- graphify/skill-codex.md | 4 +- graphify/skill-copilot.md | 4 +- graphify/skill-devin.md | 2 + graphify/skill-droid.md | 4 +- graphify/skill-kilo.md | 4 +- graphify/skill-kiro.md | 4 +- graphify/skill-opencode.md | 4 +- graphify/skill-pi.md | 4 +- graphify/skill-trae.md | 4 +- graphify/skill-vscode.md | 4 +- graphify/skill-windows.md | 4 +- graphify/skill.md | 4 +- .../expected/graphify__skill-agents.md | 4 +- .../expected/graphify__skill-aider.md | 2 + .../skillgen/expected/graphify__skill-amp.md | 4 +- .../skillgen/expected/graphify__skill-claw.md | 4 +- .../expected/graphify__skill-codex.md | 4 +- .../expected/graphify__skill-copilot.md | 4 +- .../expected/graphify__skill-devin.md | 2 + .../expected/graphify__skill-droid.md | 4 +- .../skillgen/expected/graphify__skill-kilo.md | 4 +- .../skillgen/expected/graphify__skill-kiro.md | 4 +- .../expected/graphify__skill-opencode.md | 4 +- tools/skillgen/expected/graphify__skill-pi.md | 4 +- .../skillgen/expected/graphify__skill-trae.md | 4 +- .../expected/graphify__skill-vscode.md | 4 +- .../expected/graphify__skill-windows.md | 4 +- tools/skillgen/expected/graphify__skill.md | 4 +- tools/skillgen/fragments/core/aider.md | 2 + tools/skillgen/fragments/core/core.md | 4 +- tools/skillgen/fragments/core/devin.md | 2 + tools/skillgen/gen.py | 12 +++ 39 files changed, 205 insertions(+), 29 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5672bf0df..2c073f6f9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 ` 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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e972ee3a..21ce25f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index 0459f57cf..01711e2cf 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ` 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 `), 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 @@ -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) diff --git a/graphify/skill-agents.md b/graphify/skill-agents.md index afb4ecc12..13525f85d 100644 --- a/graphify/skill-agents.md +++ b/graphify/skill-agents.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-aider.md b/graphify/skill-aider.md index 4f03ccbae..7347e0e77 100644 --- a/graphify/skill-aider.md +++ b/graphify/skill-aider.md @@ -915,6 +915,8 @@ Then run Steps 5–9 as normal (label communities, generate viz, benchmark, clea ## For /graphify query +**Cluster member?** If `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` for one membership or `--cluster NAME` for several. If the selected cluster is unavailable, report the CLI's clone/build instructions. + Two traversal modes - choose based on the question: | Mode | Flag | Best for | diff --git a/graphify/skill-amp.md b/graphify/skill-amp.md index afb4ecc12..13525f85d 100644 --- a/graphify/skill-amp.md +++ b/graphify/skill-amp.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md index d98865cc8..1d7eb95e8 100644 --- a/graphify/skill-claw.md +++ b/graphify/skill-claw.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index 0c821a278..cfe98c962 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md index d98865cc8..1d7eb95e8 100644 --- a/graphify/skill-copilot.md +++ b/graphify/skill-copilot.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-devin.md b/graphify/skill-devin.md index e3e6d2dec..fa97c61cb 100644 --- a/graphify/skill-devin.md +++ b/graphify/skill-devin.md @@ -1051,6 +1051,8 @@ Then run Steps 5-9 as normal (label communities, generate viz, benchmark, clean ## For /graphify query +**Cluster member?** If `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` for one membership or `--cluster NAME` for several. If the selected cluster is unavailable, report the CLI's clone/build instructions. + Two traversal modes - choose based on the question: | Mode | Flag | Best for | diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md index c3815d556..6de0e5209 100644 --- a/graphify/skill-droid.md +++ b/graphify/skill-droid.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-kilo.md b/graphify/skill-kilo.md index dbb4658ca..5d0f332b6 100644 --- a/graphify/skill-kilo.md +++ b/graphify/skill-kilo.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md index d98865cc8..1d7eb95e8 100644 --- a/graphify/skill-kiro.md +++ b/graphify/skill-kiro.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md index cf5dae440..848fba30b 100644 --- a/graphify/skill-opencode.md +++ b/graphify/skill-opencode.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md index d98865cc8..1d7eb95e8 100644 --- a/graphify/skill-pi.md +++ b/graphify/skill-pi.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md index b0cbeb122..36dc8ef2d 100644 --- a/graphify/skill-trae.md +++ b/graphify/skill-trae.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md index 3e6bc6b7b..34e102fce 100644 --- a/graphify/skill-vscode.md +++ b/graphify/skill-vscode.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index 574384576..957fccb97 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/graphify/skill.md b/graphify/skill.md index d98865cc8..1d7eb95e8 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-agents.md b/tools/skillgen/expected/graphify__skill-agents.md index afb4ecc12..13525f85d 100644 --- a/tools/skillgen/expected/graphify__skill-agents.md +++ b/tools/skillgen/expected/graphify__skill-agents.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-aider.md b/tools/skillgen/expected/graphify__skill-aider.md index 4f03ccbae..7347e0e77 100644 --- a/tools/skillgen/expected/graphify__skill-aider.md +++ b/tools/skillgen/expected/graphify__skill-aider.md @@ -915,6 +915,8 @@ Then run Steps 5–9 as normal (label communities, generate viz, benchmark, clea ## For /graphify query +**Cluster member?** If `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` for one membership or `--cluster NAME` for several. If the selected cluster is unavailable, report the CLI's clone/build instructions. + Two traversal modes - choose based on the question: | Mode | Flag | Best for | diff --git a/tools/skillgen/expected/graphify__skill-amp.md b/tools/skillgen/expected/graphify__skill-amp.md index afb4ecc12..13525f85d 100644 --- a/tools/skillgen/expected/graphify__skill-amp.md +++ b/tools/skillgen/expected/graphify__skill-amp.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-claw.md b/tools/skillgen/expected/graphify__skill-claw.md index d98865cc8..1d7eb95e8 100644 --- a/tools/skillgen/expected/graphify__skill-claw.md +++ b/tools/skillgen/expected/graphify__skill-claw.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-codex.md b/tools/skillgen/expected/graphify__skill-codex.md index 0c821a278..cfe98c962 100644 --- a/tools/skillgen/expected/graphify__skill-codex.md +++ b/tools/skillgen/expected/graphify__skill-codex.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-copilot.md b/tools/skillgen/expected/graphify__skill-copilot.md index d98865cc8..1d7eb95e8 100644 --- a/tools/skillgen/expected/graphify__skill-copilot.md +++ b/tools/skillgen/expected/graphify__skill-copilot.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-devin.md b/tools/skillgen/expected/graphify__skill-devin.md index e3e6d2dec..fa97c61cb 100644 --- a/tools/skillgen/expected/graphify__skill-devin.md +++ b/tools/skillgen/expected/graphify__skill-devin.md @@ -1051,6 +1051,8 @@ Then run Steps 5-9 as normal (label communities, generate viz, benchmark, clean ## For /graphify query +**Cluster member?** If `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` for one membership or `--cluster NAME` for several. If the selected cluster is unavailable, report the CLI's clone/build instructions. + Two traversal modes - choose based on the question: | Mode | Flag | Best for | diff --git a/tools/skillgen/expected/graphify__skill-droid.md b/tools/skillgen/expected/graphify__skill-droid.md index c3815d556..6de0e5209 100644 --- a/tools/skillgen/expected/graphify__skill-droid.md +++ b/tools/skillgen/expected/graphify__skill-droid.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-kilo.md b/tools/skillgen/expected/graphify__skill-kilo.md index dbb4658ca..5d0f332b6 100644 --- a/tools/skillgen/expected/graphify__skill-kilo.md +++ b/tools/skillgen/expected/graphify__skill-kilo.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-kiro.md b/tools/skillgen/expected/graphify__skill-kiro.md index d98865cc8..1d7eb95e8 100644 --- a/tools/skillgen/expected/graphify__skill-kiro.md +++ b/tools/skillgen/expected/graphify__skill-kiro.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-opencode.md b/tools/skillgen/expected/graphify__skill-opencode.md index cf5dae440..848fba30b 100644 --- a/tools/skillgen/expected/graphify__skill-opencode.md +++ b/tools/skillgen/expected/graphify__skill-opencode.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-pi.md b/tools/skillgen/expected/graphify__skill-pi.md index d98865cc8..1d7eb95e8 100644 --- a/tools/skillgen/expected/graphify__skill-pi.md +++ b/tools/skillgen/expected/graphify__skill-pi.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-trae.md b/tools/skillgen/expected/graphify__skill-trae.md index b0cbeb122..36dc8ef2d 100644 --- a/tools/skillgen/expected/graphify__skill-trae.md +++ b/tools/skillgen/expected/graphify__skill-trae.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-vscode.md b/tools/skillgen/expected/graphify__skill-vscode.md index 3e6bc6b7b..34e102fce 100644 --- a/tools/skillgen/expected/graphify__skill-vscode.md +++ b/tools/skillgen/expected/graphify__skill-vscode.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md index 574384576..957fccb97 100644 --- a/tools/skillgen/expected/graphify__skill-windows.md +++ b/tools/skillgen/expected/graphify__skill-windows.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/expected/graphify__skill.md b/tools/skillgen/expected/graphify__skill.md index d98865cc8..1d7eb95e8 100644 --- a/tools/skillgen/expected/graphify__skill.md +++ b/tools/skillgen/expected/graphify__skill.md @@ -50,7 +50,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/fragments/core/aider.md b/tools/skillgen/fragments/core/aider.md index 4f03ccbae..7347e0e77 100644 --- a/tools/skillgen/fragments/core/aider.md +++ b/tools/skillgen/fragments/core/aider.md @@ -915,6 +915,8 @@ Then run Steps 5–9 as normal (label communities, generate viz, benchmark, clea ## For /graphify query +**Cluster member?** If `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` for one membership or `--cluster NAME` for several. If the selected cluster is unavailable, report the CLI's clone/build instructions. + Two traversal modes - choose based on the question: | Mode | Flag | Best for | diff --git a/tools/skillgen/fragments/core/core.md b/tools/skillgen/fragments/core/core.md index e28910728..3be26bd49 100644 --- a/tools/skillgen/fragments/core/core.md +++ b/tools/skillgen/fragments/core/core.md @@ -47,7 +47,9 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. -**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. +**Cluster member?** Check this before the fast path below: if `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` when there is one membership or `--cluster NAME` when there are several; single-repo questions keep the local commands. If the selected cluster is unavailable, report the CLI's clone/build instructions. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately (with `--cluster` when the cluster-member check above applies and the question spans repos). Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. If no path was given, use `.` (current directory). Do not ask the user for a path. diff --git a/tools/skillgen/fragments/core/devin.md b/tools/skillgen/fragments/core/devin.md index e3e6d2dec..fa97c61cb 100644 --- a/tools/skillgen/fragments/core/devin.md +++ b/tools/skillgen/fragments/core/devin.md @@ -1051,6 +1051,8 @@ Then run Steps 5-9 as normal (label communities, generate viz, benchmark, clean ## For /graphify query +**Cluster member?** If `graphify-out/cluster-ref.json` exists, its `clusters` list names every multi-repo cluster this repo belongs to. For a cross-repo question, use `--cluster` for one membership or `--cluster NAME` for several. If the selected cluster is unavailable, report the CLI's clone/build instructions. + Two traversal modes - choose based on the question: | Mode | Flag | Best for | diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index 732215e8a..70df1f2b0 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -913,6 +913,17 @@ def _is_shebang_allowlist_fix_line(line: str) -> bool: return "[!a-zA-Z0-9/_." in line +def _is_cluster_member_note_line(line: str) -> bool: + """Whether a line is the cluster-membership awareness paragraph. + + Cluster graphs (multi-repo) write a ``cluster-ref.json`` back-reference into + each member repo's graphify-out/; the skill bodies gained one paragraph + telling the assistant to check for it and to use ``--cluster`` (or the + cluster directory) for cross-repo questions. Added, never removed. + """ + return "**Cluster member?**" in line + + def _is_obsidian_usage_comment_line(line: str) -> bool: """Whether a line is part of the ``/graphify`` usage-comment fix (#1681). @@ -974,6 +985,7 @@ def _is_semantic_cache_scope_fix_line(line: str) -> bool: _is_obsidian_usage_comment_line, _is_uv_from_interpreter_fix_line, _is_semantic_cache_scope_fix_line, + _is_cluster_member_note_line, ) From 9f3daf77ff0e29fd5a552f01c7b2407280803835 Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:50:42 -0400 Subject: [PATCH 07/11] fix(watch): pass the project root to the graph builder (#932 alignment) graphify build relativizes source_file paths against the scan root (#932); watch's rebuild called build_from_json with no root, so absolute paths from semantic subagents survived only in watch-produced graphs, re-keying nodes differently across the two build paths. --- graphify/watch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graphify/watch.py b/graphify/watch.py index 1ef1ebd4d..584bff929 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1206,7 +1206,10 @@ def _add_deleted_source(path: Path) -> None: "total_words": detected.get("total_words", 0), } - G = build_from_json(result) + # root=project_root aligns watch rebuilds with `graphify build`'s + # root-relative source_file paths (#932); without it, absolute paths + # from semantic subagents survive in watch-produced graphs only. + G = build_from_json(result, root=project_root) candidate_topology = _topology_from_graph(G) if existing_graph_data: try: From 6f374fba9821cb4bb77e812efa8bad7fb83b13da Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:51:08 -0400 Subject: [PATCH 08/11] =?UTF-8?q?feat(build):=20multigraph=20mode=20?= =?UTF-8?q?=E2=80=94=20keyed=20MultiDiGraphs=20preserving=20parallel=20rel?= =?UTF-8?q?ations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract --multigraph builds a keyed MultiDiGraph on stable content-derived edge keys (stable_edge_key), so several relations between the same node pair survive build, incremental merge (build_merge infers the existing graph's format), watch rebuilds, and the global graph (promote_to_multidigraph). The format is sticky: a later extract (including --force) reads the existing graph's multigraph flag, and --no-multigraph is the explicit downgrade path, warning that parallel relations collapse; conversion in either direction bypasses the no-change early exit and carries the existing graph forward. The edge-sort tiebreak that fixes keyed-collision order is gated to multigraph builds — simple builds keep the upstream sort. --- graphify/build.py | 153 ++++++++++++++++++++---- graphify/cli.py | 175 +++++++++++++++++++++++----- graphify/global_graph.py | 10 +- graphify/watch.py | 30 +++-- tests/test_extract_code_only_cli.py | 131 +++++++++++++++++++++ 5 files changed, 433 insertions(+), 66 deletions(-) diff --git a/graphify/build.py b/graphify/build.py index 8946c0fdb..d64fac6ab 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -22,6 +22,7 @@ # from __future__ import annotations import json +import hashlib import math import os import re @@ -334,6 +335,24 @@ def dedupe_edges(edges: list[dict]) -> list[dict]: return out +def stable_edge_key(source: object, target: object, attrs: dict) -> str: + """Deterministic MultiDiGraph key for one semantic relation occurrence.""" + identity = { + "source": source, + "target": target, + "relation": attrs.get("relation", "related_to"), + "source_file": attrs.get("source_file", ""), + "source_location": attrs.get("source_location", ""), + "context": attrs.get("context", ""), + "origin": attrs.get("origin", ""), + } + encoded = json.dumps( + identity, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str + ).encode("utf-8") + relation = _normalize_id(str(identity["relation"])) or "edge" + return f"{relation}:{hashlib.sha256(encoded).hexdigest()[:16]}" + + def _old_file_stems(rel: Path) -> list[str]: """Pre-migration stem forms a semantic fragment may have used for ``rel``. @@ -487,10 +506,17 @@ def _doc_twin_remap(nodes: list) -> dict[str, str]: return remap -def build_from_json(extraction: dict, *, directed: bool = False, root: str | Path | None = None) -> nx.Graph: +def build_from_json( + extraction: dict, + *, + directed: bool = False, + multigraph: bool = False, + root: str | Path | None = None, +) -> nx.Graph: """Build a NetworkX graph from an extraction dict. directed=True produces a DiGraph that preserves edge direction (source→target). + multigraph=True produces a keyed MultiDiGraph and implies directed=True. directed=False (default) produces an undirected Graph for backward compatibility. root: if given, absolute source_file paths from semantic subagents are made relative to root so all nodes share a consistent path key (#932). @@ -594,7 +620,13 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if isinstance(he, dict) and isinstance(he.get("nodes"), list): he["nodes"] = [_doc_remap.get(n, n) for n in he["nodes"]] - G: nx.Graph = nx.DiGraph() if directed else nx.Graph() + if multigraph: + from .multigraph_compat import require_multigraph_capabilities + + require_multigraph_capabilities() + G: nx.Graph = nx.MultiDiGraph() + else: + G = nx.DiGraph() if directed else nx.Graph() for node in extraction.get("nodes", []): # Skip dict nodes with a missing or non-hashable id (e.g. a list emitted # by a buggy LLM extraction) so NetworkX add_node never raises @@ -761,14 +793,21 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat # direction in _src/_tgt; when two edges collapse onto the same node pair the # last write wins, so an unstable iteration order flips _src/_tgt run-to-run # and makes the serialized graph churn. Sorting fixes the last-write outcome. - for edge in sorted( - extraction.get("edges", []), - key=lambda e: ( + # Multigraphs additionally need a TOTAL order: parallel keyed edges share + # (src, tgt, relation), and their iteration order decides content-suffix + # key collisions. Simple graphs skip that O(E) json.dumps tiebreak — the + # 3-tuple sort plus input order already fixes their last-write outcome. + def _edge_sort_key(e: dict): + key = ( str(e.get("source", e.get("from", ""))), str(e.get("target", e.get("to", ""))), str(e.get("relation", "")), - ), - ): + ) + if multigraph: + key += (json.dumps(e, sort_keys=True, ensure_ascii=False, default=str),) + return key + + for edge in sorted(extraction.get("edges", []), key=_edge_sort_key): if "source" not in edge and "from" in edge: edge["source"] = edge["from"] if "target" not in edge and "to" in edge: @@ -816,7 +855,11 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat # strings, NaN/inf, negatives — while numeric strings coerce cleanly. # Repair (not drop) the key so graph.json round-trips a clean value and a # cluster-only/--update reload never re-ingests the null. - attrs = {k: v for k, v in edge.items() if k not in ("source", "target", "target_file", "local_alias")} + requested_key = edge.get("key") + attrs = { + k: v for k, v in edge.items() + if k not in ("source", "target", "target_file", "local_alias", "key") + } for _num_key in ("weight", "confidence_score"): if _num_key in attrs: try: @@ -889,7 +932,26 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat existing.get("_src") == tgt and existing.get("_tgt") == src ): continue - G.add_edge(src, tgt, **attrs) + if G.is_multigraph(): + edge_key = ( + str(requested_key) + if isinstance(requested_key, (str, int)) and str(requested_key) + else stable_edge_key(src, tgt, attrs) + ) + if G.has_edge(src, tgt, edge_key): + # A repeated semantic occurrence is idempotent. A malformed + # serialized key collision gets a deterministic content suffix + # rather than overwriting the existing edge. + existing = G.get_edge_data(src, tgt, edge_key) or {} + if existing == attrs: + continue + suffix = hashlib.sha256( + json.dumps(attrs, sort_keys=True, default=str).encode("utf-8") + ).hexdigest()[:8] + edge_key = f"{edge_key}:{suffix}" + G.add_edge(src, tgt, key=edge_key, **attrs) + else: + G.add_edge(src, tgt, **attrs) hyperedges = extraction.get("hyperedges", []) if hyperedges: # Relativize hyperedge source_file the same way nodes and edges are @@ -944,6 +1006,7 @@ def build( extractions: list[dict], *, directed: bool = False, + multigraph: bool = False, dedup: bool = True, dedup_llm_backend: str | None = None, root: str | Path | None = None, @@ -976,7 +1039,7 @@ def build( combined["nodes"], combined["edges"], communities={}, dedup_llm_backend=dedup_llm_backend, ) - return build_from_json(combined, directed=directed, root=root) + return build_from_json(combined, directed=directed, multigraph=multigraph, root=root) def _norm_label(label: str | None) -> str: @@ -1047,6 +1110,7 @@ def build_merge( prune_sources: list[str] | None = None, *, directed: bool = False, + multigraph: bool | None = None, dedup: bool = True, dedup_llm_backend: str | None = None, root: str | Path | None = None, @@ -1083,11 +1147,15 @@ def build_merge( existing_edges = list(data.get(links_key, [])) existing_hyperedges = list(data.get("hyperedges", [])) had_graph = True + if multigraph is None: + multigraph = bool(data.get("multigraph", False)) else: existing_nodes = [] existing_edges = [] existing_hyperedges = [] had_graph = False + if multigraph is None: + multigraph = False # Effective root for relativizing absolute source_file / prune paths back to the # stored relative source_file keys. When the caller passes root we use it; @@ -1131,7 +1199,14 @@ def _kept(item: dict) -> bool: base = [{"nodes": existing_nodes, "edges": existing_edges}] if had_graph else [] all_chunks = base + list(new_chunks) - G = build(all_chunks, directed=directed, dedup=dedup, dedup_llm_backend=dedup_llm_backend, root=root) + G = build( + all_chunks, + directed=directed, + multigraph=bool(multigraph), + dedup=dedup, + dedup_llm_backend=dedup_llm_backend, + root=root, + ) # Prune set for deleted source files — both the raw form (matches nodes that # kept absolute source_file) and the normalised relative form (matches nodes @@ -1216,10 +1291,16 @@ def _prune_match(sf: "str | None") -> bool: file=sys.stderr, ) - edges_to_remove = [ - (u, v) for u, v, d in G.edges(data=True) - if _prune_match(d.get("source_file")) - ] + if G.is_multigraph(): + edges_to_remove = [ + (u, v, key) for u, v, key, d in G.edges(keys=True, data=True) + if _prune_match(d.get("source_file")) + ] + else: + edges_to_remove = [ + (u, v) for u, v, d in G.edges(data=True) + if _prune_match(d.get("source_file")) + ] if edges_to_remove: G.remove_edges_from(edges_to_remove) print( @@ -1299,13 +1380,15 @@ def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int: 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``). +def load_graph_json( + path: Path, *, preserve_type: bool = False, directed: bool = False +) -> nx.Graph: + """Load persisted node-link JSON, optionally preserving its graph type. 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). + (#738). By default directed/multi inputs are coerced to a simple Graph for + established callers; MultiGraph-aware composition uses ``preserve_type``. directed=True loads the stored source/target order into a directed graph. Persisted simple graphs say ``"directed": false`` even though their edge @@ -1328,11 +1411,29 @@ def load_graph_json(path: Path, *, directed: bool = False) -> nx.Graph: except TypeError: G = _jg.node_link_graph(data) simple_type = nx.DiGraph if directed else nx.Graph - if type(G) is not simple_type: + if not preserve_type and type(G) is not simple_type: G = simple_type(G) return G +def promote_to_multidigraph(G: nx.Graph) -> nx.MultiDiGraph: + """Return ``G`` as a MultiDiGraph without dropping nodes or edge keys.""" + if isinstance(G, nx.MultiDiGraph): + return G + promoted = nx.MultiDiGraph() + promoted.graph.update(G.graph) + promoted.add_nodes_from(G.nodes(data=True)) + if G.is_multigraph(): + for u, v, key, data in G.edges(keys=True, data=True): + promoted.add_edge(u, v, key=key, **data) + else: + for u, v, data in G.edges(data=True): + src = data.get("_src", u) + tgt = data.get("_tgt", v) + promoted.add_edge(src, tgt, key=stable_edge_key(src, tgt, data), **data) + return promoted + + def merge_prefixed_into(G: nx.Graph, prefixed: nx.Graph) -> int: """Merge a repo_tag::-prefixed graph into G in-place. Returns nodes added. @@ -1356,10 +1457,18 @@ def merge_prefixed_into(G: nx.Graph, prefixed: nx.Graph) -> int: 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): + if prefixed.is_multigraph(): + edge_rows = prefixed.edges(keys=True, data=True) + else: + edge_rows = ((u, v, None, data) for u, v, data in prefixed.edges(data=True)) + for u, v, key, data in edge_rows: 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) + if G.is_multigraph(): + edge_key = key if key is not None else stable_edge_key(u, v, data) + G.add_edge(u, v, key=edge_key, **data) + else: + G.add_edge(u, v, **data) return prefixed.number_of_nodes() - len(remap) diff --git a/graphify/cli.py b/graphify/cli.py index a860c0be2..469b26ffe 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -386,6 +386,36 @@ def _in_seen(p: Path) -> bool: return stale +def _filter_payload_sources(data: dict, stale: set) -> int: + """Drop nodes/edges/hyperedges owned by ``stale`` source spellings from a + raw graph payload IN MEMORY, mutating ``data``. Returns nodes removed. + + Exact string matching against ``source_file`` — callers pass spellings the + graph itself uses (or every plausible spelling of a path). + """ + links_key = "links" if "links" in data else "edges" + nodes = [n for n in data.get("nodes", []) if isinstance(n, dict)] + kept_nodes = [n for n in nodes if n.get("source_file") not in stale] + removed_ids = { + n.get("id") for n in nodes if n.get("source_file") in stale + } + n_removed = len(nodes) - len(kept_nodes) + data["nodes"] = kept_nodes + data[links_key] = [ + e for e in data.get(links_key, []) + if isinstance(e, dict) + and e.get("source_file") not in stale + and e.get("source") not in removed_ids + and e.get("target") not in removed_ids + ] + if "hyperedges" in data: + data["hyperedges"] = [ + h for h in data.get("hyperedges", []) + if isinstance(h, dict) and h.get("source_file") not in stale + ] + return n_removed + + def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int: """Drop nodes/edges/hyperedges owned by ``stale_sources`` from graph.json in place. Returns the number of nodes removed. @@ -403,33 +433,16 @@ def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int return 0 if not isinstance(data, dict): return 0 - stale = set(stale_sources) links_key = "links" if "links" in data else "edges" - nodes = [n for n in data.get("nodes", []) if isinstance(n, dict)] - kept_nodes = [n for n in nodes if n.get("source_file") not in stale] - removed_ids = { - n.get("id") for n in nodes if n.get("source_file") in stale - } - n_removed = len(nodes) - len(kept_nodes) - kept_edges = [ - e for e in data.get(links_key, []) - if isinstance(e, dict) - and e.get("source_file") not in stale - and e.get("source") not in removed_ids - and e.get("target") not in removed_ids - ] - kept_hyper = [ - h for h in data.get("hyperedges", []) - if isinstance(h, dict) and h.get("source_file") not in stale - ] - if n_removed == 0 and len(kept_edges) == len(data.get(links_key, [])) and ( - len(kept_hyper) == len(data.get("hyperedges", [])) + n_edges_before = len(data.get(links_key, [])) + n_hyper_before = len(data.get("hyperedges", [])) + n_removed = _filter_payload_sources(data, set(stale_sources)) + if ( + n_removed == 0 + and len(data.get(links_key, [])) == n_edges_before + and len(data.get("hyperedges", [])) == n_hyper_before ): return 0 - data["nodes"] = kept_nodes - data[links_key] = kept_edges - if "hyperedges" in data: - data["hyperedges"] = kept_hyper from graphify.export import backup_if_protected as _backup _backup(graph_path.parent) from graphify.paths import write_json_atomic @@ -1497,12 +1510,12 @@ def dispatch_command(cmd: str) -> None: except Exception: pass print(f" Degree: {G.degree(nid)}") - from graphify.build import edge_data + from graphify.build import edge_datas connections: list[tuple[str, str, dict]] = [] # (direction, neighbor_id, edge_data) for nb in G.successors(nid): - connections.append(("out", nb, edge_data(G, nid, nb))) + connections.extend(("out", nb, data) for data in edge_datas(G, nid, nb)) for nb in G.predecessors(nid): - connections.append(("in", nb, edge_data(G, nb, nid))) + connections.extend(("in", nb, data) for data in edge_datas(G, nb, nid)) if connections: print(f"\nConnections ({len(connections)}):") connections.sort(key=lambda c: G.degree(c[1]), reverse=True) @@ -2681,7 +2694,7 @@ def _load_graph(p: str): if len(sys.argv) < 3: print( "Usage: graphify extract [--backend gemini|kimi|claude|openai|deepseek|ollama] " - "[--model M] [--mode deep] [--out DIR|--output DIR] [--google-workspace] [--no-cluster] " + "[--model M] [--mode deep] [--out DIR|--output DIR] [--google-workspace] [--no-cluster] [--multigraph|--no-multigraph] " "[--no-gitignore] [--code-only] " "[--max-workers N] [--token-budget N] [--max-concurrency N] " "[--api-timeout S] [--postgres DSN] [--cargo] [--allow-partial] [--timing]", @@ -2706,6 +2719,7 @@ def _load_graph(p: str): cli_postgres_dsn: str | None = None cli_cargo: bool = False cli_allow_partial: bool = False + cli_multigraph: bool | None = None no_cluster = False dedup_llm = False google_workspace = False @@ -2774,6 +2788,10 @@ def _parse_float(name: str, raw: str) -> float: out_dir = Path(a.split("=", 1)[1]); i += 1 elif a == "--no-cluster": no_cluster = True; i += 1 + elif a == "--multigraph": + cli_multigraph = True; i += 1 + elif a == "--no-multigraph": + cli_multigraph = False; i += 1 elif a == "--dedup-llm": dedup_llm = True; i += 1 elif a == "--code-only": @@ -3390,6 +3408,35 @@ def _progress(idx: int, total: int, _result: dict) -> None: } graph_json_path = graphify_out / "graph.json" + # The existing graph's format is read on EVERY rebuild, not just + # incremental ones: --force without a repeated --multigraph must not + # silently downgrade a multigraph back to simple (the only intended + # downgrade path is an explicit --no-multigraph). + existing_is_multigraph = False + if graph_json_path.is_file(): + try: + existing_is_multigraph = bool( + json.loads(graph_json_path.read_text(encoding="utf-8")).get( + "multigraph", False + ) + ) + except Exception: + pass + if cli_multigraph is None: + cli_multigraph = existing_is_multigraph + elif not cli_multigraph and existing_is_multigraph: + print( + "[graphify extract] warning: --no-multigraph converts the existing " + "multigraph to a simple graph, collapsing parallel relations; " + "re-run with --multigraph to restore them.", + file=sys.stderr, + ) + # An explicit format flag that differs from the existing graph means a + # conversion is pending (either direction), so the no-change early + # exit must not fire. + multigraph_conversion = ( + graph_json_path.is_file() and bool(cli_multigraph) != existing_is_multigraph + ) analysis_path = graphify_out / ".graphify_analysis.json" # Build a manifest-safe files dict: only stamp semantic_hash for files @@ -3458,6 +3505,7 @@ def _invalidate_file_manifest_for_db_graph() -> None: and not pg_result.get("edges") and not cargo_result.get("nodes") and not cargo_result.get("edges") + and not multigraph_conversion ): # An exclusion-only change reaches this gate (excluded files # are deliberately NOT in deleted_files, #1908) but must still @@ -3484,8 +3532,58 @@ def _invalidate_file_manifest_for_db_graph() -> None: stages.total() sys.exit(0) + if multigraph_conversion and incremental_mode: + # A conversion run bypassed the no-change early exit only to + # rewrite the graph's format, but `merged` holds just this + # run's incremental extraction (empty when nothing changed) — + # the existing graph must be carried forward, not replaced. + # Carried entries are filtered like build_merge's prune set: + # sources re-extracted this run (their fresh results replace + # them — carrying both would leave stale nodes and duplicate + # parallel keyed edges), plus deleted and newly-excluded + # sources. Existing entries go first so this run's results win + # on dedupe collision, matching the AST-then-semantic order. + try: + _existing_payload = json.loads( + graph_json_path.read_text(encoding="utf-8") + ) + _stale = { + str(x["source_file"]) + for part in ("nodes", "edges", "hyperedges") + for x in merged[part] + if isinstance(x, dict) and x.get("source_file") + } + _stale.update(str(s) for s in graph_stale_sources) + for _d in deleted_files: + # deleted_files spellings may be absolute; the graph + # stores root-relative — match both, like build_merge. + _stale.add(str(_d)) + try: + _rel = os.path.relpath(str(_d), str(target)) + _stale.add(_rel) + _stale.add(Path(_rel).as_posix()) + except ValueError: # Windows cross-drive + pass + _filter_payload_sources(_existing_payload, _stale) + _existing_links = _existing_payload.get( + "links", _existing_payload.get("edges", []) + ) + merged["nodes"] = list(_existing_payload.get("nodes", [])) + merged["nodes"] + merged["edges"] = list(_existing_links) + merged["edges"] + merged["hyperedges"] = ( + list(_existing_payload.get("hyperedges", [])) + merged["hyperedges"] + ) + except Exception as exc: + print( + f"[graphify extract] warning: could not read the existing " + f"graph for multigraph conversion ({exc}); converting this " + f"run's extraction only", + file=sys.stderr, + ) + merged["nodes"] = _dedupe_nodes(merged["nodes"]) - merged["edges"] = _dedupe_edges(merged["edges"]) + if not cli_multigraph: + merged["edges"] = _dedupe_edges(merged["edges"]) # Disambiguate colliding-basename file-node labels (#2032). This raw # --no-cluster path bypasses build_from_json (where the clustered path # gets this), so apply it directly on the merged node list. @@ -3528,7 +3626,19 @@ def _invalidate_file_manifest_for_db_graph() -> None: _backup(graphify_out) _invalidate_file_manifest_for_db_graph() from graphify.paths import write_json_atomic as _write_json_atomic - _write_json_atomic(graph_json_path, merged, indent=2) + if cli_multigraph: + from networkx.readwrite import json_graph as _json_graph + from graphify.build import build_from_json as _build_multigraph + + _raw_graph = _build_multigraph(merged, multigraph=True, root=target) + try: + _output_payload = _json_graph.node_link_data(_raw_graph, edges="links") + except TypeError: + _output_payload = _json_graph.node_link_data(_raw_graph) + _output_payload["hyperedges"] = _raw_graph.graph.get("hyperedges", []) + else: + _output_payload = merged + _write_json_atomic(graph_json_path, _output_payload, indent=2) try: # Record the scan root so a later build_merge / update runbook can # relativize deleted-file paths correctly even for a custom --out @@ -3545,7 +3655,8 @@ def _invalidate_file_manifest_for_db_graph() -> None: ) print( f"[graphify extract] wrote {graph_json_path} — " - f"{len(merged['nodes'])} nodes, {len(merged['edges'])} edges " + f"{len(_output_payload['nodes'])} nodes, " + f"{len(_output_payload.get('links', _output_payload.get('edges', [])))} edges " f"(no clustering)" ) if merged["input_tokens"] or merged["output_tokens"]: @@ -3599,6 +3710,7 @@ def _invalidate_file_manifest_for_db_graph() -> None: graph_path=existing_graph_path, prune_sources=_prune_sources or None, dedup=True, + multigraph=cli_multigraph, dedup_llm_backend=dedup_backend, root=target, ) @@ -3606,6 +3718,7 @@ def _invalidate_file_manifest_for_db_graph() -> None: G = _build( [merged], dedup=True, + multigraph=cli_multigraph, dedup_llm_backend=dedup_backend, root=target, ) diff --git a/graphify/global_graph.py b/graphify/global_graph.py index 647afb447..617e3b498 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -49,7 +49,7 @@ def _save_manifest(manifest: dict) -> None: def _load_global_graph() -> nx.Graph: if _GLOBAL_GRAPH.exists(): from graphify.build import load_graph_json - return load_graph_json(_GLOBAL_GRAPH) + return load_graph_json(_GLOBAL_GRAPH, preserve_type=True) return nx.Graph() @@ -79,6 +79,7 @@ def global_add(source_path: Path, repo_tag: str) -> dict: load_graph_json, merge_prefixed_into, prefix_graph_for_global, + promote_to_multidigraph, prune_repo_from_graph, ) @@ -101,12 +102,15 @@ def global_add(source_path: Path, repo_tag: str) -> dict: return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True} # Load source graph, prefix IDs for cross-project isolation - src_G = load_graph_json(source_path) - prefixed = prefix_graph_for_global(src_G, repo_tag) + src_G = load_graph_json(source_path, preserve_type=True) # Load global graph, prune stale nodes for this repo, merge with # external-library dedup-by-label (shared helper in build.py). G = _load_global_graph() + if src_G.is_multigraph() or G.is_multigraph(): + src_G = promote_to_multidigraph(src_G) + G = promote_to_multidigraph(G) + prefixed = prefix_graph_for_global(src_G, repo_tag) removed = prune_repo_from_graph(G, repo_tag) added = merge_prefixed_into(G, prefixed) _save_global_graph(G) diff --git a/graphify/watch.py b/graphify/watch.py index 584bff929..c8ccbd477 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1140,12 +1140,21 @@ def _add_deleted_source(path: Path) -> None: # Dedupe parallel edges (the clustered path's DiGraph collapses them implicitly); # without it, --no-cluster + repeated `update` accumulate duplicates and edge # counts diverge across build modes (#1317). - from graphify.build import dedupe_edges as _dedupe_edges, dedupe_nodes as _dedupe_nodes - candidate_graph_data = { - **{k: v for k, v in result.items() if k not in ("edges", "nodes")}, - "nodes": _dedupe_nodes(result.get("nodes", [])), - "links": _dedupe_edges(result.get("edges", [])), - } + from graphify.build import ( + build_from_json as _build_from_json, + dedupe_edges as _dedupe_edges, + dedupe_nodes as _dedupe_nodes, + ) + if existing_graph_data.get("multigraph"): + candidate_graph_data = _topology_from_graph( + _build_from_json(result, multigraph=True, root=project_root) + ) + else: + candidate_graph_data = { + **{k: v for k, v in result.items() if k not in ("edges", "nodes")}, + "nodes": _dedupe_nodes(result.get("nodes", [])), + "links": _dedupe_edges(result.get("edges", [])), + } candidate_graph_text = _json_text(candidate_graph_data) same_graph = False if existing_graph.exists(): @@ -1206,10 +1215,11 @@ def _add_deleted_source(path: Path) -> None: "total_words": detected.get("total_words", 0), } - # root=project_root aligns watch rebuilds with `graphify build`'s - # root-relative source_file paths (#932); without it, absolute paths - # from semantic subagents survive in watch-produced graphs only. - G = build_from_json(result, root=project_root) + G = build_from_json( + result, + multigraph=bool(existing_graph_data.get("multigraph", False)), + root=project_root, + ) candidate_topology = _topology_from_graph(G) if existing_graph_data: try: diff --git a/tests/test_extract_code_only_cli.py b/tests/test_extract_code_only_cli.py index 93fec48d3..9adeee9b6 100644 --- a/tests/test_extract_code_only_cli.py +++ b/tests/test_extract_code_only_cli.py @@ -49,6 +49,94 @@ def test_code_only_succeeds_without_key(tmp_path): assert any(str(l).startswith("hello") for l in labels), "code was indexed" +def test_multigraph_flag_writes_keyed_directed_graph(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / "app.py").write_text( + "def target():\n return 1\n\ndef caller():\n return target()\n", + encoding="utf-8", + ) + result = _run(repo, "--code-only", "--multigraph") + assert result.returncode == 0, result.stderr + graph = json.loads((repo / "graphify-out" / "graph.json").read_text()) + assert graph["multigraph"] is True and graph["directed"] is True + assert graph["links"] and all("key" in edge for edge in graph["links"]) + + +def _link_signature(links): + """Endpoints + relation data per link, ignoring only the multigraph key.""" + return sorted( + (str(l.get("source")), str(l.get("target")), str(l.get("relation", ""))) + for l in links + ) + + +def test_multigraph_conversion_of_unchanged_graph_preserves_content(tmp_path): + """`--multigraph` on an unchanged simple graph bypasses the no-change early + exit to rewrite the format — it must carry the existing graph forward, not + serialize this run's empty incremental extraction over it.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "app.py").write_text( + "def target():\n return 1\n\ndef caller():\n return target()\n", + encoding="utf-8", + ) + (repo / "lib.py").write_text("def helper():\n return 2\n", encoding="utf-8") + assert _run(repo, "--code-only").returncode == 0 + graph_path = repo / "graphify-out" / "graph.json" + before = json.loads(graph_path.read_text()) + assert before.get("multigraph", False) is False and before["nodes"] + # Seed a hyperedge over real node ids, as a prior semantic pass would have + # left it — conversion must carry hyperedge CONTENT forward too. graph.json + # is not a manifest-tracked source, so the run below still sees no changes. + hyper_nodes = sorted(n["id"] for n in before["nodes"])[:2] + before["hyperedges"] = [{ + "id": "flow_seeded", "nodes": hyper_nodes, "relation": "data_flow", + "label": "seeded flow", "source_file": "app.py", + }] + graph_path.write_text(json.dumps(before), encoding="utf-8") + + result = _run(repo, "--code-only", "--multigraph") + assert result.returncode == 0, result.stderr + after = json.loads(graph_path.read_text()) + assert after["multigraph"] is True and after["directed"] is True + assert {n["id"] for n in after["nodes"]} == {n["id"] for n in before["nodes"]} + before_links = before.get("links", before.get("edges", [])) + assert _link_signature(after["links"]) == _link_signature(before_links) + assert all("key" in edge for edge in after["links"]) + (hyper,) = after["hyperedges"] + assert hyper["relation"] == "data_flow" + assert sorted(hyper["nodes"]) == hyper_nodes + + +def test_multigraph_conversion_with_changed_file_replaces_its_content(tmp_path): + """A conversion run that coincides with a changed file must NOT carry that + file's old content forward — fresh extraction replaces it (no stale nodes, + no duplicate parallel edges) while other files' content is preserved.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "app.py").write_text( + "def target():\n return 1\n\ndef caller():\n return target()\n", + encoding="utf-8", + ) + (repo / "lib.py").write_text("def helper():\n return 2\n", encoding="utf-8") + assert _run(repo, "--code-only").returncode == 0 + graph_path = repo / "graphify-out" / "graph.json" + before_ids = {n["id"] for n in json.loads(graph_path.read_text())["nodes"]} + assert "lib_helper" in before_ids + + (repo / "lib.py").write_text("def renamed():\n return 2\n", encoding="utf-8") + result = _run(repo, "--code-only", "--multigraph") + assert result.returncode == 0, result.stderr + after = json.loads(graph_path.read_text()) + after_ids = {n["id"] for n in after["nodes"]} + assert "lib_helper" not in after_ids, "changed file's stale node survived" + assert "lib_renamed" in after_ids + assert before_ids - {"lib_helper"} <= after_ids, "unchanged files' nodes lost" + sigs = _link_signature(after["links"]) + assert len(sigs) == len(set(sigs)), "duplicate parallel edges from the carry-forward" + + def test_mixed_repo_without_key_errors_and_points_at_code_only(tmp_path): repo = _mixed_repo(tmp_path) r = _run(repo) # no --code-only, no key @@ -258,3 +346,46 @@ def test_extract_names_skipped_sensitive_files(tmp_path): out = r.stdout + r.stderr assert "skipped as potentially sensitive" in out assert "github_token.txt" in out, "the skipped filename must be surfaced (#2106)" + + +def test_force_rebuild_preserves_multigraph_format(tmp_path): + """`extract --force` without a repeated --multigraph must keep the existing + graph's format — the only intended downgrade path is --no-multigraph.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "app.py").write_text( + "def target():\n return 1\n\ndef caller():\n return target()\n", + encoding="utf-8", + ) + assert _run(repo, "--code-only", "--multigraph").returncode == 0 + graph_path = repo / "graphify-out" / "graph.json" + assert json.loads(graph_path.read_text())["multigraph"] is True + + result = _run(repo, "--code-only", "--force") + assert result.returncode == 0, result.stderr + graph = json.loads(graph_path.read_text()) + assert graph["multigraph"] is True, "--force silently downgraded the format" + assert graph["links"] and all("key" in edge for edge in graph["links"]) + + +def test_no_multigraph_downgrades_with_warning(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / "app.py").write_text( + "def target():\n return 1\n\ndef caller():\n return target()\n", + encoding="utf-8", + ) + assert _run(repo, "--code-only", "--multigraph").returncode == 0 + graph_path = repo / "graphify-out" / "graph.json" + before = json.loads(graph_path.read_text()) + assert before["multigraph"] is True + + # Incremental downgrade on an unchanged corpus: the conversion must bypass + # the no-change early exit and still carry the graph content forward. + result = _run(repo, "--code-only", "--no-multigraph") + assert result.returncode == 0, result.stderr + assert "collapsing parallel relations" in result.stderr + graph = json.loads(graph_path.read_text()) + assert graph.get("multigraph", False) is False + assert {n["id"] for n in graph["nodes"]} == {n["id"] for n in before["nodes"]} + assert graph["links"], "downgrade must keep the edges" From 63d3c938b2e59bb5a8e40169841fab64e9290f74 Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:51:09 -0400 Subject: [PATCH 09/11] =?UTF-8?q?feat(cluster):=20graph=5Fmode=20"multi"?= =?UTF-8?q?=20=E2=80=94=20clusters=20of=20multigraph=20members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cluster.json's graph_mode: "multi" composes members into a keyed MultiDiGraph (simple members are promoted with a warning naming --multigraph), declared links get stable keys and per-identity duplicate detection, and community detection aggregates parallel edge weights with a non-finite guard. --- graphify/cluster.py | 17 ++++++++- graphify/cluster_graph.py | 70 +++++++++++++++++++++++++++++--------- tests/test_cluster.py | 22 +++++++++++- tests/test_cluster_spec.py | 17 ++++----- 4 files changed, 97 insertions(+), 29 deletions(-) diff --git a/graphify/cluster.py b/graphify/cluster.py index 682210700..2896dfac3 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -4,6 +4,7 @@ import inspect import io import json +import math import sys import networkx as nx @@ -42,7 +43,21 @@ def _partition(G: nx.Graph, resolution: float = 1.0) -> dict[str, int]: ), ) for src, tgt, attrs in edge_rows: - stable.add_edge(src, tgt, **attrs) + weight = attrs.get("weight", 1.0) + try: + weight = float(weight) + if not math.isfinite(weight): + raise ValueError("edge weight must be finite") + except (TypeError, ValueError): + weight = 1.0 + if stable.has_edge(src, tgt): + stable[src][tgt]["weight"] = stable[src][tgt].get("weight", 1.0) + weight + stable[src][tgt]["parallel_count"] = stable[src][tgt].get("parallel_count", 1) + 1 + else: + projected = dict(attrs) + projected["weight"] = weight + projected["parallel_count"] = 1 + stable.add_edge(src, tgt, **projected) try: from graspologic.partition import leiden diff --git a/graphify/cluster_graph.py b/graphify/cluster_graph.py index a4682a809..e9a963086 100644 --- a/graphify/cluster_graph.py +++ b/graphify/cluster_graph.py @@ -54,7 +54,7 @@ LINK_TYPES = (*DIRECT_LINK_RELATIONS, "shared_resource") _ON_MISSING = ("warn", "create", "error") -_GRAPH_MODES = ("simple",) +_GRAPH_MODES = ("simple", "multi") class ClusterSpecError(ValueError): @@ -502,13 +502,14 @@ def compose_members( load_graph_json, merge_prefixed_into, prefix_graph_for_global, + promote_to_multidigraph, ) - # Composed directed: the composed graph is re-serialized, and an - # undirected round-trip re-emits edge endpoints by node insertion order, + # Composed directed in BOTH modes: the composed graph is re-serialized, and + # an undirected round-trip re-emits edge endpoints by node insertion order, # silently flipping caller/callee (#760). Members load directed so their # stored source/target order is what the cluster graph.json persists. - G: nx.Graph = nx.DiGraph() + G: nx.Graph = nx.MultiDiGraph() if spec.graph_mode == "multi" else nx.DiGraph() stats: dict[str, dict] = {} cid_base = 0 for member in spec.members: @@ -519,12 +520,23 @@ def compose_members( f"Run `graphify extract .` (or your usual build) in {resolved[member.tag]} first." ) try: - member_graph = load_graph_json(gp, directed=True) + member_graph = load_graph_json( + gp, preserve_type=spec.graph_mode == "multi", directed=True + ) except ValueError as exc: # JSONDecodeError and the size cap raise ClusterSpecError( f"member '{member.tag}' has an unreadable graph at {gp} ({exc}). " f"Re-run `graphify extract . --force` in {resolved[member.tag]} to rebuild it." ) from exc + source_multigraph = member_graph.is_multigraph() + if spec.graph_mode == "multi": + if not source_multigraph: + print( + f"[graphify cluster] warning: member '{member.tag}' is a simple " + "graph; re-extract it with --multigraph to recover parallel relations", + file=sys.stderr, + ) + member_graph = promote_to_multidigraph(member_graph) prefixed = prefix_graph_for_global(member_graph, member.tag) cid_base = _renumber_member_communities(prefixed, cid_base) total = prefixed.number_of_nodes() @@ -538,6 +550,7 @@ def compose_members( "node_count": total, "edge_count": prefixed.number_of_edges(), "externals_merged": total - added, + "source_multigraph": source_multigraph, } return G, stats @@ -695,6 +708,7 @@ def apply_spec_links(G: nx.Graph, spec: ClusterSpec, *, dry_run: bool = False) - pair = (min(u, v), max(u, v)) relation = data.get("relation") or "unknown" occupied_pairs[pair] = f"existing relation {relation!r}" + declared_identities: set[tuple[str, str, str, str]] = set() def _resolve(link: ClusterLink, sel: dict, link_label: str) -> str | None: try: @@ -735,15 +749,24 @@ def _add_edge( report.warnings.append(f"link '{link.name or link.type}' resolved to a self-loop; skipped") return False pair = (min(u, v), max(u, v)) - prior = occupied_pairs.get(pair) - if prior is not None: - report.errors.append( - f"{link_label}: cannot add relation {relation!r} between {u} and {v}; " - f"the pair already has {prior}. Simple cluster graphs allow only " - f"one relation per node pair" - ) - return False - occupied_pairs[pair] = f"{link_label} relation {relation!r}" + if G.is_multigraph(): + identities = [(u, v, relation, link.name or link.type)] + if link.direction == "both": + identities.append((v, u, relation, link.name or link.type)) + if any(identity in declared_identities for identity in identities): + report.errors.append(f"{link_label}: duplicate declared cluster relation") + return False + declared_identities.update(identities) + else: + prior = occupied_pairs.get(pair) + if prior is not None: + report.errors.append( + f"{link_label}: cannot add relation {relation!r} between {u} and {v}; " + f"the pair already has {prior}. Simple cluster graphs allow only " + f"one relation per node pair" + ) + return False + occupied_pairs[pair] = f"{link_label} relation {relation!r}" # direction: "both" materializes as a real reverse edge — traversal # (affected's in_edges, query BFS) reads topology, not attrs, so a # metadata-only flag would silently traverse one way. The declared @@ -768,7 +791,13 @@ def _add_edge( attrs["direction"] = "both" if link.note: attrs["note"] = link.note - G.add_edge(src, tgt, **attrs) + if G.is_multigraph(): + from .build import stable_edge_key + + attrs["context"] = link.name or link.type + G.add_edge(src, tgt, key=stable_edge_key(src, tgt, attrs), **attrs) + else: + G.add_edge(src, tgt, **attrs) report.edges_added += len(endpoint_pairs) return True @@ -891,7 +920,16 @@ def apply_auto_package_links( "_src": source, "_tgt": target, } - G.add_edge(source, target, **attrs) + if G.is_multigraph(): + from .build import stable_edge_key + + G.add_edge( + source, target, + key=stable_edge_key(source, target, attrs), + **attrs, + ) + else: + G.add_edge(source, target, **attrs) report.edges_added += 1 report.auto_package_edges += 1 report.resolved.append( diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 21fd2ca3a..5e77e41e6 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -1,9 +1,11 @@ import json +import math import sys import networkx as nx +import pytest from pathlib import Path from graphify.build import build_from_json -from graphify.cluster import cluster, cohesion_score, remap_communities_to_previous, score_all +from graphify.cluster import _partition, cluster, cohesion_score, remap_communities_to_previous, score_all FIXTURES = Path(__file__).parent / "fixtures" @@ -76,6 +78,24 @@ def test_cluster_does_not_write_to_stderr(capsys): assert "\x1b" not in line, f"cluster() wrote ANSI to stderr: {line!r}" +@pytest.mark.parametrize("weight", [math.nan, math.inf, -math.inf]) +def test_partition_replaces_non_finite_edge_weights(monkeypatch, weight): + graph = nx.Graph() + graph.add_edge("a", "b", weight=weight) + captured = {} + + monkeypatch.setitem(sys.modules, "graspologic.partition", None) + + def fake_louvain(projected, **_kwargs): + captured["weight"] = projected["a"]["b"]["weight"] + return [{"a", "b"}] + + monkeypatch.setattr(nx.community, "louvain_communities", fake_louvain) + + assert _partition(graph) == {"a": 0, "b": 0} + assert captured["weight"] == 1.0 + + def test_remap_communities_to_previous_reuses_old_ids(): communities = { 10: ["a", "b", "c"], diff --git a/tests/test_cluster_spec.py b/tests/test_cluster_spec.py index a3c27ce03..d6bbc4b89 100644 --- a/tests/test_cluster_spec.py +++ b/tests/test_cluster_spec.py @@ -70,20 +70,15 @@ def test_new_spec_and_local_config_are_json_first(tmp_path): def test_graph_mode_round_trip_and_validation(tmp_path): - # Only "simple" exists today; unknown modes fail LOUD rather than silently - # composing a simple graph. The plumbing (spec field, manifest key, status - # line) is in place so a future mode is a validation widening, not a - # format change. - _write_spec(tmp_path, _minimal(graph_mode="simple")) + _write_spec(tmp_path, _minimal(graph_mode="multi")) spec = load_spec(tmp_path) - assert spec.graph_mode == "simple" + assert spec.graph_mode == "multi" save_spec(spec, tmp_path) - assert json.loads((tmp_path / "cluster.json").read_text())["graph_mode"] == "simple" + assert json.loads((tmp_path / "cluster.json").read_text())["graph_mode"] == "multi" - for unknown in ("multi", "hyper"): - _write_spec(tmp_path, _minimal(graph_mode=unknown)) - with pytest.raises(ClusterSpecError, match="graph_mode"): - load_spec(tmp_path) + _write_spec(tmp_path, _minimal(graph_mode="hyper")) + with pytest.raises(ClusterSpecError, match="graph_mode"): + load_spec(tmp_path) def test_missing_spec_is_actionable(tmp_path): From c93a404f302c5dabdc07476d68b42304dc67eb7f Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:51:09 -0400 Subject: [PATCH 10/11] feat(query): surface and preserve parallel relations end to end query/MCP render one EDGE line per parallel edge (edge_datas), get_neighbors does the same (extracted as _neighbor_lines; a relation_filter can no longer miss relations that exist), explain lists every parallel connection, affected groups hits per source and reports all matched relations while keeping the honest def-line fallback when an edge has no location, graph diff distinguishes parallel edges on multigraphs only, and GraphML/Cypher/Canvas exports keep parallel edges (Cypher MERGEs on graphify_key stay idempotent; canvas edge ids are index-qualified). --- graphify/affected.py | 40 +++++-- graphify/analyze.py | 16 ++- graphify/export.py | 39 +++++-- graphify/serve.py | 100 ++++++++-------- tests/test_affected_cli.py | 35 ++++++ tests/test_analyze.py | 26 +++++ tests/test_multigraph_build.py | 205 +++++++++++++++++++++++++++++++++ 7 files changed, 391 insertions(+), 70 deletions(-) create mode 100644 tests/test_multigraph_build.py diff --git a/graphify/affected.py b/graphify/affected.py index 253253495..22a9a5c1d 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -22,11 +22,10 @@ "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. + # Cross-repo relations added by `graphify cluster` link resolution: a + # change on one side of a declared contract affects the other repo. + # (`depends_on` is deliberately NOT here — it predates clusters and + # including it would change single-repo affected behavior.) "calls_api", "mirrors", ) @@ -42,6 +41,7 @@ class AffectedHit: # existing constructors/tests working; None falls back to the node's def line. via_file: "str | None" = None via_location: "str | None" = None + via_relations: tuple[str, ...] = () def _node_label(graph: nx.Graph, node_id: str) -> str: @@ -194,13 +194,26 @@ def affected_nodes( for source, target, data in graph.edges(data=True) if target == current ) + grouped: dict[str, list[dict]] = {} for source, _target, data in incoming: relation = str(data.get("relation", "")) - if relation not in relation_set: - continue + if relation in relation_set: + grouped.setdefault(str(source), []).append(data) + for source in sorted(grouped): source = str(source) if source in seen: continue + matching = sorted( + grouped[source], + key=lambda data: ( + str(data.get("relation", "")), + str(data.get("source_file", "")), + str(data.get("source_location", "")), + ), + ) + data = matching[0] + matched_relations = tuple(sorted({str(d.get("relation", "")) for d in matching})) + relation = matched_relations[0] seen.add(source) # Carry the matched edge's location (taken from the SAME edge dict # whose relation passed the filter, so relation and location stay @@ -210,6 +223,7 @@ def affected_nodes( source, current_depth + 1, relation, via_file=str(data.get("source_file") or "") or None, via_location=str(data.get("source_location") or "") or None, + via_relations=matched_relations, ) hits.append(hit) queue.append((source, current_depth + 1)) @@ -242,14 +256,16 @@ def format_affected( for hit in hits: data = graph.nodes[hit.node_id] if hit.via_location: - # The relation SITE in this node's file (call/import/reference line), - # labeled by [via_relation] so it's never mistaken for a def line. + # The relation SITE (call/import/reference line), labeled by + # [via_relation] so it's never mistaken for a def line. Gate on the + # LOCATION: an edge with source_file but a null source_location + # (allowed by the extraction schema) would render a non-clickable + # "file:-" — the node's own def line is the honest fallback there. location = f"{hit.via_file or data.get('source_file') or '-'}:{hit.via_location}" else: location = _format_location(data) # honest fallback: the node's own def line - lines.append( - f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {location}" - ) + relations_text = ", ".join(hit.via_relations or (hit.via_relation,)) + lines.append(f"- {_node_label(graph, hit.node_id)} [{relations_text}] {location}") return "\n".join(lines) diff --git a/graphify/analyze.py b/graphify/analyze.py index ec1e61a99..1c58d334c 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -573,9 +573,21 @@ def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict: ] def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple: + semantic = (data.get("relation", ""),) + if G.is_multigraph(): + # Parallel edges share (u, v, relation); only occurrence-level + # attributes tell them apart. Simple graphs keep the relation-only + # key so an attribute change (e.g. a moved call site) doesn't + # report as edge removed + added. + semantic += ( + data.get("source_file", ""), + data.get("source_location", ""), + data.get("context", ""), + data.get("origin", ""), + ) if G.is_directed(): - return (u, v, data.get("relation", "")) - return (min(u, v), max(u, v), data.get("relation", "")) + return (u, v, *semantic) + return (min(u, v), max(u, v), *semantic) old_edge_keys = { edge_key(G_old, u, v, d) diff --git a/graphify/export.py b/graphify/export.py index e1f2caa99..168a31661 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -392,7 +392,11 @@ def to_cypher(G: nx.Graph, output_path: str) -> None: ) lines.append(f"MERGE (n:{ftype} {{id: '{node_id_esc}', label: '{label}'}});") lines.append("") - for u, v, data in G.edges(data=True): + if G.is_multigraph(): + edge_rows = G.edges(keys=True, data=True) + else: + edge_rows = ((u, v, None, data) for u, v, data in G.edges(data=True)) + for u, v, key, data in edge_rows: rel = _cypher_label( (data.get("relation", "RELATES_TO") or "RELATES_TO").upper(), "RELATES_TO", @@ -400,10 +404,20 @@ def to_cypher(G: nx.Graph, output_path: str) -> None: conf = _cypher_escape(data.get("confidence", "EXTRACTED")) u_esc = _cypher_escape(u) v_esc = _cypher_escape(v) - lines.append( - f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) " - f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);" - ) + if key is None: + lines.append( + f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) " + f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);" + ) + else: + # MERGE on the stable per-occurrence key keeps re-runs of the + # script idempotent; CREATE would duplicate every parallel edge. + key_esc = _cypher_escape(str(key)) + lines.append( + f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) " + f"MERGE (a)-[:{rel} {{confidence: '{conf}', " + f"graphify_key: '{key_esc}'}}]->(b);" + ) with open(output_path, "w", encoding="utf-8") as f: # nosec f.write("\n".join(lines)) @@ -948,9 +962,9 @@ def safe_name(label: str) -> str: all_edges_weighted.append((weight, u, v, label)) all_edges_weighted.sort(key=lambda x: -x[0]) - for weight, u, v, label in all_edges_weighted[:200]: + for index, (weight, u, v, label) in enumerate(all_edges_weighted[:200]): canvas_edges.append({ - "id": f"e_{u}_{v}", + "id": f"e_{u}_{v}_{index}", "fromNode": f"n_{u}", "toNode": f"n_{v}", "label": label, @@ -1003,9 +1017,14 @@ def _graphml_safe(val): for node_id in H.nodes(): for key, val in list(H.nodes[node_id].items()): H.nodes[node_id][key] = _graphml_safe(val) - for u, v in H.edges(): - for key, val in list(H.edges[u, v].items()): - H.edges[u, v][key] = _graphml_safe(val) + if H.is_multigraph(): + for _u, _v, _key, attrs in H.edges(keys=True, data=True): + for attr, val in list(attrs.items()): + attrs[attr] = _graphml_safe(val) + else: + for _u, _v, attrs in H.edges(data=True): + for attr, val in list(attrs.items()): + attrs[attr] = _graphml_safe(val) # Write atomically: a mid-serialization error otherwise leaves a 0-byte # .graphml on disk that downstream tooling mistakes for a completed export diff --git a/graphify/serve.py b/graphify/serve.py index 245f5905f..48a0dec18 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -858,39 +858,38 @@ def _adj(n): lines.append(line) for u, v in edges: if u in nodes and v in nodes: - raw = G[u][v] - d = next(iter(raw.values()), {}) if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)) else raw + for d in edge_datas(G, u, v): # (u, v) is BFS/DFS visit order, not necessarily the true edge # direction: on an undirected graph G.neighbors() walks callers # and callees alike, so a caller->callee edge renders backwards # whenever the callee is visited first. _src/_tgt (stashed on the # edge data by the `query` CLI loader) carry the real direction; # fall back to (u, v) for graphs/edges that don't set them. - src = d.get("_src", u) - tgt = d.get("_tgt", v) + src = d.get("_src", u) + tgt = d.get("_tgt", v) # Guard against a stray/dangling _src/_tgt (hand-edited or adversarial # graph.json): only trust them when they name exactly this edge's # endpoints, else fall back to (u, v). Without this, G.nodes[src] # would KeyError on an unknown id (#2080 review). - if {src, tgt} != {u, v}: - src, tgt = u, v - context = d.get("context") - context_suffix = f" context={sanitize_label(str(context))}" if context else "" + if {src, tgt} != {u, v}: + src, tgt = u, v + context = d.get("context") + context_suffix = f" context={sanitize_label(str(context))}" if context else "" # The relation SITE (call/import/reference line in the source's # file), not a def line — so "who calls X" cites a clickable call # location, not the caller's def (#BUG1). - _loc = str(d.get("source_location") or "") - at_suffix = ( - f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(_loc)}" - if _loc else "" - ) - line = ( - f"EDGE {sanitize_label(G.nodes[src].get('label', src))} " - f"--{sanitize_label(str(d.get('relation', '')))} " - f"[{sanitize_label(str(d.get('confidence', '')))}{context_suffix}]--> " - f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}" - ) - lines.append(line) + _loc = str(d.get("source_location") or "") + at_suffix = ( + f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(_loc)}" + if _loc else "" + ) + line = ( + f"EDGE {sanitize_label(G.nodes[src].get('label', src))} " + f"--{sanitize_label(str(d.get('relation', '')))} " + f"[{sanitize_label(str(d.get('confidence', '')))}{context_suffix}]--> " + f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}" + ) + lines.append(line) output = "\n".join(lines) if len(output) > char_budget: cut_at = output[:char_budget].rfind("\n") @@ -985,6 +984,40 @@ def _query_graph_text( return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget, seeds=start_nodes) +def _neighbor_lines(G: nx.Graph, nid: str, rel_filter: str = "") -> list[str]: + """Per-edge neighbor lines for get_neighbors. + + One line per parallel edge (edge_datas, matching the query surface): + edge_data would surface an arbitrary single relation on multigraphs, so a + relation_filter could return empty for relations that exist. Simple graphs + produce identical output (edge_datas returns a one-element list). + """ + def _edge_at(d: dict) -> str: + # Edge location = the relation SITE (call/import line) in the source + # node's file, not a def line (#BUG1). + loc = str(d.get("source_location") or "") + return ( + f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(loc)}" + if loc else "" + ) + + lines: list[str] = [] + for arrow, pairs in ( + ("-->", ((nid, nb, nb) for nb in G.successors(nid))), + ("<--", ((nb, nid, nb) for nb in G.predecessors(nid))), + ): + for u, v, nb in pairs: + for d in edge_datas(G, u, v): + rel = d.get("relation", "") + if rel_filter and rel_filter not in str(rel).lower(): + continue + lines.append( + f" {arrow} {sanitize_label(G.nodes[nb].get('label', nb))} " + f"[{sanitize_label(str(rel))}] [{sanitize_label(str(d.get('confidence', '')))}]{_edge_at(d)}" + ) + return lines + + def _find_node(G: nx.Graph, label: str) -> list[str]: """Return node IDs whose label or ID matches the search term (diacritic-insensitive). @@ -1425,32 +1458,7 @@ def _tool_get_neighbors(arguments: dict) -> str: return f"No node matching '{label}' found." + _cluster_note() nid = matches[0] lines = [f"Neighbors of {sanitize_label(G.nodes[nid].get('label', nid))}:"] - def _edge_at(d: dict) -> str: - # Edge location = the relation SITE (call/import line) in the source - # node's file, not a def line (#BUG1). - loc = str(d.get("source_location") or "") - return ( - f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(loc)}" - if loc else "" - ) - for nb in G.successors(nid): - d = edge_data(G, nid, nb) - rel = d.get("relation", "") - if rel_filter and rel_filter not in rel.lower(): - continue - lines.append( - f" --> {sanitize_label(G.nodes[nb].get('label', nb))} " - f"[{sanitize_label(str(rel))}] [{sanitize_label(str(d.get('confidence', '')))}]{_edge_at(d)}" - ) - for nb in G.predecessors(nid): - d = edge_data(G, nb, nid) - rel = d.get("relation", "") - if rel_filter and rel_filter not in rel.lower(): - continue - lines.append( - f" <-- {sanitize_label(G.nodes[nb].get('label', nb))} " - f"[{sanitize_label(str(rel))}] [{sanitize_label(str(d.get('confidence', '')))}]{_edge_at(d)}" - ) + lines += _neighbor_lines(G, nid, rel_filter) budget = int(arguments.get("token_budget", 2000)) return _cut_lines_to_budget( lines, budget, "Narrow with relation_filter or use get_node for a specific symbol" diff --git a/tests/test_affected_cli.py b/tests/test_affected_cli.py index ca608b6b3..95d02ecc6 100644 --- a/tests/test_affected_cli.py +++ b/tests/test_affected_cli.py @@ -311,3 +311,38 @@ def test_affected_falls_back_to_def_line_when_edge_has_no_location(monkeypatch, monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "affected", "target", "--graph", str(gp)]) mainmod.main() assert "a.py:L90" in capsys.readouterr().out + + +def test_affected_falls_back_to_def_line_when_edge_location_is_missing( + monkeypatch, tmp_path, capsys +): + """An edge with source_file but a NULL source_location (allowed by the + extraction schema) must fall back to the node's own def line — a bare + "traversed.py:-" is a non-clickable location, worse than the honest + fallback the pre-cluster formatter printed.""" + g = nx.DiGraph() + g.add_node("loader", label="load()", source_file="definition.py", source_location="L90") + g.add_node("target", label="target()", source_file="target.py", source_location="L5") + g.add_edge( + "loader", + "target", + relation="calls", + confidence="INFERRED", + source_file="traversed.py", + ) + graph_path = tmp_path / "graph.json" + graph_path.write_text( + json.dumps(json_graph.node_link_data(g, edges="links")), encoding="utf-8" + ) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "affected", "target", "--graph", str(graph_path)], + ) + + mainmod.main() + + out = capsys.readouterr().out + assert "traversed.py:-" not in out + assert "definition.py:L90" in out diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 7bff432cf..00346d81e 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -318,6 +318,32 @@ def test_graph_diff_removed_nodes(): assert "removed" in diff["summary"] +def test_graph_diff_simple_graph_ignores_edge_attribute_churn(): + """On simple graphs, edge identity stays (u, v, relation): a moved call + site (changed source_location) must not report edge removed + added. + Occurrence-level attributes only distinguish edges on multigraphs.""" + nodes = [("n1", "Alpha"), ("n2", "Beta")] + G_old = _make_simple_graph(nodes, [("n1", "n2", "calls", "EXTRACTED")]) + G_new = _make_simple_graph(nodes, [("n1", "n2", "calls", "EXTRACTED")]) + G_old.edges["n1", "n2"]["source_location"] = "L10" + G_new.edges["n1", "n2"]["source_location"] = "L99" + diff = graph_diff(G_old, G_new) + assert diff["new_edges"] == [] and diff["removed_edges"] == [] + + +def test_graph_diff_multigraph_distinguishes_parallel_edges(): + G_old = nx.MultiDiGraph() + G_new = nx.MultiDiGraph() + for G in (G_old, G_new): + G.add_node("n1", label="Alpha", source_file="test.py") + G.add_node("n2", label="Beta", source_file="test.py") + G.add_edge("n1", "n2", relation="calls", source_location="L10") + G_new.add_edge("n1", "n2", relation="calls", source_location="L99") + diff = graph_diff(G_old, G_new) + assert len(diff["new_edges"]) == 1 + assert diff["removed_edges"] == [] + + def test_graph_diff_new_edges(): nodes = [("n1", "Alpha"), ("n2", "Beta"), ("n3", "Gamma")] G_old = _make_simple_graph(nodes, [("n1", "n2", "calls", "EXTRACTED")]) diff --git a/tests/test_multigraph_build.py b/tests/test_multigraph_build.py new file mode 100644 index 000000000..ae3a93ee4 --- /dev/null +++ b/tests/test_multigraph_build.py @@ -0,0 +1,205 @@ +"""Opt-in MultiDiGraph extraction, persistence, and read-surface behavior.""" +from __future__ import annotations + +import json + +import networkx as nx +from networkx.readwrite import json_graph + +from graphify.affected import affected_nodes +from graphify.build import build_from_json, build_merge +from graphify.cluster_graph import build_cluster +from graphify.export import to_canvas, to_cypher, to_graphml, to_json +from graphify.exporters.html import to_html +from graphify.serve import _subgraph_to_text +from tests.test_cluster_build import _load_out, _node, make_member, write_cluster + + +def _parallel_extraction(): + return { + "nodes": [ + _node("a", source_file="a.py"), + _node("b", source_file="b.py"), + ], + "edges": [ + { + "source": "a", "target": "b", "relation": "calls", + "source_file": "a.py", "source_location": "L3", + "confidence": "EXTRACTED", + }, + { + "source": "a", "target": "b", "relation": "references", + "source_file": "a.py", "source_location": "L4", + "confidence": "EXTRACTED", + }, + ], + } + + +def test_multigraph_build_preserves_parallel_relations_and_stable_keys(tmp_path): + first = build_from_json(_parallel_extraction(), multigraph=True) + second = build_from_json(_parallel_extraction(), multigraph=True) + + assert isinstance(first, nx.MultiDiGraph) + assert first.number_of_edges("a", "b") == 2 + assert set(first["a"]["b"]) == set(second["a"]["b"]) + assert {data["relation"] for data in first["a"]["b"].values()} == { + "calls", "references" + } + + out = tmp_path / "graph.json" + assert to_json(first, {0: ["a", "b"]}, str(out)) + raw = json.loads(out.read_text(encoding="utf-8")) + assert raw["directed"] is True and raw["multigraph"] is True + assert len({edge["key"] for edge in raw["links"]}) == 2 + + +def test_multigraph_exact_duplicate_is_idempotent(): + extraction = _parallel_extraction() + extraction["edges"].append(dict(extraction["edges"][0])) + graph = build_from_json(extraction, multigraph=True) + assert graph.number_of_edges("a", "b") == 2 + + +def test_incremental_merge_infers_existing_multigraph_mode(tmp_path): + graph = build_from_json(_parallel_extraction(), multigraph=True) + path = tmp_path / "graph.json" + path.write_text( + json.dumps(json_graph.node_link_data(graph, edges="links")), + encoding="utf-8", + ) + + merged = build_merge([], graph_path=path) + assert isinstance(merged, nx.MultiDiGraph) + assert merged.number_of_edges("a", "b") == 2 + + +def test_multi_cluster_allows_distinct_declared_relations_on_same_pair(tmp_path): + make_member(tmp_path, "web", [_node("client", source_file="client.py")]) + make_member(tmp_path, "svc", [_node("server", source_file="server.py")]) + cluster = tmp_path / "cluster" + write_cluster( + cluster, + [{"tag": "web", "path": "../web"}, {"tag": "svc", "path": "../svc"}], + links=[ + { + "type": "api_call", "name": "api", + "from": {"repo": "web", "id": "client"}, + "to": {"repo": "svc", "id": "server"}, + }, + { + "type": "references", "name": "schema", + "from": {"repo": "web", "id": "client"}, + "to": {"repo": "svc", "id": "server"}, + }, + ], + graph_mode="multi", + ) + + build_cluster(cluster) + graph = _load_out(cluster) + assert isinstance(graph, nx.MultiDiGraph) + assert { + data["relation"] for data in graph["web::client"]["svc::server"].values() + } == {"calls_api", "references"} + + +def test_query_and_affected_surface_all_parallel_relations(): + graph = build_from_json(_parallel_extraction(), multigraph=True) + text = _subgraph_to_text(graph, {"a", "b"}, [("a", "b")]) + assert "--calls" in text and "--references" in text + + hits = affected_nodes(graph, "b", relations=("calls", "references"), depth=1) + assert len(hits) == 1 + assert hits[0].node_id == "a" + assert hits[0].via_relations == ("calls", "references") + + +def test_multigraph_exporters_keep_parallel_edges(tmp_path): + graph = build_from_json(_parallel_extraction(), multigraph=True) + communities = {0: ["a", "b"]} + + graphml = tmp_path / "graph.graphml" + to_graphml(graph, communities, str(graphml)) + loaded = nx.read_graphml(graphml) + assert loaded.number_of_edges() == 2 + + cypher = tmp_path / "graph.cypher" + to_cypher(graph, str(cypher)) + cypher_text = cypher.read_text(encoding="utf-8") + assert cypher_text.count("MERGE (a)-[") == 2 # keyed MERGE: re-runs stay idempotent + assert cypher_text.count("graphify_key") == 2 + + html = tmp_path / "graph.html" + to_html(graph, communities, str(html)) + rendered = html.read_text(encoding="utf-8") + assert '"label": "calls"' in rendered + assert '"label": "references"' in rendered + + canvas = tmp_path / "graph.canvas" + to_canvas(graph, communities, str(canvas)) + payload = json.loads(canvas.read_text(encoding="utf-8")) + assert len(payload["edges"]) == 2 + assert len({edge["id"] for edge in payload["edges"]}) == 2 + + +def test_get_neighbors_surfaces_all_parallel_relations(): + """get_neighbors must iterate edge_datas like query/path: edge_data picks + one arbitrary parallel edge, so a relation_filter could return empty for + relations that exist.""" + from graphify.serve import _neighbor_lines + + G = nx.MultiDiGraph() + G.add_node("a", label="A") + G.add_node("b", label="B") + G.add_edge("a", "b", key="k1", relation="calls", confidence="EXTRACTED") + G.add_edge("a", "b", key="k2", relation="references", confidence="INFERRED") + + out = _neighbor_lines(G, "a") + assert any("[calls]" in line for line in out) + assert any("[references]" in line for line in out) + incoming = _neighbor_lines(G, "b") + assert any("[calls]" in line for line in incoming) + assert any("[references]" in line for line in incoming) + # The filter applies per edge, not to an arbitrarily-picked one. + assert _neighbor_lines(G, "a", "references") and all( + "[references]" in line for line in _neighbor_lines(G, "a", "references") + ) + + +def test_mixed_graph_modes_compose_with_warning(tmp_path, capsys): + """A simple member in a multi cluster promotes with a warning; a + multigraph member in a simple cluster collapses parallels silently + (the coercion the loader documents).""" + # simple member into multi cluster + make_member(tmp_path, "plain", [ + _node("a", source_file="a.ts"), _node("b", source_file="b.ts"), + ], edges=[("a", "b", {"relation": "calls"})]) + multi_cluster = tmp_path / "multi-cluster" + write_cluster( + multi_cluster, [{"tag": "plain", "path": "../plain"}], + name="multi-cluster", graph_mode="multi", + ) + build_cluster(multi_cluster) + assert "re-extract it with --multigraph" in capsys.readouterr().err + assert isinstance(_load_out(multi_cluster), nx.MultiDiGraph) + + # multigraph member into simple cluster: parallel edges collapse to one + keyed = tmp_path / "keyed" / "graphify-out" + keyed.mkdir(parents=True) + M = nx.MultiDiGraph() + M.add_node("x", label="x", source_file="x.ts") + M.add_node("y", label="y", source_file="y.ts") + M.add_edge("x", "y", key="k1", relation="calls") + M.add_edge("x", "y", key="k2", relation="references") + (keyed / "graph.json").write_text( + json.dumps(json_graph.node_link_data(M, edges="links")), encoding="utf-8" + ) + simple_cluster = tmp_path / "simple-cluster" + write_cluster( + simple_cluster, [{"tag": "keyed", "path": "../keyed"}], name="simple-cluster" + ) + build_cluster(simple_cluster) + G = _load_out(simple_cluster) + assert not G.is_multigraph() + assert G.number_of_edges() == 1 From 60a1bb0e6f7a30390c7e99f6864c067922b9484a Mon Sep 17 00:00:00 2001 From: Stuart Gardner Date: Wed, 22 Jul 2026 23:51:09 -0400 Subject: [PATCH 11/11] docs(multigraph): README + CHANGELOG + --multigraph/--no-multigraph in help --- CHANGELOG.md | 4 +++- README.md | 14 +++++++++++--- graphify/__main__.py | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ce25f76..3cc327ca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ Full release notes with details on each version: [GitHub Releases](https://githu - 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. +- New: `affected` traverses `calls_api`/`mirrors` relations by default, so impact analysis crosses repo boundaries; when several parallel relations match a hop, all are listed. +- New: multigraph mode — `graphify extract --multigraph` (and `graph_mode: "multi"` clusters) builds keyed MultiDiGraphs that preserve parallel relations end to end: build/merge, incremental updates and watch, community detection (parallel weights aggregate), graph diff, MCP/query output, and GraphML/Cypher/Canvas exports. The format persists across rebuilds (including `--force`); `--no-multigraph` converts back with a warning. +- Fix: `graphify watch` now passes the project root through to the graph builder, aligning watch rebuilds with `graphify build`'s root-relative `source_file` paths (#932). ## 0.9.25 (2026-07-22) diff --git a/README.md b/README.md index 01711e2cf..8620eea97 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,7 @@ A cluster is a directory with a `cluster.json` spec: { "schema_version": 1, "name": "my-stack", + "graph_mode": "multi", "members": [ {"tag": "web", "url": "https://github.com/org/web", "path": "../web"}, {"tag": "worker", "url": "https://github.com/org/worker"} @@ -473,8 +474,12 @@ A cluster is a directory with a `cluster.json` spec: } ``` -YAML specs remain available when PyYAML is installed, but initialization and -documentation use JSON consistently. +`graph_mode` is `simple` by default. Set it to `multi` and extract members with +`graphify extract . --multigraph` to retain several relations between the same +nodes. The multigraph format persists across rebuilds (including `--force`); +`graphify extract . --no-multigraph` converts back, with a warning that +parallel relations collapse. 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. @@ -495,7 +500,7 @@ 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. +In simple mode, `cluster check` and `cluster build` reject a declared link that would overwrite another relation on the same pair. Multi mode preserves every keyed relation. `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. @@ -820,6 +825,9 @@ graphify export callflow-html --max-sections 8 # cap generated architecture graphify export callflow-html --output docs/arch.html graphify export callflow-html ./some-repo/graphify-out +graphify extract . --multigraph # opt in to keyed parallel relations (sticky across rebuilds) +graphify extract . --no-multigraph # convert back to a simple graph (collapses parallels) + graphify global add graphify-out/graph.json --as myrepo # register a project graph into ~/.graphify/global-graph.json graphify global remove myrepo # remove a project from the global graph graphify global list # show all registered repos + node/edge counts diff --git a/graphify/__main__.py b/graphify/__main__.py index 2f976c41f..50eebfac1 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -615,6 +615,9 @@ def _run_cli() -> None: print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction") print(" --no-gitignore ignore .gitignore and .git/info/exclude (prioritizes .graphifyignore)") print(" --no-cluster skip clustering, write raw extraction only") + print(" --multigraph keyed MultiDiGraph output: keep parallel relations between") + print(" the same node pair (persists across rebuilds)") + print(" --no-multigraph convert a multigraph back to simple (collapses parallel relations)") print(" --code-only index code (local AST, no API key) and skip doc/paper/image files") print(" --postgres DSN extract schema from a live PostgreSQL database") print(" maps tables, views, functions + FK relationships;")