diff --git a/graphify/watch.py b/graphify/watch.py index 8ad02c4df..e97e2e065 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -14,6 +14,12 @@ from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT, is_absolute_any_platform _PENDING_FILENAME = ".pending_changes" _PENDING_DRAIN_MAX_PASSES = 20 +# #3045: below this corpus size the post-extraction phases (build/cluster/ +# analyze/report/export) finish fast enough that stage output would just be +# noise. Above it — the AST-extraction progress print in extract.py uses the +# same threshold — a large corpus can spend real time in each phase with no +# output at all, which reads as a hang instead of a build in progress. +_STAGE_PROGRESS_MIN_FILES = 100 def _queue_pending(out_dir: Path, changed_paths: list[Path]) -> None: @@ -1649,6 +1655,9 @@ def _failed(f: str) -> bool: "total_words": detected.get("total_words", 0), } + _show_stage_progress = len(code_files) >= _STAGE_PROGRESS_MIN_FILES + if _show_stage_progress: + print("[graphify watch] Building graph...", flush=True) # Inherit the existing graph's directed flag (#2342) so `graphify # update` can't silently downgrade a directed graph to undirected - # build_from_json defaults to directed=False otherwise. @@ -1693,11 +1702,18 @@ def _failed(f: str) -> bool: print("[graphify watch] No code-graph topology changes detected; outputs left untouched.") return True + if _show_stage_progress: + print( + f"[graphify watch] Clustering graph ({G.number_of_nodes()} nodes, " + f"{G.number_of_edges()} edges)...", flush=True, + ) communities = cluster(G) previous_node_community = _node_community_map(existing_graph_data) if previous_node_community: communities = remap_communities_to_previous(communities, previous_node_community) cohesion = score_all(G, communities) + if _show_stage_progress: + print("[graphify watch] Analyzing graph structure...", flush=True) gods = god_nodes(G) surprises = surprising_connections(G, communities) labels_file = out / ".graphify_labels.json" @@ -1758,6 +1774,8 @@ def _failed(f: str) -> bool: file=sys.stderr, ) questions = suggest_questions(G, communities, labels) + if _show_stage_progress: + print("[graphify watch] Generating report...", flush=True) from graphify.report import load_learning_for_report as _llfr report = generate(G, communities, cohesion, labels, gods, surprises, detection, {"input": 0, "output": 0}, report_root, suggested_questions=questions, @@ -1765,6 +1783,8 @@ def _failed(f: str) -> bool: 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" graph_tmp = out / ".graph.tmp.json" + if _show_stage_progress: + print("[graphify watch] Writing graph.json...", flush=True) json_written = to_json(G, communities, str(graph_tmp), force=True, built_at_commit=commit, community_labels=labels) if not json_written: return False diff --git a/tests/test_watch.py b/tests/test_watch.py index 25d0cd967..455ed7cb4 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -265,6 +265,45 @@ def test_rebuild_code_writes_community_name(tmp_path): ) +def test_rebuild_code_reports_post_extraction_stage_progress(tmp_path, capsys): + """#3045: on a large corpus, `graphify update` finished AST extraction (100%) + and then went silent through build/cluster/analyze/report/export, which read + as a hang on a slow machine instead of a build still in progress. Above the + same 100-file threshold extract.py's own AST-extraction progress print uses, + _rebuild_code must announce each post-extraction stage.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + for i in range(100): + (corpus / f"m{i}.py").write_text( + f"def f{i}():\n return f{(i + 1) % 100}()\n", encoding="utf-8" + ) + assert _rebuild_code(corpus, acquire_lock=False) is True + + out = capsys.readouterr().out + assert "[graphify watch] Building graph..." in out + assert "[graphify watch] Clustering graph (" in out + assert "[graphify watch] Analyzing graph structure..." in out + assert "[graphify watch] Generating report..." in out + assert "[graphify watch] Writing graph.json..." in out + + +def test_rebuild_code_stays_quiet_on_a_small_corpus(tmp_path, capsys): + """The #3045 stage-progress prints are gated on corpus size so a normal-sized + rebuild's output is not cluttered with lines that only matter at scale.""" + 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 = capsys.readouterr().out + assert "[graphify watch] Building graph..." not in out + assert "[graphify watch] Clustering graph (" not in out + + 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,