Skip to content
50 changes: 48 additions & 2 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,19 @@ def _failed(f: str) -> bool:
surprises = surprising_connections(G, communities)
labels_file = out / ".graphify_labels.json"
sig_file = out / (".graphify_labels.json" + ".sig")
# #3334: a community without a curated name gets a deterministic hub
# fallback below so reports and graph.json always show something
# readable -- but that fallback used to be written straight into the
# same tracked file as genuinely-curated (manual or LLM) names, so a
# from-empty rebuild (a fresh clone, a new worktree, any CI run) kept
# appending fallback entries to a file users are told is the reviewed,
# committable semantic layer. Fallback names now live in a SEPARATE
# sidecar (`pending_labels_file`) that never needs reviewing, so the
# tracked file only ever contains names someone actually set.
# `pending_cids` tracks which entries in `labels` came from there (or
# from this run's hub-fill) rather than the tracked file, so the write
# step below can route each cid back to the file it belongs in.
pending_labels_file = out / ".graphify_labels.pending.json"
try:
raw = json.loads(labels_file.read_text(encoding="utf-8")) if labels_file.exists() else {}
# Skip persisted "Community N" placeholders so the hub-fill below
Expand All @@ -2003,6 +2016,19 @@ def _failed(f: str) -> bool:
except Exception:
raw = {}
labels = {}
pending_cids: set[int] = set()
try:
pending_raw = (
json.loads(pending_labels_file.read_text(encoding="utf-8"))
if pending_labels_file.exists() else {}
)
for k, v in pending_raw.items():
cid = int(k)
if cid in communities and cid not in labels and v != f"Community {cid}":
labels[cid] = v
pending_cids.add(cid)
except Exception:
pass
# A saved label belongs to a cid, but re-clustering reassigns cids: after a
# rebuild that adds nodes, cid 30 can cover a completely different community
# and its old name is then simply wrong. Validate every reused label against
Expand All @@ -2029,16 +2055,21 @@ def _failed(f: str) -> bool:
else:
# No sidecar (labels predate it). A differing community COUNT means the
# labels describe a different clustering, so no cid's label is trustworthy;
# an equal count is the best available "unchanged" signal.
# an equal count is the best available "unchanged" signal. Compare
# against the tracked file alone (`raw`), not the pending-merged
# `labels` -- a from-empty rebuild's fresh pending entries must not
# count toward "how many labels were saved".
stale = set(labels) if len(raw) != len(communities) else set()
for cid in stale:
del labels[cid]
pending_cids.discard(cid)
missing = {cid: members for cid, members in communities.items() if cid not in labels}
if missing:
# Deterministic hub name (highest-degree member) beats a bare "Community N"
# placeholder for any community without a saved label.
from graphify.cluster import label_communities_by_hub
labels.update(label_communities_by_hub(G, missing))
pending_cids.update(missing)
if stale:
print(
f"[graphify watch] community set changed since labeling "
Expand All @@ -2053,7 +2084,14 @@ def _failed(f: str) -> bool:
{"input": 0, "output": 0}, report_root, suggested_questions=questions,
built_at_commit=commit, learning=_llfr(out / "graph.json"))
report_path = out / "GRAPH_REPORT.md"
labels_json = json.dumps({str(k): v for k, v in sorted(labels.items())}, ensure_ascii=False, indent=2) + "\n"
# Split back into the tracked (curated) file and the pending
# (hub-fallback) sidecar (#3334) -- report/graph.json generation above
# already used the full, merged `labels` so a fallback-named community
# still reads correctly there; only the on-disk split changes.
curated_labels = {cid: v for cid, v in labels.items() if cid not in pending_cids}
pending_labels = {cid: v for cid, v in labels.items() if cid in pending_cids}
labels_json = json.dumps({str(k): v for k, v in sorted(curated_labels.items())}, ensure_ascii=False, indent=2) + "\n"
pending_labels_json = json.dumps({str(k): v for k, v in sorted(pending_labels.items())}, ensure_ascii=False, indent=2) + "\n"
graph_tmp = out / ".graph.tmp.json"
json_written = to_json(G, communities, str(graph_tmp), force=True, built_at_commit=commit, community_labels=labels)
if not json_written:
Expand Down Expand Up @@ -2115,6 +2153,14 @@ def _failed(f: str) -> bool:
os_replace_with_fallback(graph_tmp, existing_graph)
report_path.write_text(report, encoding="utf-8")
labels_file.write_text(labels_json, encoding="utf-8")
if pending_labels:
pending_labels_file.write_text(pending_labels_json, encoding="utf-8")
else:
# Every community now has a curated name (or there simply are
# none pending this run) -- a leftover empty/stale sidecar
# would otherwise sit there forever once its last entry is
# promoted to a real label.
pending_labels_file.unlink(missing_ok=True)
# Keep the membership signatures in step with the labels we just wrote.
# Skipping this was the other half of the stale-label bug: labels.json
# advanced every rebuild while the sidecar kept describing an older
Expand Down
139 changes: 134 additions & 5 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,16 +329,23 @@ def test_rebuild_code_drops_labels_whose_community_changed(tmp_path):

