diff --git a/graphify/watch.py b/graphify/watch.py index ec0a33878..8b9a5bdc0 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -2394,6 +2394,19 @@ def _rebuild_code( 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 @@ -2405,6 +2418,19 @@ def _rebuild_code( 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 @@ -2431,16 +2457,21 @@ def _rebuild_code( 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 " @@ -2455,7 +2486,14 @@ def _rebuild_code( {"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: @@ -2538,6 +2576,14 @@ def _rebuild_code( # that no longer exists. Writing labels first keeps the sidecar # trailing, which the guard sees as a mismatch and re-labels. write_text_atomic(labels_file, labels_json) + if pending_labels: + write_text_atomic(pending_labels_file, pending_labels_json) + 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) write_text_atomic( sig_file, json.dumps({str(k): v for k, v in cur_sigs.items()})) diff --git a/tests/test_watch.py b/tests/test_watch.py index ed0a69e32..a7dfd812e 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -385,16 +385,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"): @@ -445,6 +452,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