From f5ae7621e8b3f0e058b217013a01e398c35ac4ec Mon Sep 17 00:00:00 2001 From: "Can H. Tartanoglu" Date: Thu, 16 Jul 2026 10:09:15 +0200 Subject: [PATCH 1/3] fix: repair OpenCode plugin registration --- graphify/install.py | 16 ++++++++++++---- tests/test_install.py | 14 +++++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/graphify/install.py b/graphify/install.py index 0ad0750a5..96a1139fc 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -1245,6 +1245,7 @@ def _uninstall_kilo_plugin(project_dir: Path) -> None: }; """ _OPENCODE_PLUGIN_PATH = Path(".opencode") / "plugins" / "graphify.js" +_OPENCODE_PLUGIN_ENTRY = Path("plugins") / "graphify.js" _OPENCODE_CONFIG_PATH = Path(".opencode") / "opencode.json" def _install_opencode_plugin(project_dir: Path) -> None: """Write graphify.js plugin and register it in opencode.json.""" @@ -1263,7 +1264,11 @@ def _install_opencode_plugin(project_dir: Path) -> None: config = {} plugins = config.setdefault("plugin", []) - entry = _OPENCODE_PLUGIN_PATH.as_posix() + entry = _OPENCODE_PLUGIN_ENTRY.as_posix() + # OpenCode resolves plugin entries relative to opencode.json. Older + # releases registered the project-relative path, causing a duplicated + # .opencode path during plugin loading. + plugins[:] = [plugin for plugin in plugins if plugin != _OPENCODE_PLUGIN_PATH.as_posix()] if entry not in plugins: plugins.append(entry) config_file.write_text(json.dumps(config, indent=2), encoding="utf-8") @@ -1285,9 +1290,12 @@ def _uninstall_opencode_plugin(project_dir: Path) -> None: except json.JSONDecodeError: return plugins = config.get("plugin", []) - entry = _OPENCODE_PLUGIN_PATH.as_posix() - if entry in plugins: - plugins.remove(entry) + entries = { + _OPENCODE_PLUGIN_PATH.as_posix(), + _OPENCODE_PLUGIN_ENTRY.as_posix(), + } + if any(plugin in entries for plugin in plugins): + plugins[:] = [plugin for plugin in plugins if plugin not in entries] if not plugins: config.pop("plugin") config_file.write_text(json.dumps(config, indent=2), encoding="utf-8") diff --git a/tests/test_install.py b/tests/test_install.py index b1bcaa780..406ca126b 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -676,7 +676,19 @@ def test_opencode_agents_install_registers_plugin_in_config(tmp_path): import json as _json config = _json.loads(config_file.read_text()) - assert any("graphify.js" in p for p in config.get("plugin", [])) + assert "plugins/graphify.js" in config.get("plugin", []) + assert ".opencode/plugins/graphify.js" not in config.get("plugin", []) + + +def test_opencode_agents_install_repairs_legacy_plugin_entry(tmp_path): + import json as _json + + config_file = tmp_path / ".opencode" / "opencode.json" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(_json.dumps({"plugin": [".opencode/plugins/graphify.js"]})) + _agents_install(tmp_path, "opencode") + config = _json.loads(config_file.read_text()) + assert config["plugin"] == ["plugins/graphify.js"] def test_opencode_agents_install_merges_existing_config(tmp_path): From 3cadda9e82f41fe49e2bf66fe15d1de571ca3389 Mon Sep 17 00:00:00 2001 From: "Can H. Tartanoglu" Date: Fri, 17 Jul 2026 23:42:58 +0200 Subject: [PATCH 2/3] Normalize no-cluster graph output through endpoint reconciliation Rewrite the --no-cluster path in both extract and watch to use build_from_json and node_link_data instead of raw dedup, so the unclustered graph passes the diagnostic integrity gate. Also update documentation (AGENTS.md, README, --help text) to describe the normalized output and add the endpoint-safe graph regression test. --- AGENTS.md | 2 +- README.md | 4 ++-- graphify/__main__.py | 4 ++-- graphify/cli.py | 43 +++++++++++++++++++++++-------------------- graphify/watch.py | 30 ++++++++++++++++++------------ tests/test_watch.py | 29 +++++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b919654c4..0012ee1a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,4 +5,4 @@ This project has a graphify knowledge graph at graphify-out/. Rules: - Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure - If graphify-out/wiki/index.md exists, navigate it instead of reading raw files -- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) +- After modifying code files in this session, run `graphify update .` and then `graphify diagnose multigraph --graph graphify-out/graph.json --json` to keep the graph current and verify endpoint integrity (AST-only, no API cost) diff --git a/README.md b/README.md index e73db3aa0..d15bfaaed 100644 --- a/README.md +++ b/README.md @@ -720,7 +720,7 @@ graphify extract ./docs --api-timeout 900 # longer HTTP timeout for slow lo graphify extract ./docs --google-workspace # export .gdoc/.gsheet/.gslides via gws before extraction graphify extract ./src --no-gitignore # include git-ignored source; still honor .graphifyignore graphify extract ./docs --mode deep # richer semantic extraction via extended system prompt -graphify extract ./docs --no-cluster # raw extraction only, skip clustering +graphify extract ./docs --no-cluster # normalized unclustered graph, skip clustering graphify extract ./docs --timing # print per-stage wall-clock timings to stderr (also works on cluster-only) graphify extract ./docs --force # overwrite graph.json even if new graph has fewer nodes (use after refactors or to clear ghost duplicates) graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous entity pairs (uses same API key) @@ -752,7 +752,7 @@ graphify --version # print installed version graphify watch ./src graphify check-update ./src graphify update ./src -graphify update ./src --no-cluster # skip reclustering, write raw AST graph only +graphify update ./src --no-cluster # skip reclustering, write a normalized unclustered graph graphify update ./src --force # overwrite even if new graph has fewer nodes graphify cluster-only ./my-project graphify cluster-only ./my-project --graph path/to/graph.json # custom graph location diff --git a/graphify/__main__.py b/graphify/__main__.py index d97d48fda..d06dcfc61 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -538,7 +538,7 @@ def _run_cli() -> None: print(" update re-extract code files and update the graph (no LLM needed)") print(" --force overwrite graph.json even if the rebuild has fewer nodes") print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)") - print(" --no-cluster skip clustering, write raw extraction only") + print(" --no-cluster skip clustering, write a normalized unclustered graph") print(" cluster-only rerun clustering on an existing graph.json and regenerate report") print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)") print(" --graph path to graph.json (default /graphify-out/graph.json)") @@ -606,7 +606,7 @@ def _run_cli() -> None: print(" --out DIR output dir (default: ); writes /graphify-out/") 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(" --no-cluster skip clustering, write a normalized unclustered graph") 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;") diff --git a/graphify/cli.py b/graphify/cli.py index 774034220..66c065360 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3043,17 +3043,17 @@ def _progress(idx: int, total: int, _result: dict) -> None: ) if no_cluster: - # --no-cluster: dump the raw merged extraction as graph.json. - # No NetworkX, no community detection, no analysis sidecar. - # Dedupe nodes (by id) and parallel edges so the raw output matches the - # clustered path (whose DiGraph collapses both) and stays deterministic - # across modes (#1317; node dedup also collapses shared Swift module - # anchors emitted per importing file, #1327). - from graphify.build import dedupe_edges as _dedupe_edges, dedupe_nodes as _dedupe_nodes + # --no-cluster: write an unclustered graph without community + # detection or an analysis sidecar. Build it through the same + # endpoint reconciliation as the clustered path; a raw merged + # extraction can contain external endpoints and parallel edges, + # which makes graph.json unsafe for downstream consumers (#1781). + from graphify.build import build_from_json as _build_unclustered from graphify.export import ( backup_if_protected as _backup, existing_graph_node_count as _existing_graph_node_count, ) + from networkx.readwrite import json_graph as _json_graph if ( incremental_mode and not code_files @@ -3089,16 +3089,19 @@ def _progress(idx: int, total: int, _result: dict) -> None: stages.total() sys.exit(0) - merged["nodes"] = _dedupe_nodes(merged["nodes"]) - merged["edges"] = _dedupe_edges(merged["edges"]) - # Backfill source_file from endpoint nodes — this raw path bypasses - # build_from_json's backfill, and semantic edges sometimes omit it (#1279). - _node_sf = {n.get("id"): n.get("source_file") for n in merged["nodes"]} - for _e in merged["edges"]: - if not _e.get("source_file"): - _e["source_file"] = ( - _node_sf.get(_e.get("source")) or _node_sf.get(_e.get("target")) or "" - ) + _unclustered = _build_unclustered(merged, root=target) + try: + _normalized = _json_graph.node_link_data(_unclustered, edges="links") + except TypeError: + _normalized = _json_graph.node_link_data(_unclustered) + for _link in _normalized.get("links", []): + _true_src = _link.pop("_src", None) + _true_tgt = _link.pop("_tgt", None) + if _true_src is not None and _true_tgt is not None: + _link["source"] = _true_src + _link["target"] = _true_tgt + _normalized["input_tokens"] = merged["input_tokens"] + _normalized["output_tokens"] = merged["output_tokens"] # RT-parity for the raw path: an incomplete build must not force a # partial graph over a larger complete one here either. The clustered # path gets this from to_json's #479 guard; this path never calls @@ -3108,7 +3111,7 @@ def _progress(idx: int, total: int, _result: dict) -> None: from graphify.export import MALFORMED_GRAPH as _MALFORMED_GRAPH _existing_n = _existing_graph_node_count(graph_json_path) _malformed = _existing_n is _MALFORMED_GRAPH - _shrinks = isinstance(_existing_n, int) and len(merged["nodes"]) < _existing_n + _shrinks = isinstance(_existing_n, int) and len(_normalized["nodes"]) < _existing_n if _malformed or _shrinks: _detail = ( f"the existing {graph_json_path} is present but unparseable " @@ -3127,14 +3130,14 @@ def _progress(idx: int, total: int, _result: dict) -> None: sys.exit(1) _backup(graphify_out) from graphify.paths import write_json_atomic as _write_json_atomic - _write_json_atomic(graph_json_path, merged, indent=2) + _write_json_atomic(graph_json_path, _normalized, indent=2) stages.mark("write") cost = _estimate_cost( backend, merged["input_tokens"], merged["output_tokens"] ) print( f"[graphify extract] wrote {graph_json_path} — " - f"{len(merged['nodes'])} nodes, {len(merged['edges'])} edges " + f"{len(_normalized['nodes'])} nodes, {len(_normalized['links'])} edges " f"(no clustering)" ) if merged["input_tokens"] or merged["output_tokens"]: diff --git a/graphify/watch.py b/graphify/watch.py index 37edc9cb1..5d78272ac 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -818,8 +818,11 @@ def _rebuild_code( ``block_on_lock=True`` to wait instead of skip (used by the interactive ``graphify update`` CLI). - ``no_cluster`` skips community detection and writes raw merged extraction - JSON to graphify-out/graph.json (mirrors ``extract --no-cluster``). + ``no_cluster`` skips community detection and writes the merged extraction + after the same endpoint reconciliation used by the clustered path. The + result is an unclustered graph, not an intermediate raw extraction, so + graph.json remains safe for consumers that expect every link endpoint to + name a node. Returns True on success, False on error or skipped-due-to-lock. """ @@ -1081,16 +1084,19 @@ def _add_deleted_source(path: Path) -> None: out.mkdir(exist_ok=True) if no_cluster: - # Normalise to "links" key so schema is consistent with the full clustered path. - # 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", [])), - } + # Keep the no-cluster output on the same normalization path as the + # full build. The old raw dump preserved unresolved external + # endpoints and parallel edges, which made a freshly generated + # graph.json fail the diagnostic integrity gate even though the + # clustered build was healthy (#1781). + normalized_graph = build_from_json(result, root=project_root) + candidate_graph_data = _topology_from_graph(normalized_graph) + for link in candidate_graph_data.get("links", []): + true_src = link.pop("_src", None) + true_tgt = link.pop("_tgt", None) + if true_src is not None and true_tgt is not None: + link["source"] = true_src + link["target"] = true_tgt candidate_graph_text = _json_text(candidate_graph_data) same_graph = False if existing_graph.exists(): diff --git a/tests/test_watch.py b/tests/test_watch.py index e665e7738..1dd3a660d 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -363,6 +363,35 @@ def test_rebuild_code_deleted_cwd_uses_graphify_repo_root(tmp_path, monkeypatch) os.chdir(old_cwd) +def test_no_cluster_writes_endpoint_safe_graph(tmp_path): + """The unclustered output must be a valid graph, not raw extraction JSON.""" + from graphify.diagnostics import diagnose_file + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "main.py").write_text( + "import missing_module\n\n" + "def main():\n" + " return missing_module.value\n", + encoding="utf-8", + ) + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + graph_path = corpus / "graphify-out" / "graph.json" + data = json.loads(graph_path.read_text(encoding="utf-8")) + node_ids = {node["id"] for node in data["nodes"]} + + assert all( + link["source"] in node_ids and link["target"] in node_ids + for link in data["links"] + ) + summary = diagnose_file(graph_path) + assert summary["dangling_endpoint_edges"] == 0 + assert summary["directed_same_endpoint_collapsed_edges"] == 0 + assert summary["undirected_same_endpoint_collapsed_edges"] == 0 + + def test_rebuild_code_evicts_nodes_from_deleted_files(tmp_path): """#1007: graphify update (_rebuild_code with no changed_paths) must remove nodes and edges from files deleted since the last run.""" From af21df265ce46bb7d216f6b5fa4ec2f4218f58b5 Mon Sep 17 00:00:00 2001 From: "Can H. Tartanoglu" Date: Fri, 17 Jul 2026 23:43:05 +0200 Subject: [PATCH 3/3] Suppress zero-node warning for skipped JSON data files JSON data files are intentionally skipped by the structural extractor. They should not trigger the zero-node aggregate warning, which was designed for failed code extractions (#1666). --- graphify/extract.py | 5 ++++- tests/test_extract.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index bc10f7d7d..947593e4a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4409,7 +4409,10 @@ def extract( _empty_sources: list[str] = [] for i, _p in enumerate(paths): _res = per_file[i] or {} - if _res.get("nodes") or _res.get("error"): + # JSON data files are intentionally skipped by the structural extractor + # (#1224). They are not failed code extractions and must not repeatedly + # trigger the zero-node warning (#1666). + if _res.get("nodes") or _res.get("error") or _res.get("skipped"): continue if _get_extractor(_p) is not None: _empty_sources.append(str(_p)) diff --git a/tests/test_extract.py b/tests/test_extract.py index 98b73edb9..f3877c282 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1753,6 +1753,16 @@ def test_extract_json_top_level_array_skipped(tmp_path): assert result["edges"] == [] +def test_extract_json_data_file_does_not_trigger_zero_node_warning(tmp_path, capsys): + """Intentionally skipped data JSON must stay quiet in the aggregate pass.""" + data = tmp_path / "records.json" + data.write_text(json.dumps([{"id": 1}, {"id": 2}])) + + extract([data], cache_root=tmp_path / "out", parallel=False) + + assert "zero nodes" not in capsys.readouterr().err + + def test_extract_json_config_by_filename_still_extracted(tmp_path): """tsconfig.json must still be AST-extracted even without telltale keys.""" cfg = tmp_path / "tsconfig.json"