out = corpus / "graphify-out"
labels_file = out / ".graphify_labels.json"
pending_file = out / ".graphify_labels.pending.json"
sig_file = out / ".graphify_labels.json.sig"
assert sig_file.exists(), "rebuild must persist membership signatures beside labels"

# Stand in for an LLM naming pass: give every community a distinctive name,
# leaving the signatures untouched so they still describe THIS clustering.
labels = json.loads(labels_file.read_text(encoding="utf-8"))
assert labels, "expected the first rebuild to write community labels"
# A from-empty rebuild's hub-fallback names land in the pending sidecar,
# not the tracked file (#3334) -- read the cid list from there.
pending = json.loads(pending_file.read_text(encoding="utf-8"))
assert pending, "expected the first rebuild to write pending hub-fallback labels"

# Stand in for an LLM naming pass: give every community a distinctive,
# CURATED name (a real naming pass promotes a pending entry into the
# tracked file), leaving the signatures untouched so they still describe
# THIS clustering.
labels_file.write_text(
json.dumps({cid: f"Named-{cid}" for cid in labels}), encoding="utf-8"
json.dumps({cid: f"Named-{cid}" for cid in pending}), encoding="utf-8"
)
pending_file.unlink()

# Grow the corpus so clustering changes, then rebuild incrementally.
for name in ("b.py", "c.py", "d.py"):
Expand Down Expand Up @@ -389,6 +396,128 @@ def test_rebuild_code_drops_labels_whose_community_changed(tmp_path):
)


def test_rebuild_code_keeps_hub_fallback_labels_out_of_the_tracked_file(tmp_path):
"""#3334: a from-empty rebuild (a fresh clone, a new worktree, any CI job
with no prior graphify-out/) names every community by its deterministic
hub, but that name is not something anyone reviewed or asked to commit.
Writing it into the same tracked file as genuinely curated names meant a
routine rebuild kept appending placeholder entries to a file the project
is told to commit as its reviewed semantic layer. The tracked file must
stay empty on a first, from-empty rebuild; the fallback names must still
be findable (in the pending sidecar) and must still reach the report."""
import json
from graphify.watch import _rebuild_code

corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "a.py").write_text("def alpha():\n return 1\n", encoding="utf-8")
(corpus / "b.py").write_text("def beta():\n return 2\n", encoding="utf-8")

assert _rebuild_code(corpus, acquire_lock=False) is True

out = corpus / "graphify-out"
labels_file = out / ".graphify_labels.json"
pending_file = out / ".graphify_labels.pending.json"

