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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ def _run_cli() -> None:
print(" update <path> 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 <path> 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> path to graph.json (default <path>/graphify-out/graph.json)")
Expand Down Expand Up @@ -606,7 +606,7 @@ def _run_cli() -> None:
print(" --out DIR output dir (default: <path>); writes <DIR>/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;")
Expand Down
43 changes: 23 additions & 20 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 "
Expand All @@ -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"]:
Expand Down
5 changes: 4 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 12 additions & 4 deletions graphify/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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")
Expand All @@ -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")
Expand Down
30 changes: 18 additions & 12 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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():
Expand Down
10 changes: 10 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 13 additions & 1 deletion tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down