3131import textwrap
3232from pathlib import Path
3333
34- from graph_utils import load_snapshot_graph , tarjan_scc
34+ from graph_utils import load_snapshot_graph
3535from html_renderer import render_html
3636from scan_analysis import (
3737 init_policy ,
@@ -52,13 +52,15 @@ def cmd_scan(args: argparse.Namespace) -> int:
5252
5353 payload = build_scan_payload (repo , snapshot , strategy , top_n = 8 )
5454
55+ # Pop internal fields — not part of public JSON output
56+ graph = payload .pop ("_graph" )
57+ sccs = payload .pop ("_sccs" )
58+
5559 if args .json :
5660 print (json .dumps (payload , indent = 2 , ensure_ascii = False ))
5761 return 0
5862
59- # Generate HTML visualization
60- graph = load_snapshot_graph (repo , snapshot )
61- sccs = tarjan_scc (list (graph .nodes .keys ()), list (graph .edges ))
63+ # Generate HTML visualization — reuse already-computed graph and sccs
6264 html_payload = {
6365 "node_list" : sorted (graph .nodes .keys ()),
6466 "edge_list" : list (graph .edges ),
@@ -79,11 +81,27 @@ def cmd_scan(args: argparse.Namespace) -> int:
7981 print (f"{ payload ['health' ]['cycles' ]['count' ]} cycles, { violations } violations, { status } → { output } " )
8082 return 0
8183
84+ def _diff_default_snapshots (repo : Path ) -> tuple [str , str ]:
85+ """Smart default for diff with no args:
86+ - If worktree has uncommitted changes: HEAD vs worktree
87+ - If worktree is clean: HEAD~1 vs HEAD (last commit)
88+ """
89+ result = subprocess .run (
90+ ["git" , "status" , "--porcelain" ],
91+ cwd = repo , capture_output = True , text = True
92+ )
93+ if result .stdout .strip ():
94+ return "HEAD" , "worktree"
95+ return "HEAD~1" , "HEAD"
96+
8297
8398def cmd_diff (args : argparse .Namespace ) -> int :
8499 repo = Path (args .repo ).resolve ()
85- from_snap = args .from_snapshot or "HEAD"
86- to_snap = args .to_snapshot or "worktree"
100+ if args .from_snapshot or args .to_snapshot :
101+ from_snap = args .from_snapshot or "HEAD"
102+ to_snap = args .to_snapshot or "worktree"
103+ else :
104+ from_snap , to_snap = _diff_default_snapshots (repo )
87105 strategy = load_strategy (args .strategy , DEFAULT_STRATEGY )
88106 init_policy (strategy .get ("policy" , {}).get ("rules" , []))
89107
@@ -93,32 +111,66 @@ def cmd_diff(args: argparse.Namespace) -> int:
93111 print (json .dumps (payload , indent = 2 , ensure_ascii = False ))
94112 return 0
95113
96- # Human-readable summary
114+ # Human-readable — show meaningful detail, not just counts
97115 hc = payload ["health_comparison" ]
98116 cyc = hc ["cycles" ]
99117 vio = hc ["violations" ]
100118
101- parts = [ f"{ from_snap } → { to_snap } :" ]
102- parts . append ( f"edges { payload [ 'edge_changes' ][ 'added' ]:+d } / { - payload [ 'edge_changes' ][ 'removed' ] } " )
119+ print ( f"{ from_snap } → { to_snap } " )
120+ print ( )
103121
122+ # Health overview
104123 cyc_delta = cyc ["largest" ]["delta" ]
105- if cyc_delta :
106- parts .append (f"largest_cycle { cyc ['largest' ]['from' ]} →{ cyc ['largest' ]['to' ]} ({ cyc_delta :+d} )" )
107-
108124 vio_delta = vio ["delta" ]
109- parts .append (f"violations { vio ['from' ]} →{ vio ['to' ]} ({ vio_delta :+d} )" )
110-
125+ print (f" cycles: { cyc ['count' ]['from' ]} →{ cyc ['count' ]['to' ]} "
126+ f"largest { cyc ['largest' ]['from' ]} →{ cyc ['largest' ]['to' ]} ({ cyc_delta :+d} )" )
127+ print (f" violations: { vio ['from' ]} →{ vio ['to' ]} ({ vio_delta :+d} )" )
128+ ec = payload ["edge_changes" ]
129+ print (f" edges: +{ ec ['added' ]} /-{ ec ['removed' ]} (net { ec ['net' ]:+d} )" )
130+ print ()
131+
132+ # SCC changes
133+ scc_changes = payload .get ("scc_changes" , [])
134+ if scc_changes :
135+ print (" SCC changes:" )
136+ for ch in scc_changes :
137+ if "resolved" in ch :
138+ print (f" ✓ resolved size={ ch ['from_size' ]} → gone" )
139+ elif "new_cycle" in ch :
140+ members = ", " .join (ch ["new_cycle" ][:4 ])
141+ ellipsis = f" +{ len (ch ['new_cycle' ])- 4 } more" if len (ch ["new_cycle" ]) > 4 else ""
142+ print (f" ✗ new cycle size={ ch ['to_size' ]} : { members } { ellipsis } " )
143+ else :
144+ delta = ch ["delta" ]
145+ if ch ["gained_members" ]:
146+ print (f" ~ grew { ch ['from_size' ]} →{ ch ['to_size' ]} ({ delta :+d} )" )
147+ for m in ch ["gained_members" ]:
148+ print (f" + { m } " )
149+ if ch ["lost_members" ]:
150+ print (f" ~ shrank { ch ['from_size' ]} →{ ch ['to_size' ]} ({ delta :+d} )" )
151+ for m in ch ["lost_members" ]:
152+ print (f" - { m } " )
153+ print ()
154+
155+ # Violations fixed / introduced
111156 fixed = payload ["violations_fixed" ]
112- new = payload ["violations_new" ]
157+ new_v = payload ["violations_new" ]
113158 if fixed :
114- parts .append (f"fixed={ len (fixed )} " )
115- if new :
116- parts .append (f"new={ len (new )} " )
159+ print (f" Fixed violations ({ len (fixed )} ):" )
160+ for v in fixed :
161+ print (f" ✓ [{ v ['rule' ]} ] { v ['src' ]} → { v ['dst' ]} " )
162+ print ()
163+ if new_v :
164+ print (f" New violations ({ len (new_v )} ):" )
165+ for v in new_v :
166+ print (f" ✗ [{ v ['rule' ]} ] { v ['src' ]} → { v ['dst' ]} " )
167+ print ()
168+ if not fixed and not new_v :
169+ print (" No violation changes." )
170+ print ()
117171
118- print (" " .join (parts ))
119172 return 0
120173
121-
122174def main () -> int :
123175 parser = argparse .ArgumentParser (
124176 description = "Dependency topology scanner — detect layering violations and visualize module dependencies." ,
0 commit comments