tracked = json.loads(labels_file.read_text(encoding="utf-8"))
assert tracked == {}, (
f"a from-empty rebuild must not write hub-fallback names into the "
f"tracked labels file; got {tracked}"
)
assert pending_file.exists(), "hub-fallback names must land in the pending sidecar"
pending = json.loads(pending_file.read_text(encoding="utf-8"))
assert pending, "expected at least one hub-fallback label in the sidecar"

# The fallback names must still reach graph.json, unaffected by the split:
# every node's community_name comes from the merged (curated + pending)
# labels dict, not from the tracked file alone.
graph = json.loads((out / "graph.json").read_text(encoding="utf-8"))
names_seen = {
str(n.get("community")): n.get("community_name")
for n in graph["nodes"] if n.get("community") is not None
}
for cid, name in pending.items():
assert names_seen.get(cid) == name, (
f"community {cid}'s hub-fallback name did not reach graph.json "
f"(expected {name!r}, saw {names_seen.get(cid)!r})"
)


def test_rebuild_code_reuses_pending_labels_without_regenerating(tmp_path):
"""A hub-fallback name, once written to the pending sidecar, must be
REUSED on the next rebuild (not silently regenerated every run) as long
as the community's membership hasn't changed -- matching the existing
reuse guarantee already given to curated labels."""
import json
from graphify.watch import _rebuild_code

corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "a.py").write_text("def alpha():\n return 1\n", encoding="utf-8")

assert _rebuild_code(corpus, acquire_lock=False) is True
out = corpus / "graphify-out"
pending_file = out / ".graphify_labels.pending.json"
first = json.loads(pending_file.read_text(encoding="utf-8"))
assert first

# Hand-edit the pending sidecar to a distinguishable value, then force a
# real rebuild (unrelated file added, so the topology fast path is
# skipped and the label merge logic actually runs) without touching the
# community's own membership.
pending_file.write_text(
json.dumps({cid: "Reused Fallback Name" for cid in first}), encoding="utf-8"
)
(corpus / "unrelated.py").write_text("x = 1\n", encoding="utf-8")
assert _rebuild_code(corpus, acquire_lock=False) is True

second = json.loads(pending_file.read_text(encoding="utf-8"))
assert "Reused Fallback Name" in second.values(), (
f"pending label was regenerated instead of reused; got {second}"
)


def test_rebuild_code_promotes_curated_label_and_drops_it_from_pending(tmp_path):
"""A community that starts as an unreviewed hub fallback and then gets a
real (curated) name -- simulating what a `graphify label` pass does --
must end up in the tracked file, not still duplicated in the pending
sidecar."""
import json
from graphify.watch import _rebuild_code

corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "a.py").write_text("def alpha():\n return 1\n", encoding="utf-8")

assert _rebuild_code(corpus, acquire_lock=False) is True
out = corpus / "graphify-out"
labels_file = out / ".graphify_labels.json"
pending_file = out / ".graphify_labels.pending.json"
pending = json.loads(pending_file.read_text(encoding="utf-8"))
assert pending

# Promote every pending entry into the tracked file, as a real naming
# pass would, and remove it from pending.
labels_file.write_text(
json.dumps({cid: f"Curated {cid}" for cid in pending}), encoding="utf-8"
)
pending_file.unlink()

(corpus / "unrelated.py").write_text("x = 1\n", encoding="utf-8")
assert _rebuild_code(corpus, acquire_lock=False) is True

tracked_after = json.loads(labels_file.read_text(encoding="utf-8"))
for cid in pending:
assert tracked_after.get(cid, "").startswith("Curated "), (
f"promoted label for community {cid} did not survive in the tracked file"
)
if pending_file.exists():
pending_after = json.loads(pending_file.read_text(encoding="utf-8"))
assert set(pending_after) & set(pending) == set(), (
"a promoted community must not still appear in the pending sidecar"
)


def test_rebuild_code_keeps_a_visualization_when_over_the_viz_cap(tmp_path, monkeypatch):
"""Crossing the viz node limit must not leave the project with no graph.html.
_rebuild_code used to unlink the existing file and write nothing, so a repo
Expand Down
Loading