33from __future__ import annotations
44
55import os
6+ import time
67from pathlib import Path
78from typing import TYPE_CHECKING , TypeVar
89
910import click
1011from rich .panel import Panel
11- from rich .progress import Progress
12+ from rich .progress import (
13+ BarColumn ,
14+ MofNCompleteColumn ,
15+ Progress ,
16+ SpinnerColumn ,
17+ TextColumn ,
18+ TimeElapsedColumn ,
19+ )
1220from rich .table import Table
1321from rich .text import Text
1422
3139
3240_T = TypeVar ("_T" )
3341
42+ # Per-phase icons for the live headers and the closing results panel.
43+ # Kept in one place so the whole set is trivially swappable (plan §10.5).
44+ _ICON_STRUCTURAL = "🔎"
45+ _ICON_PR_ORIGINS = "🔗"
46+ _ICON_LLM = "🧠"
47+ _ICON_CODEGRAPH = "🕸"
48+
3449
3550@click .command (name = "scan" )
3651@click .option (
3954 is_flag = True ,
4055 default = False ,
4156 help = (
42- "Skip Phase 2 (per-commit LLM descriptions). The git and GitHub "
43- "crawlers still run. The MCP tools `whygraph_evidence_for` and "
57+ "Skip the final LLM-descriptions phase (per-commit descriptions). "
58+ "The git and GitHub crawlers still run. The MCP tools "
59+ "`whygraph_evidence_for` and "
4460 "`whygraph_rationale_brief` lazily backfill descriptions on demand, "
4561 "and a later `whygraph scan` (without this flag) backfills the rest."
4662 ),
@@ -144,73 +160,134 @@ def scan_cmd(
144160 pr_origins_enabled = enrich_pr_origins and github_client is not None ,
145161 )
146162
163+ # Which optional phases have work — decided up front so the phase
164+ # headers can be numbered against the count of phases that actually run.
165+ run_pr_origins = enrich_pr_origins and github_client is not None
166+ run_analyze = descriptor is not None
167+ phase_total = 1 + int (run_pr_origins ) + int (run_analyze ) # Phase 1 always runs
168+
147169 scan_log_path = db_path .parent / "scan.log"
148- with scan_log_redirect (scan_log_path ), Progress () as progress :
149- # CodeGraph refresh — runs concurrently as its own crawler. It
150- # writes .codegraph/ and has no data dependency on the WhyGraph DB,
151- # so it overlaps the entire crawl (started with phase 1, joined
152- # last). Best-effort: failures land on .warning, not .error.
170+ phase_timings : dict [str , float ] = {}
171+ ran : list [Crawler ] = []
172+ scan_t0 = time .monotonic ()
173+ # Share the stderr `console` with Progress so the phase headers render
174+ # above the live bars on one stream, and add an M-of-N + elapsed column
175+ # so the slow LLM phase reports "12/45 · 0:00:31".
176+ with (
177+ scan_log_redirect (scan_log_path ),
178+ Progress (
179+ SpinnerColumn (),
180+ TextColumn ("[progress.description]{task.description}" ),
181+ BarColumn (),
182+ MofNCompleteColumn (),
183+ TimeElapsedColumn (),
184+ console = console ,
185+ ) as progress ,
186+ ):
187+ # CodeGraph refresh — a background crawler. It writes .codegraph/
188+ # and has no data dependency on the WhyGraph DB, so it overlaps the
189+ # entire crawl (started before Phase 1, joined last). Best-effort:
190+ # failures land on .warning, not .error.
153191 codegraph_crawler = (
154192 CodeGraphCrawler (
155193 progress , project_root = repository .root , image = codegraph_image
156194 )
157195 if refresh_codegraph
158196 else None
159197 )
198+ if codegraph_crawler is not None :
199+ codegraph_crawler .start ()
160200
161- # Phase 1 — source crawlers, run concurrently.
201+ n = 0
202+
203+ # ── Phase 1 · Structural crawl — git + GitHub, concurrent. ──
204+ n += 1
205+ console .rule (
206+ f"{ _ICON_STRUCTURAL } Phase { n } /{ phase_total } · Structural crawl" ,
207+ style = "cyan" ,
208+ )
209+ t0 = time .monotonic ()
162210 phase1 : list [Crawler ] = [GitCrawler (progress , repository = repository )]
163211 if github_client is not None :
164212 phase1 .append (GitHubCrawler (progress , client = github_client ))
165-
166- # Phase 2 — started only once phase 1 has joined (it reads the
167- # commits + PRs phase 1 persisted). The analyzer and the PR-origin
168- # enricher run concurrently: analyze only touches main-walk commits,
169- # the enricher only inserts new on_default_branch=0 rows, so they
170- # never contend over the same commit row.
171- phase2 : list [Crawler ] = []
172- if descriptor is not None :
173- phase2 .append (
174- AnalyzeCrawler (
175- progress ,
176- repository = repository ,
177- descriptor = descriptor ,
178- max_workers = config .scan_max_workers ,
179- large_commit_file_count = config .analyze .large_commit_file_count ,
180- )
181- )
182- # The enricher needs PR rows (the GitHub crawler ran) and the
183- # network for its fetch — so it is gated on a resolved client, which
184- # is itself None under --no-remote.
185- if enrich_pr_origins and github_client is not None :
186- phase2 .append (
187- PROriginEnricher (
188- progress ,
189- repository = repository ,
190- min_commits = config .analyze .pr_origin_min_commits ,
191- large_commit_file_count = config .analyze .large_commit_file_count ,
192- )
193- )
194-
195- if codegraph_crawler is not None :
196- codegraph_crawler .start ()
213+ ran += phase1
197214 for c in phase1 :
198215 c .start ()
199216 for c in phase1 :
200217 c .join ()
201- for c in phase2 :
202- c .start ()
203- for c in phase2 :
204- c .join ()
218+ phase_timings ["Structural crawl" ] = time .monotonic () - t0
219+ _print_phase_done (
220+ "Structural crawl" ,
221+ phase_timings ["Structural crawl" ],
222+ ok = all (c .error is None for c in phase1 ),
223+ )
224+
225+ # ── Phase 2 · PR-origin recovery — needs Phase 1's git + PR rows.
226+ # Gated on a resolved client, which is None under --no-remote. ──
227+ if run_pr_origins :
228+ n += 1
229+ console .rule (
230+ f"{ _ICON_PR_ORIGINS } Phase { n } /{ phase_total } · PR-origin recovery" ,
231+ style = "cyan" ,
232+ )
233+ t0 = time .monotonic ()
234+ enricher = PROriginEnricher (
235+ progress ,
236+ repository = repository ,
237+ min_commits = config .analyze .pr_origin_min_commits ,
238+ large_commit_file_count = config .analyze .large_commit_file_count ,
239+ )
240+ ran .append (enricher )
241+ enricher .start ()
242+ enricher .join ()
243+ phase_timings ["PR-origin recovery" ] = time .monotonic () - t0
244+ _print_phase_done (
245+ "PR-origin recovery" ,
246+ phase_timings ["PR-origin recovery" ],
247+ ok = enricher .error is None ,
248+ )
249+
250+ # ── Phase 3 · LLM descriptions — the slow, token-heavy long pole,
251+ # run strictly last and alone. Only ever describes main-walk
252+ # commits, so the recovered on_default_branch=0 rows stay lazy. ──
253+ if run_analyze :
254+ n += 1
255+ console .rule (
256+ f"{ _ICON_LLM } Phase { n } /{ phase_total } · LLM descriptions" ,
257+ style = "cyan" ,
258+ )
259+ t0 = time .monotonic ()
260+ analyzer = AnalyzeCrawler (
261+ progress ,
262+ repository = repository ,
263+ descriptor = descriptor ,
264+ max_workers = config .scan_max_workers ,
265+ large_commit_file_count = config .analyze .large_commit_file_count ,
266+ )
267+ ran .append (analyzer )
268+ analyzer .start ()
269+ analyzer .join ()
270+ phase_timings ["LLM descriptions" ] = time .monotonic () - t0
271+ _print_phase_done (
272+ "LLM descriptions" ,
273+ phase_timings ["LLM descriptions" ],
274+ ok = analyzer .error is None ,
275+ )
276+
205277 if codegraph_crawler is not None :
206278 codegraph_crawler .join ()
207279
208- console .print (f"Scan log: { scan_log_path } " )
209-
210- if codegraph_crawler is not None and codegraph_crawler .warning is not None :
211- console .print (Text (codegraph_crawler .warning , style = "yellow" ))
280+ total_elapsed = time .monotonic () - scan_t0
281+ _render_results_panel (
282+ ran = ran ,
283+ codegraph_crawler = codegraph_crawler ,
284+ db_path = db_path ,
285+ scan_log_path = scan_log_path ,
286+ phase_timings = phase_timings ,
287+ total_elapsed = total_elapsed ,
288+ )
212289
213- crawlers = phase1 + phase2
290+ crawlers = list ( ran )
214291 if codegraph_crawler is not None :
215292 crawlers .append (codegraph_crawler )
216293 failed = [c for c in crawlers if c .error is not None ]
@@ -220,6 +297,138 @@ def scan_cmd(
220297 raise click .exceptions .Exit (1 )
221298
222299
300+ def _fmt_elapsed (seconds : float ) -> str :
301+ """Format an elapsed duration as ``"4.1s"`` or ``"2m 08s"``."""
302+ if seconds < 60 :
303+ return f"{ seconds :.1f} s"
304+ minutes , secs = divmod (int (round (seconds )), 60 )
305+ return f"{ minutes } m { secs :02d} s"
306+
307+
308+ def _print_phase_done (title : str , seconds : float , * , ok : bool ) -> None :
309+ """Print the dim per-phase completion line under that phase's bars.
310+
311+ ``ok`` reflects whether every crawler in the phase finished without an
312+ error; a failed phase gets a red ``✗`` (the real error is also surfaced
313+ by the closing failure sweep and results panel).
314+ """
315+ glyph = "✓" if ok else "✗"
316+ console .print (
317+ f" { glyph } { title } · { _fmt_elapsed (seconds )} " ,
318+ style = "dim" if ok else "red" ,
319+ )
320+
321+
322+ def _status_glyph (* , ok : bool , warn : bool = False ) -> Text :
323+ """Return the results-panel status cell: ``✓`` / ``⚠`` / ``✗``."""
324+ if not ok :
325+ return Text ("✗" , style = "red" )
326+ if warn :
327+ return Text ("⚠" , style = "yellow" )
328+ return Text ("✓" , style = "green" )
329+
330+
331+ def _optional_phase_cells (
332+ crawler : "Crawler | None" , timing : str
333+ ) -> tuple [object , object , str ]:
334+ """Return the (status, summary, timing) cells for an optional phase.
335+
336+ A crawler that never ran (phase skipped via ``--no-remote`` /
337+ ``--no-llm-descriptions``) renders a dim ``— skipped`` status with no
338+ summary or timing.
339+ """
340+ if crawler is None :
341+ return Text ("— skipped" , style = "dim" ), "" , ""
342+ return _status_glyph (ok = crawler .error is None ), crawler .summary or "—" , timing
343+
344+
345+ def _render_results_panel (
346+ * ,
347+ ran : "list[Crawler]" ,
348+ codegraph_crawler : "Crawler | None" ,
349+ db_path : Path ,
350+ scan_log_path : Path ,
351+ phase_timings : "dict[str, float]" ,
352+ total_elapsed : float ,
353+ ) -> None :
354+ """Print the closing results panel — a bookend to the pre-scan panel.
355+
356+ One row per phase (git + GitHub merge into the structural row), each
357+ carrying a status glyph, the crawler's own one-line summary, and the
358+ phase's elapsed time; then the database / scan-log paths. CodeGraph is
359+ a background task, so it gets a row but no per-phase timing. Pure
360+ formatting over data already in hand — no DB or network access — so it
361+ can never turn a successful crawl into a crash.
362+ """
363+ by_name = {c .name : c for c in ran }
364+ git = by_name .get ("git" )
365+ github = by_name .get ("github" )
366+ enricher = by_name .get ("pr-origins" )
367+ analyzer = by_name .get ("analyze" )
368+
369+ def _timing (title : str ) -> str :
370+ seconds = phase_timings .get (title )
371+ return _fmt_elapsed (seconds ) if seconds is not None else ""
372+
373+ grid = Table .grid (padding = (0 , 2 ))
374+ grid .add_column (no_wrap = True ) # icon
375+ grid .add_column (style = "bold cyan" , no_wrap = True ) # label
376+ grid .add_column (justify = "center" , no_wrap = True ) # status
377+ grid .add_column (overflow = "fold" ) # summary
378+ grid .add_column (justify = "right" , no_wrap = True ) # timing
379+
380+ # Structural row — git + GitHub combined into one phase row.
381+ structural = [c for c in (git , github ) if c is not None ]
382+ structural_summary = " · " .join (c .summary for c in structural if c .summary ) or "—"
383+ grid .add_row (
384+ _ICON_STRUCTURAL ,
385+ "Structural crawl" ,
386+ _status_glyph (ok = all (c .error is None for c in structural )),
387+ structural_summary ,
388+ _timing ("Structural crawl" ),
389+ )
390+ grid .add_row (
391+ _ICON_PR_ORIGINS ,
392+ "PR-origin recovery" ,
393+ * _optional_phase_cells (enricher , _timing ("PR-origin recovery" )),
394+ )
395+ grid .add_row (
396+ _ICON_LLM ,
397+ "LLM descriptions" ,
398+ * _optional_phase_cells (analyzer , _timing ("LLM descriptions" )),
399+ )
400+
401+ # CodeGraph — background task; no per-phase timing.
402+ if codegraph_crawler is None :
403+ grid .add_row (
404+ _ICON_CODEGRAPH , "CodeGraph" , Text ("— skipped" , style = "dim" ), "" , ""
405+ )
406+ else :
407+ warning = getattr (codegraph_crawler , "warning" , None )
408+ grid .add_row (
409+ _ICON_CODEGRAPH ,
410+ "CodeGraph" ,
411+ _status_glyph (ok = codegraph_crawler .error is None , warn = warning is not None ),
412+ codegraph_crawler .summary or warning or "—" ,
413+ "" ,
414+ )
415+
416+ grid .add_row ("" , "" , "" , "" , "" )
417+ grid .add_row ("" , "Database" , "" , str (db_path ), "" )
418+ grid .add_row ("" , "Scan log" , "" , str (scan_log_path ), "" )
419+
420+ console .print (
421+ Panel (
422+ grid ,
423+ title = f"whygraph scan · done in { _fmt_elapsed (total_elapsed )} " ,
424+ title_align = "left" ,
425+ border_style = "cyan" ,
426+ padding = (1 , 2 ),
427+ )
428+ )
429+ console .print ()
430+
431+
223432def _apply_github_token (config : "Config" ) -> None :
224433 """Export the configured GitHub token so every ``gh`` subprocess sees it.
225434
0 commit comments