Skip to content

Commit 0d1bc13

Browse files
committed
fix: address copilot review + improve diff output
- graph_utils: clear error message on invalid git ref - scan_analysis: remove unused imports (degree, largest_scc_size) - html_renderer: defensive isinstance guard in match_group; fix expanded cluster edges — children now show actual connections via rawEdges projection instead of appearing isolated - scan_topology: eliminate duplicate graph/SCC computation in cmd_scan; smart diff default (dirty worktree → HEAD vs worktree, clean → HEAD~1 vs HEAD); expand diff human output with SCC changes, fixed/new violations per edge - add requirements.txt declaring json5 dependency - AGENTS.md: document diff default behavior and ref syntax
1 parent b234f35 commit 0d1bc13

6 files changed

Lines changed: 164 additions & 41 deletions

File tree

scripts/dependency-topology/AGENTS.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,29 @@ python3 scripts/dependency-topology/scan_topology.py scan -o /tmp/deps.html
2525
# JSON output (for scripts/agents)
2626
python3 scripts/dependency-topology/scan_topology.py scan --json
2727

28-
# Compare HEAD vs working tree (default)
28+
# Diff — smart default:
29+
# worktree has uncommitted Lua changes → HEAD vs worktree
30+
# worktree is clean → HEAD~1 vs HEAD (last commit)
2931
python3 scripts/dependency-topology/scan_topology.py diff
3032

