From 76474b46fd41657224770143bde8061423c970d3 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:31:24 +0530 Subject: [PATCH 1/6] Read and merge a pending labels sidecar in watch rebuild A community without a curated name gets a deterministic hub fallback so reports and graph.json always show something readable, but that fallback has always been written straight into the same tracked file as genuinely curated, manual or LLM, names. This is the read half of splitting the two apart: a new sidecar next to the tracked labels file is now also loaded and merged into the working labels dict, and every cid that came from it, or gets freshly hub filled this run rather than reused from the tracked file, is tracked in a separate set so the write step can route each entry back to the file it belongs in. Stale invalidation is extended to cover sidecar entries the same way it already covers tracked ones. Toward #3334. The write side, which is what actually stops fallback names from landing in the tracked file, follows in the next commit; this commit alone still writes the merged result into the tracked file as before, so existing behavior is unchanged and no test needs updating yet. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/watch.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/graphify/watch.py b/graphify/watch.py index 9943b5caf..ad10f2370 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 @@ -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 @@ -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 " From 83c8ba10c2fe9097658c60a0b89a6f970aa09dbe Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:32:05 +0530 Subject: [PATCH 2/6] Route hub fallback labels to the pending sidecar on write The write half of splitting the two label kinds apart. Labels are split back into the tracked, curated set and the pending, hub fallback set right before serialising, using the cid set the read side built up. The tracked file gets only the curated names, exactly as before this change set began; the pending set goes to the new sidecar, or the sidecar is removed outright once nothing in it is still unpromoted, so a stale, now empty pending file never lingers once its last entry becomes a real label. Fixes #3334. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/watch.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/graphify/watch.py b/graphify/watch.py index ad10f2370..f0aa63b59 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -2084,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: @@ -2146,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 From 464ee4e3b9253e194b65a4d51731541171dca3ef Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:32:49 +0530 Subject: [PATCH 3/6] Update the stale label test for the pending sidecar split This test simulates a naming pass by reading whatever cid list the first rebuild produced and overwriting each with a distinctive name. That list now comes from the pending sidecar rather than the tracked file, matching what a real naming pass does: promote a pending, hub fallback entry into a curated one, removing it from the sidecar in the same move. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_watch.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_watch.py b/tests/test_watch.py index a056538f4..bad2bdc54 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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"): From d369d13db7f05e288a160f7596d54299b777a2de Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:33:25 +0530 Subject: [PATCH 4/6] Add a test pinning hub fallback names out of the tracked file A from empty rebuild on a two file corpus must leave the tracked labels file empty while writing both communities' hub names to the pending sidecar, and every node's community_name in graph.json must still carry the right name regardless of which file it actually lives in. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_watch.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_watch.py b/tests/test_watch.py index bad2bdc54..610ce9efb 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -396,6 +396,53 @@ 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_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 From 4365d9ba48ad2f0f22aaf9f01b14c91bd0bce54b Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:33:56 +0530 Subject: [PATCH 5/6] Add a test pinning pending label reuse across rebuilds A hand edited pending sidecar entry must survive an unrelated rebuild untouched, matching the reuse guarantee already given to curated labels, rather than getting silently regenerated from the hub every single run. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_watch.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_watch.py b/tests/test_watch.py index 610ce9efb..2e5b6e2f7 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -443,6 +443,40 @@ def test_rebuild_code_keeps_hub_fallback_labels_out_of_the_tracked_file(tmp_path ) +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_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 From 240b759202c3e52bf5f17bca7ac52b10f7cc98a7 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:34:31 +0530 Subject: [PATCH 6/6] Add a test pinning label promotion out of the pending sidecar Simulates a real naming pass by promoting every pending entry into the tracked file and deleting the sidecar, then forces a real rebuild. The promoted names must survive, and the community must not reappear in the sidecar even though it did not change membership. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_watch.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_watch.py b/tests/test_watch.py index 2e5b6e2f7..e233e564e 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -477,6 +477,47 @@ def test_rebuild_code_reuses_pending_labels_without_regenerating(tmp_path): ) +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