diff --git a/graphify/watch.py b/graphify/watch.py index 1ef1ebd4..08b6fdd4 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1240,6 +1240,7 @@ def _rebuild_code( gods = god_nodes(G) surprises = surprising_connections(G, communities) labels_file = out / ".graphify_labels.json" + sig_file = out / (".graphify_labels.json" + ".sig") 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 @@ -1251,12 +1252,50 @@ def _rebuild_code( except Exception: raw = {} labels = {} + # 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 + # the membership signature saved beside the labels — the same guard the + # cluster-only path applies — and drop any whose community changed so the + # hub-fill below renames it, deterministically and correct-by-construction. + # Without this, an incremental `graphify update` launders stale names into + # labels.json as though they were current (#label-stale). + from graphify.cluster import community_member_sigs + cur_sigs = community_member_sigs(communities) + saved_sigs: dict[int, str] = {} + if sig_file.exists(): + try: + saved_sigs = { + int(k): v for k, v in + json.loads(sig_file.read_text(encoding="utf-8")).items() + if isinstance(v, str) + } + except Exception: + saved_sigs = {} + if saved_sigs: + # Precise: the signature tells us exactly which communities changed. + stale = {cid for cid in labels if saved_sigs.get(cid) != cur_sigs.get(cid)} + 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. + stale = set(labels) if len(raw) != len(communities) else set() + for cid in stale: + del labels[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)) + if stale: + print( + f"[graphify watch] community set changed since labeling " + f"({len(raw)} saved labels, {len(communities)} communities now; " + f"renamed {len(stale)} community(ies) by their hub). " + f"Run `graphify label` to refresh names with the LLM.", + file=sys.stderr, + ) questions = suggest_questions(G, communities, labels) from graphify.report import load_learning_for_report as _llfr report = generate(G, communities, cohesion, labels, gods, surprises, detection, @@ -1301,6 +1340,12 @@ def _rebuild_code( graph_tmp.replace(existing_graph) report_path.write_text(report, encoding="utf-8") labels_file.write_text(labels_json, encoding="utf-8") + # 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 + # clustering, so the guard above had nothing accurate to check against. + sig_file.write_text( + json.dumps({str(k): v for k, v in cur_sigs.items()}), encoding="utf-8") (out / ".graphify_root").write_text(str(watch_path), encoding="utf-8") diff --git a/tests/test_watch.py b/tests/test_watch.py index b60c0f60..a9b3ebe8 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -188,6 +188,85 @@ def test_rebuild_code_writes_community_name(tmp_path): ) +def test_rebuild_code_drops_labels_whose_community_changed(tmp_path): + """An incremental rebuild must not reuse a saved label for a community whose + membership changed. Labels are keyed by cid, but re-clustering reassigns cids, + so after new files land cid N can cover a different community and its old name + is then simply wrong. cluster-only guards this with the `.sig` membership + fingerprints; _rebuild_code ignored them and hub-filled only *missing* labels, + so stale names survived and were written back to labels.json as if current.""" + import json + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "a.py").write_text( + "def alpha():\n return beta()\n\ndef beta():\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" + 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" + labels_file.write_text( + json.dumps({cid: f"Named-{cid}" for cid in labels}), encoding="utf-8" + ) + + # Grow the corpus so clustering changes, then rebuild incrementally. + for name in ("b.py", "c.py", "d.py"): + (corpus / name).write_text( + f"def {name[0]}_one():\n return {name[0]}_two()\n\n" + f"def {name[0]}_two():\n return 2\n", + encoding="utf-8", + ) + assert _rebuild_code(corpus, acquire_lock=False) is True + + graph = json.loads((out / "graph.json").read_text(encoding="utf-8")) + after = json.loads(labels_file.read_text(encoding="utf-8")) + sigs = json.loads(sig_file.read_text(encoding="utf-8")) + + communities = {} + for node in graph["nodes"]: + cid = node.get("community") + if cid is not None: + communities.setdefault(str(cid), []).append(node["id"]) + + from graphify.cluster import community_member_sigs + expected = { + str(cid): sig + for cid, sig in community_member_sigs( + {int(c): m for c, m in communities.items()} + ).items() + } + assert sigs == expected, ( + "signatures must be rewritten in step with the labels; a drifting sidecar " + "leaves the staleness guard nothing accurate to check against" + ) + + # Any surviving "Named-N" must sit on a community that genuinely did not change. + for cid, name in after.items(): + if name.startswith("Named-"): + assert cid in communities, f"label kept for vanished community {cid}" + assert sigs[cid] == expected[cid], ( + f"community {cid} kept the stale label {name!r} after its " + f"membership changed" + ) + + for node in graph["nodes"]: + cid = node.get("community") + if cid is not None and node.get("community_name", "").startswith("Named-"): + assert sigs[str(cid)] == expected[str(cid)], ( + f"node {node['id']} carries stale community_name " + f"{node['community_name']!r}" + ) + + def test_update_rebuilds_with_nested_star_gitignore(tmp_path): """#1880: `graphify update` must not emit 0 nodes (and then refuse to overwrite) just because the source tree has a nested `.gitignore` with a