31-
# Compare specific refs
32-
python3 scripts/dependency-topology/scan_topology.py diff --from main --to HEAD
33+
# Compare specific refs (branch names, commit SHAs, remote refs)
34+
python3 scripts/dependency-topology/scan_topology.py diff --from upstream/main --to clean-code-remove-core
35+
python3 scripts/dependency-topology/scan_topology.py diff --from HEAD~5 --to HEAD
3336
```
3437

3538
## Snapshot References
3639

3740
- `worktree` — current working tree (uncommitted changes)
3841
- `HEAD` — latest commit
39-
- Any git ref — branch name, tag, commit SHA
42+
- Any git ref — branch name (e.g. `upstream/main`), tag, short or full commit SHA
43+
- Relative refs — `HEAD~1`, `HEAD^`
4044

41-
**diff defaults:** `--from HEAD --to worktree`
45+
**diff defaults (no args):**
46+
- Worktree has uncommitted Lua changes → `HEAD` vs `worktree`
47+
- Worktree is clean → `HEAD~1` vs `HEAD`
48+
49+
Note: ambiguous short names (e.g. `upstream` when both a local branch and remote exist)
50+
produce a git warning. Prefer fully-qualified refs: `upstream/main`, `refs/heads/mybranch`.
4251

4352
## Output
4453

scripts/dependency-topology/graph_utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@ def _worktree_files(repo: Path) -> List[Tuple[str, str]]:
5252

5353
def _git_files(repo: Path, ref: str) -> List[Tuple[str, str]]:
5454
cmd = ["git", "ls-tree", "-r", "--name-only", ref, "lua/opencode"]
55-
ls = subprocess.check_output(cmd, cwd=repo, text=True)
55+
try:
56+
ls = subprocess.check_output(cmd, cwd=repo, text=True, stderr=subprocess.PIPE)
57+
except subprocess.CalledProcessError as e:
58+
stderr = e.stderr.strip() if e.stderr else ""
59+
raise ValueError(
60+
f"Invalid snapshot ref '{ref}'. Valid values: HEAD, worktree, branch name, commit SHA.\n"
61+
f"git error: {stderr}"
62+
) from None
5663

5764
out: List[Tuple[str, str]] = []
5865
for rel in ls.splitlines():

scripts/dependency-topology/html_renderer.py

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,13 @@ class ClusterEdge:
3737
def match_group(module: str, groups: Dict[str, Any]) -> str:
3838
"""Match module to group using fnmatch patterns."""
3939
for group_name, group_data in groups.items():
40+
if not isinstance(group_data, dict):
41+
continue
4042
patterns = group_data.get("modules", [])
43+
if not isinstance(patterns, list):
44+
continue
4145
for pattern in patterns:
42-
if fnmatch.fnmatch(module, pattern):
46+
if isinstance(pattern, str) and fnmatch.fnmatch(module, pattern):
4347
return group_name
4448
return "ungrouped"
4549

@@ -134,7 +138,7 @@ def render_html(payload: dict, groups: Dict[str, Any], cluster_depth: int = 2) -
134138
node_list, edge_list, scc_nodes, groups, depth=cluster_depth
135139
)
136140

137-
# Build GRAPH data
141+
# Build GRAPH data — cluster edges for default view, rawEdges for expand
138142
graph_data = {
139143
'nodes': [
140144
{
@@ -149,13 +153,25 @@ def render_html(payload: dict, groups: Dict[str, Any], cluster_depth: int = 2) -
149153
],
150154
'edges': [
151155
{
152-
'src': e.src,
153-
'dst': e.dst,
156+
'src': e.src,
157+
'dst': e.dst,
154158
'weight': e.weight,
155159
'isViolation': e.is_violation,
156160
'rule': e.rule
157161
}
158162
for e in cluster_edges
163+
],
164+
# Raw module-level edges — used when clusters are expanded so children
165+
# can show their actual connections instead of appearing isolated.
166+
'rawEdges': [
167+
{
168+
'src': src,
169+
'dst': dst,
170+
'isViolation': bool(match_group(src, groups) and
171+
_edge_rule(match_group(src, groups), match_group(dst, groups))),
172+
'rule': _edge_rule(match_group(src, groups), match_group(dst, groups)),
173+
}
174+
for src, dst in edge_list
159175
]
160176
}
161177

@@ -451,19 +467,50 @@ def render_html(payload: dict, groups: Dict[str, Any], cluster_depth: int = 2) -
451467
}
452468
});
453469
454-
// Build edges between visible nodes
470+
// Build edges between visible nodes.
471+
// If any cluster is expanded, use rawEdges (module-level) and project them:
472+
// - if endpoint is a visible child → use as-is
473+
// - if endpoint's parent cluster is collapsed → map to cluster id
474+
// - if endpoint is ungrouped leaf → use as-is
455475
const nodeIdSet = new Set(currentNodes.map(n => n.id));
456-
457-
// Use original edges, map to visible nodes
458-
GRAPH.edges.forEach(e => {
459-
let src = e.src, dst = e.dst;
460-
// If src/dst is expanded cluster, need to find which child
461-
// For simplicity, show edge if both endpoints visible or map to cluster
462-
if (nodeIdSet.has(src) && nodeIdSet.has(dst)) {
463-
currentEdges.push({ src, dst, isViolation: e.isViolation, rule: e.rule });
476+
477+
// Build child→parentCluster reverse lookup for projection
478+
const childToCluster = {};
479+
currentNodes.forEach(n => { if (n.parentCluster) childToCluster[n.id] = n.parentCluster; });
480+
GRAPH.nodes.forEach(n => {
481+
if (n.isCluster && n.children) {
482+
n.children.forEach(c => { if (!childToCluster[c]) childToCluster[c] = n.id; });
464483
}
465484
});
466-
485+
486+
function resolveEndpoint(id) {
487+
if (nodeIdSet.has(id)) return id; // directly visible
488+
const cluster = childToCluster[id];
489+
if (cluster && nodeIdSet.has(cluster)) return cluster; // map to collapsed cluster
490+
return null;
491+
}
492+
493+
if (expandedClusters.size > 0) {
494+
// Use raw module-level edges and project to visible nodes
495+
const seen = new Set();
496+
GRAPH.rawEdges.forEach(e => {
497+
const src = resolveEndpoint(e.src);
498+
const dst = resolveEndpoint(e.dst);
499+
if (!src || !dst || src === dst) return;
500+
const key = src + '|' + dst;
501+
if (seen.has(key)) return;
502+
seen.add(key);
503+
currentEdges.push({ src, dst, isViolation: e.isViolation, rule: e.rule });
504+
});
505+
} else {
506+
// All clusters collapsed: use pre-aggregated cluster edges (faster)
507+
GRAPH.edges.forEach(e => {
508+
if (nodeIdSet.has(e.src) && nodeIdSet.has(e.dst)) {
509+
currentEdges.push({ src: e.src, dst: e.dst, isViolation: e.isViolation, rule: e.rule });
510+
}
511+
});
512+
}
513+
467514
renderGraph();
468515
}
469516
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Dependencies for the dependency topology scanner.
2+
# Install with: pip install -r scripts/dependency-topology/requirements.txt
3+
4+
json5>=0.9.0 # JSONC parser — required for topology.jsonc (supports // and /* */ comments)

scripts/dependency-topology/scan_analysis.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pathlib import Path
1010
from typing import Any, Dict, List, Set, Tuple
1111

12-
from graph_utils import back_edges, degree, largest_scc_size, load_snapshot_graph, tarjan_scc, find_cycle_in_scc
12+
from graph_utils import back_edges, load_snapshot_graph, tarjan_scc, find_cycle_in_scc
1313

1414

1515
_POLICY_RULES: List[Dict[str, Any]] = []
@@ -304,6 +304,10 @@ def scc_severity(size: int) -> str:
304304

305305
# Layer coverage — confirms all modules are classified
306306
"group_coverage": grouped_counts,
307+
308+
# Internal: raw graph for HTML rendering (not included in --json output)
309+
"_graph": graph,
310+
"_sccs": comps,
307311
}
308312

309313

scripts/dependency-topology/scan_topology.py

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import textwrap
3232
from pathlib import Path
3333

34-
from graph_utils import load_snapshot_graph, tarjan_scc
34+
from graph_utils import load_snapshot_graph
3535
from html_renderer import render_html
3636
from 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

8398
def 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-
122174
def main() -> int:
123175
parser = argparse.ArgumentParser(
124176
description="Dependency topology scanner — detect layering violations and visualize module dependencies.",

0 commit comments

Comments
 (0)