diff --git a/.gitignore b/.gitignore index 44490023..503f43d1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ deps/ # Local Claude settings (keep out of repo) .claude/ + +# Dependency topology tool artifacts +scripts/dependency-topology/__pycache__/ +*.html diff --git a/AGENTS.md b/AGENTS.md index 067ebec4..76c0eb57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,13 @@ - **Comments:** Avoid obvious comments that merely restate what the code does. Only add comments when necessary to explain _why_ something is done, not _what_ is being done. Prefer self-explanatory code. - **Config:** Centralize in `config.lua`. Use deep merge for user overrides. - **Types:** Use Lua annotations (`---@class`, `---@field`, etc.) for public APIs/config. + +## Dependency Topology Tool + +Use `scripts/dependency-topology/scan_topology.py` to inspect and track architectural layering. + +- Use `python3 scripts/dependency-topology/scan_topology.py scan` to inspect current-state vs target-policy gap +- Use `diff` to inspect change direction (improved/regressed/neutral) between snapshots +- Pass `--snapshot ` for historical snapshots +- Pass `--json` when feeding outputs into scripts or agents +- Keep architecture cleanup discussions anchored on scanner output instead of ad-hoc grep chains diff --git a/scripts/dependency-topology/AGENTS.md b/scripts/dependency-topology/AGENTS.md new file mode 100644 index 00000000..45ac2b22 --- /dev/null +++ b/scripts/dependency-topology/AGENTS.md @@ -0,0 +1,71 @@ +# Dependency Topology Scanner + +Static analysis tool for Lua codebase dependency architecture. + +## File Structure + +``` +scripts/dependency-topology/ +├── scan_topology.py # CLI entry: scan / diff subcommands +├── scan_analysis.py # Core analysis: groups, edge rules, payload builders +├── graph_utils.py # Pure graph algorithms (Tarjan SCC, back edges, degree) +├── html_renderer.py # Interactive dagre-d3 + d3v5 HTML visualization +└── topology.jsonc # Group definitions + review comments (strategy file) +``` + +## Quick Start + +```bash +# Scan current HEAD → generate interactive HTML +python3 scripts/dependency-topology/scan_topology.py scan + +# Output to specific path +python3 scripts/dependency-topology/scan_topology.py scan -o /tmp/deps.html + +# JSON output (for scripts/agents) +python3 scripts/dependency-topology/scan_topology.py scan --json + +# Diff — smart default: +# worktree has uncommitted Lua changes → HEAD vs worktree +# worktree is clean → HEAD~1 vs HEAD (last commit) +python3 scripts/dependency-topology/scan_topology.py diff + +# Compare specific refs (branch names, commit SHAs, remote refs) +python3 scripts/dependency-topology/scan_topology.py diff --from upstream/main --to clean-code-remove-core +python3 scripts/dependency-topology/scan_topology.py diff --from HEAD~5 --to HEAD +``` + +## Snapshot References + +- `worktree` — current working tree (uncommitted changes) +- `HEAD` — latest commit +- Any git ref — branch name (e.g. `upstream/main`), tag, short or full commit SHA +- Relative refs — `HEAD~1`, `HEAD^` + +**diff defaults (no args):** +- Worktree has uncommitted Lua changes → `HEAD` vs `worktree` +- Worktree is clean → `HEAD~1` vs `HEAD` + +Note: ambiguous short names (e.g. `upstream` when both a local branch and remote exist) +produce a git warning. Prefer fully-qualified refs: `upstream/main`, `refs/heads/mybranch`. + +## Output + +**scan:** One-line summary + HTML file path +``` +4 cycles, 20 violations, violations=20 → /path/to/dependency-graph.html +``` + +**diff:** Change direction summary +``` +HEAD → worktree: +2/-1 edges, improved=1, regressed=0 +``` + +## JSON Output Signals + +When using `--json`: + +- `health` — one-glance status for cycles / violations / ungrouped coverage +- `cycles` — SCC details with severity, members_by_layer, example_cycle, back_edges_in_scc +- `violations` — policy violations grouped by rule with full edge lists +- `group_coverage` — module counts per layer (including ungrouped) diff --git a/scripts/dependency-topology/graph_utils.py b/scripts/dependency-topology/graph_utils.py new file mode 100644 index 00000000..7133fd68 --- /dev/null +++ b/scripts/dependency-topology/graph_utils.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Repository-local static Lua dependency graph helpers. + +Mechanism only: +- Parse `require('opencode.*')` edges from `lua/opencode/**/*.lua` +- Build snapshot graph from worktree or git ref +- Provide SCC / back-edge utilities +""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +import re +import subprocess +from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple + + +REQUIRE_PATTERNS = [ + re.compile(r"require\s*\(\s*['\"](opencode(?:\.[^'\"]+)?)['\"]\s*\)"), + re.compile(r"require\s+['\"](opencode(?:\.[^'\"]+)?)['\"]"), +] + + +@dataclass +class SnapshotGraph: + snapshot: str + files: int + nodes: Dict[str, str] # module -> relative file path + edges: Set[Tuple[str, str]] + + +def module_from_relpath(relpath: str) -> Optional[str]: + if not relpath.startswith("lua/opencode/") or not relpath.endswith(".lua"): + return None + mod = relpath[len("lua/") : -len(".lua")] + if mod.endswith("/init"): + mod = mod[: -len("/init")] + return mod.replace("/", ".") + + +def _worktree_files(repo: Path) -> List[Tuple[str, str]]: + out: List[Tuple[str, str]] = [] + base = repo / "lua" / "opencode" + for fp in base.rglob("*.lua"): + rel = fp.relative_to(repo).as_posix() + text = fp.read_text(encoding="utf-8", errors="ignore") + out.append((rel, text)) + return out + + +def _git_files(repo: Path, ref: str) -> List[Tuple[str, str]]: + cmd = ["git", "ls-tree", "-r", "--name-only", ref, "lua/opencode"] + try: + ls = subprocess.check_output(cmd, cwd=repo, text=True, stderr=subprocess.PIPE) + except subprocess.CalledProcessError as e: + stderr = e.stderr.strip() if e.stderr else "" + raise ValueError( + f"Invalid snapshot ref '{ref}'. Valid values: HEAD, worktree, branch name, commit SHA.\n" + f"git error: {stderr}" + ) from None + + out: List[Tuple[str, str]] = [] + for rel in ls.splitlines(): + if not rel.endswith(".lua"): + continue + show_cmd = ["git", "show", f"{ref}:{rel}"] + try: + text = subprocess.check_output(show_cmd, cwd=repo, text=True, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + continue + out.append((rel, text)) + return out + + +def load_snapshot_graph(repo: Path, snapshot: str) -> SnapshotGraph: + files = _worktree_files(repo) if snapshot == "worktree" else _git_files(repo, snapshot) + + nodes: Dict[str, str] = {} + for rel, _ in files: + module = module_from_relpath(rel) + if module: + nodes[module] = rel + + edges: Set[Tuple[str, str]] = set() + for rel, content in files: + src = module_from_relpath(rel) + if not src: + continue + + deps: Set[str] = set() + for pat in REQUIRE_PATTERNS: + deps.update(m.group(1) for m in pat.finditer(content)) + + for dep in deps: + if dep in nodes: + edges.add((src, dep)) + + return SnapshotGraph(snapshot=snapshot, files=len(files), nodes=nodes, edges=edges) + + +def tarjan_scc(nodes: Iterable[str], edges: Iterable[Tuple[str, str]]) -> List[List[str]]: + graph: Dict[str, List[str]] = defaultdict(list) + for a, b in edges: + graph[a].append(b) + + index = 0 + stack: List[str] = [] + on_stack: Set[str] = set() + indices: Dict[str, int] = {} + lowlink: Dict[str, int] = {} + result: List[List[str]] = [] + + def strongconnect(v: str) -> None: + nonlocal index + indices[v] = index + lowlink[v] = index + index += 1 + stack.append(v) + on_stack.add(v) + + for w in graph[v]: + if w not in indices: + strongconnect(w) + lowlink[v] = min(lowlink[v], lowlink[w]) + elif w in on_stack: + lowlink[v] = min(lowlink[v], indices[w]) + + if lowlink[v] == indices[v]: + comp: List[str] = [] + while True: + w = stack.pop() + on_stack.remove(w) + comp.append(w) + if w == v: + break + result.append(comp) + + for n in sorted(set(nodes)): + if n not in indices: + strongconnect(n) + + return result + + +def back_edges(nodes: Iterable[str], edges: Iterable[Tuple[str, str]]) -> Set[Tuple[str, str]]: + graph: Dict[str, List[str]] = defaultdict(list) + for a, b in edges: + graph[a].append(b) + for n in graph: + graph[n] = sorted(set(graph[n])) + + white, gray, black = 0, 1, 2 + color: Dict[str, int] = {n: white for n in set(nodes)} + backs: Set[Tuple[str, str]] = set() + + def dfs(v: str) -> None: + color[v] = gray + for w in graph[v]: + c = color.get(w, white) + if c == white: + dfs(w) + elif c == gray: + backs.add((v, w)) + color[v] = black + + for n in sorted(color.keys()): + if color[n] == white: + dfs(n) + + return backs + + +def degree(edges: Iterable[Tuple[str, str]]) -> Tuple[Counter, Counter]: + indeg: Counter = Counter() + outdeg: Counter = Counter() + for src, dst in edges: + outdeg[src] += 1 + indeg[dst] += 1 + return indeg, outdeg + + +def find_cycle_in_scc(members: List[str], edges: Iterable[Tuple[str, str]]) -> List[str]: + """Return one concrete cycle path within an SCC, e.g. [a, b, c, a]. + + Uses DFS from the first member; backtracks until a back-edge is found. + Returns [] if no cycle is found (shouldn't happen for a real SCC > 1). + """ + member_set = set(members) + graph: Dict[str, List[str]] = defaultdict(list) + for a, b in edges: + if a in member_set and b in member_set: + graph[a].append(b) + for n in graph: + graph[n] = sorted(set(graph[n])) + + path: List[str] = [] + on_path: Dict[str, int] = {} # node -> index in path + visited: Set[str] = set() + + def dfs(v: str) -> List[str]: + path.append(v) + on_path[v] = len(path) - 1 + for w in graph[v]: + if w in on_path: + # Found cycle: extract from w's position to end, close it + return path[on_path[w]:] + [w] + if w not in visited: + visited.add(w) + result = dfs(w) + if result: + return result + path.pop() + del on_path[v] + return [] + + start = sorted(members)[0] + visited.add(start) + return dfs(start) + + +def largest_scc_size(comps: Sequence[Sequence[str]]) -> int: + nontrivial = [c for c in comps if len(c) > 1] + return max((len(c) for c in nontrivial), default=0) diff --git a/scripts/dependency-topology/html_renderer.py b/scripts/dependency-topology/html_renderer.py new file mode 100644 index 00000000..257b9a5d --- /dev/null +++ b/scripts/dependency-topology/html_renderer.py @@ -0,0 +1,673 @@ +"""HTML visualization renderer v2 for dependency topology. + +Features: +- Full node names (no truncation) +- Compact TB layout +- Cluster collapse/expand on click +- Violation coloring (red edges for policy violations) +""" + +from dataclasses import dataclass, field +from typing import List, Dict, Set, Tuple, Any +import json +import fnmatch + +from scan_analysis import edge_rule as _edge_rule_impl + + +@dataclass +class ClusterNode: + id: str + is_cluster: bool + children: List[str] = field(default_factory=list) + in_degree: int = 0 + out_degree: int = 0 + in_scc: bool = False + + +@dataclass +class ClusterEdge: + src: str + dst: str + weight: int = 1 + is_violation: bool = False + rule: str = "" + + +def match_group(module: str, groups: Dict[str, Any]) -> str: + """Match module to group using fnmatch patterns.""" + for group_name, group_data in groups.items(): + if not isinstance(group_data, dict): + continue + patterns = group_data.get("modules", []) + if not isinstance(patterns, list): + continue + for pattern in patterns: + if isinstance(pattern, str) and fnmatch.fnmatch(module, pattern): + return group_name + return "ungrouped" + + +def _edge_rule(src_group: str, dst_group: str) -> str: + """Thin wrapper: normalise scan_analysis.edge_rule None -> empty string.""" + return _edge_rule_impl(src_group, dst_group) or "" + + +def auto_cluster_graph( + nodes: List[str], + edges: List[Tuple[str, str]], + scc_nodes: Set[str], + groups: Dict[str, Any], + depth: int = 2 +) -> Tuple[List[ClusterNode], List[ClusterEdge]]: + """Cluster nodes by namespace prefix at given depth.""" + # Build prefix → children mapping + prefix_children: Dict[str, List[str]] = {} + node_to_cluster: Dict[str, str] = {} + + for node in nodes: + parts = node.split('.') + if len(parts) > depth: + prefix = '.'.join(parts[:depth]) + '.*' + else: + prefix = node + + if prefix not in prefix_children: + prefix_children[prefix] = [] + prefix_children[prefix].append(node) + node_to_cluster[node] = prefix + + # Build cluster nodes + cluster_nodes: Dict[str, ClusterNode] = {} + for prefix, children in prefix_children.items(): + is_cluster = prefix.endswith('.*') + in_scc = any(c in scc_nodes for c in children) + cluster_nodes[prefix] = ClusterNode( + id=prefix, + is_cluster=is_cluster, + children=sorted(children) if is_cluster else [], + in_scc=in_scc + ) + + # Build cluster edges with violation detection + edge_counts: Dict[Tuple[str, str], Tuple[int, bool, str]] = {} + for src, dst in edges: + csrc = node_to_cluster.get(src, src) + cdst = node_to_cluster.get(dst, dst) + if csrc != cdst: + key = (csrc, cdst) + # Check violation on original edge + src_grp = match_group(src, groups) + dst_grp = match_group(dst, groups) + rule = _edge_rule(src_grp, dst_grp) + + if key not in edge_counts: + edge_counts[key] = (0, False, "") + cnt, is_vio, existing_rule = edge_counts[key] + edge_counts[key] = (cnt + 1, is_vio or bool(rule), existing_rule or rule) + + cluster_edges = [ + ClusterEdge(k[0], k[1], v[0], v[1], v[2]) + for k, v in edge_counts.items() + ] + + # Compute degrees + for e in cluster_edges: + if e.src in cluster_nodes: + cluster_nodes[e.src].out_degree += 1 + if e.dst in cluster_nodes: + cluster_nodes[e.dst].in_degree += 1 + + return list(cluster_nodes.values()), cluster_edges + + +def render_html(payload: dict, groups: Dict[str, Any], cluster_depth: int = 2) -> str: + """Render interactive HTML visualization.""" + node_list = payload.get('node_list', []) + edge_list = payload.get('edge_list', []) + sccs = payload.get('sccs', []) + + # Flatten SCC nodes + scc_nodes: Set[str] = set() + for scc in sccs: + if len(scc) > 1: + scc_nodes.update(scc) + + # Auto-cluster with violation detection + cluster_nodes, cluster_edges = auto_cluster_graph( + node_list, edge_list, scc_nodes, groups, depth=cluster_depth + ) + + # Build GRAPH data — cluster edges for default view, rawEdges for expand + graph_data = { + 'nodes': [ + { + 'id': n.id, + 'isCluster': n.is_cluster, + 'children': n.children, + 'inDegree': n.in_degree, + 'outDegree': n.out_degree, + 'inScc': n.in_scc + } + for n in cluster_nodes + ], + 'edges': [ + { + 'src': e.src, + 'dst': e.dst, + 'weight': e.weight, + 'isViolation': e.is_violation, + 'rule': e.rule + } + for e in cluster_edges + ], + # Raw module-level edges — used when clusters are expanded so children + # can show their actual connections instead of appearing isolated. + 'rawEdges': [ + { + 'src': src, + 'dst': dst, + 'isViolation': bool(match_group(src, groups) and + _edge_rule(match_group(src, groups), match_group(dst, groups))), + 'rule': _edge_rule(match_group(src, groups), match_group(dst, groups)), + } + for src, dst in edge_list + ] + } + + # Build METRICS + violation_count = sum(1 for e in cluster_edges if e.is_violation) + + # Compute degree stats from original edges + in_deg: Dict[str, int] = {} + out_deg: Dict[str, int] = {} + for src, dst in edge_list: + out_deg[src] = out_deg.get(src, 0) + 1 + in_deg[dst] = in_deg.get(dst, 0) + 1 + + # Top hubs/spreaders + top_in = sorted([(k, v) for k, v in in_deg.items()], key=lambda x: -x[1])[:5] + top_out = sorted([(k, v) for k, v in out_deg.items()], key=lambda x: -x[1])[:5] + + avg_degree = len(edge_list) * 2 / len(node_list) if node_list else 0 + max_in = top_in[0][1] if top_in else 0 + max_out = top_out[0][1] if top_out else 0 + + metrics = { + 'total_modules': len(node_list), + 'total_edges': len(edge_list), + 'clusters': sum(1 for n in cluster_nodes if n.is_cluster), + 'leaves': sum(1 for n in cluster_nodes if not n.is_cluster), + 'scc_count': len([s for s in sccs if len(s) > 1]), + 'largest_scc': max((len(s) for s in sccs), default=0), + 'violations': violation_count, + 'avg_degree': round(avg_degree, 2), + 'max_in_degree': max_in, + 'max_out_degree': max_out, + 'top_in_degree': [{'id': k, 'degree': v} for k, v in top_in], + 'top_out_degree': [{'id': k, 'degree': v} for k, v in top_out] + } + + # Generate HTML + html = HTML_TEMPLATE.replace('__GRAPH_DATA__', json.dumps(graph_data, indent=2)) + html = html.replace('__METRICS_DATA__', json.dumps(metrics, indent=2)) + + return html + + +HTML_TEMPLATE = ''' + + + + Dependency Topology + + + + + + +
+ +
+ + + +
+
+
+ + + +''' diff --git a/scripts/dependency-topology/requirements.txt b/scripts/dependency-topology/requirements.txt new file mode 100644 index 00000000..1c6bd5a6 --- /dev/null +++ b/scripts/dependency-topology/requirements.txt @@ -0,0 +1,4 @@ +# Dependencies for the dependency topology scanner. +# Install with: pip install -r scripts/dependency-topology/requirements.txt + +json5>=0.9.0 # JSONC parser — required for topology.jsonc (supports // and /* */ comments) diff --git a/scripts/dependency-topology/scan_analysis.py b/scripts/dependency-topology/scan_analysis.py new file mode 100644 index 00000000..4aeeacf8 --- /dev/null +++ b/scripts/dependency-topology/scan_analysis.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Core analysis logic for scan and diff commands.""" + +from __future__ import annotations + +import fnmatch +import json5 +from collections import Counter +from pathlib import Path +from typing import Any, Dict, List, Set, Tuple + +from graph_utils import back_edges, load_snapshot_graph, tarjan_scc, find_cycle_in_scc + + +_POLICY_RULES: List[Dict[str, Any]] = [] + + +def init_policy(rules: List[Dict[str, Any]]) -> None: + """Initialize policy rules from topology.jsonc policy.rules list.""" + global _POLICY_RULES + _POLICY_RULES = rules or [] + + +def edge_rule(src_group: str, dst_group: str) -> str | None: + for r in _POLICY_RULES: + if r.get("from") == src_group and dst_group in r.get("to", []): + return r["name"] + return None + + +def load_strategy(path: str | None, default_path: Path) -> Dict[str, Any]: + strategy_path = Path(path) if path else default_path + if not strategy_path.exists(): + return {} + data = json5.loads(strategy_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("strategy file must be an object") + groups = data.get("groups", {}) + if groups is not None and not isinstance(groups, dict): + raise ValueError("strategy.groups must be an object") + return data + + +def build_group_rules(groups: Dict[str, Any] | None) -> List[Tuple[str, List[str]]]: + rules: List[Tuple[str, List[str]]] = [] + for group_name, value in (groups or {}).items(): + if not isinstance(value, dict): + continue + raw_modules = value.get("modules", []) + if not isinstance(raw_modules, list): + continue + patterns = [m for m in raw_modules if isinstance(m, str) and m.strip()] + rules.append((group_name, patterns)) + return rules + + +def group_of(module: str, rules: List[Tuple[str, List[str]]], cache: Dict[str, str]) -> str: + cached = cache.get(module) + if cached: + return cached + + for group_name, patterns in rules: + for pattern in patterns: + if fnmatch.fnmatch(module, pattern): + cache[module] = group_name + return group_name + + cache[module] = "ungrouped" + return "ungrouped" + + +def classify_policy_violations(edge_rows: List[Dict[str, str]]) -> Tuple[Dict[str, int], List[Dict[str, str]]]: + violations: List[Dict[str, str]] = [] + summary: Dict[str, int] = {"total_violations": 0} + + for row in edge_rows: + rule = edge_rule(row["src_group"], row["dst_group"]) + if not rule: + continue + v = dict(row) + v["rule"] = rule + violations.append(v) + summary[rule] = summary.get(rule, 0) + 1 + summary["total_violations"] += 1 + + return summary, violations + + +def short_module_name(module: str) -> str: + return module.split(".")[-1] if module else module + + +def build_scc_condensation_view(nodes: List[str], edges: Set[Tuple[str, str]]) -> Tuple[List[Dict[str, Any]], Set[Tuple[int, int]]]: + comps = tarjan_scc(nodes, edges) + comp_sorted = sorted(comps, key=lambda c: (-len(c), sorted(c)[0] if c else "")) + + comp_index: Dict[str, int] = {} + comp_rows: List[Dict[str, Any]] = [] + for idx, comp in enumerate(comp_sorted): + members = sorted(comp) + for m in members: + comp_index[m] = idx + label = f"C{idx}" + title = members[0] if members else label + if len(members) > 1: + title = f"{title} +{len(members)-1}" + comp_rows.append( + { + "id": idx, + "label": label, + "size": len(members), + "title": title, + "members": members, + } + ) + + condensed_edges: Set[Tuple[int, int]] = set() + for a, b in edges: + ia = comp_index.get(a) + ib = comp_index.get(b) + if ia is None or ib is None or ia == ib: + continue + condensed_edges.add((ia, ib)) + + return comp_rows, condensed_edges + + +def build_reality_collapsed_view( + components: List[Dict[str, Any]], + edges: List[Dict[str, int]], +) -> Dict[str, Any]: + major_ids = [c["id"] for c in components if c["size"] > 1] + singleton_ids = [c["id"] for c in components if c["size"] == 1] + + singleton_bucket = -1 if singleton_ids else None + + def bucket_of(comp_id: int) -> int: + if comp_id in major_ids: + return comp_id + return singleton_bucket if singleton_bucket is not None else comp_id + + edge_counter: Counter[Tuple[int, int]] = Counter() + for e in edges: + a = bucket_of(e["src"]) + b = bucket_of(e["dst"]) + if a is None or b is None: + continue + edge_counter[(a, b)] += 1 + + comp_by_id = {c["id"]: c for c in components} + + nodes: Dict[int, str] = {} + for c in components: + if c["id"] in major_ids: + head = c["members"][0] if c.get("members") else c["title"] + nodes[c["id"]] = f"{c['label']}:{short_module_name(head)}({c['size']})" + if singleton_bucket is not None: + nodes[singleton_bucket] = f"S*({len(singleton_ids)})" + + flows = [ + { + "src": k[0], + "dst": k[1], + "count": v, + "src_label": nodes.get(k[0], str(k[0])), + "dst_label": nodes.get(k[1], str(k[1])), + } + for k, v in edge_counter.items() + ] + flows.sort(key=lambda x: (-x["count"], x["src_label"], x["dst_label"])) + + return { + "nodes": [{"id": k, "label": v} for k, v in sorted(nodes.items(), key=lambda x: x[1])], + "flows": flows, + "major_components": [c for c in components if c["id"] in major_ids], + "major_component_legend": [ + { + "id": c["id"], + "label": c["label"], + "size": c["size"], + "head": c["members"][0] if c.get("members") else c["title"], + } + for c in components + if c["id"] in major_ids + ], + "singleton_count": len(singleton_ids), + "singleton_bucket_id": singleton_bucket, + "singleton_sample_heads": [ + comp_by_id[i]["members"][0] if comp_by_id[i].get("members") else comp_by_id[i]["title"] + for i in singleton_ids[:10] + ], + } + + +def build_scan_payload(repo: Path, snapshot: str, strategy: Dict[str, Any], top_n: int) -> Dict[str, Any]: + graph = load_snapshot_graph(repo, snapshot) + comps = tarjan_scc(graph.nodes.keys(), graph.edges) + cycles = sorted([sorted(c) for c in comps if len(c) > 1], key=lambda c: (-len(c), c[0])) + + group_rules = build_group_rules(strategy.get("groups", {})) + group_cache: Dict[str, str] = {} + grouped_counts: Dict[str, int] = {} + + for m in graph.nodes.keys(): + g = group_of(m, group_rules, group_cache) + grouped_counts[g] = grouped_counts.get(g, 0) + 1 + + edge_rows = [ + { + "src": a, + "dst": b, + "src_group": group_of(a, group_rules, group_cache), + "dst_group": group_of(b, group_rules, group_cache), + } + for a, b in sorted(graph.edges) + ] + policy_summary, policy_violations = classify_policy_violations(edge_rows) + + # ── Cycles: business-logic view ────────────────────────────────── + def scc_severity(size: int) -> str: + if size >= 10: + return "critical" + if size >= 3: + return "warning" + return "minor" + + cycle_entries = [] + for members in cycles: + member_set = set(members) + # Back-edges that are internal to this SCC + internal_backs = [ + {"src": a, "dst": b} + for a, b in back_edges(members, {(a, b) for a, b in graph.edges if a in member_set and b in member_set}) + ] + # Members grouped by layer + by_layer: Dict[str, List[str]] = {} + for m in members: + layer = group_of(m, group_rules, group_cache) + by_layer.setdefault(layer, []).append(m) + + cycle_entries.append({ + "size": len(members), + "severity": scc_severity(len(members)), + "members": members, + "members_by_layer": by_layer, + # One concrete cycle path so an agent can trace the actual loop + "example_cycle": find_cycle_in_scc(members, graph.edges), + # Back-edges within this SCC (the edges that close the loops) + "back_edges_in_scc": sorted(internal_backs, key=lambda e: (e["src"], e["dst"])), + }) + + # ── Policy violations: grouped by rule ─────────────────────────── + violations_by_rule: Dict[str, List[Dict[str, str]]] = {} + for v in policy_violations: + rule = v["rule"] + violations_by_rule.setdefault(rule, []) + violations_by_rule[rule].append({"src": v["src"], "dst": v["dst"]}) + + violation_groups = [ + { + "rule": rule, + "count": len(edges), + "edges": sorted(edges, key=lambda e: (e["src"], e["dst"])), + } + for rule, edges in sorted(violations_by_rule.items()) + ] + + # ── Health summary ──────────────────────────────────────────────── + total_violations = policy_summary.get("total_violations", 0) + ungrouped = grouped_counts.get("ungrouped", 0) + cycle_verdict = "critical" if cycles and len(cycles[0]) >= 10 else "warning" if cycles else "ok" + violation_verdict = "critical" if total_violations >= 20 else "warning" if total_violations > 0 else "ok" + + return { + "snapshot": snapshot, + + # One-glance health — agent should start here + "health": { + "cycles": { + "count": len(cycles), + "largest": len(cycles[0]) if cycles else 0, + "verdict": cycle_verdict, + # Cycles are always bad — they prevent clean layering and make + # incremental builds, testing, and refactoring harder. + }, + "violations": { + "count": total_violations, + "verdict": violation_verdict, + # Violations mean the layer rules in topology.jsonc are broken. + # They indicate real architectural debt, not just style issues. + }, + "ungrouped": { + "count": ungrouped, + # If > 0, some modules are not covered by topology.jsonc — + # their dependencies are invisible to policy checking. + }, + }, + + # Full cycle details — the most actionable architectural problem + "cycles": cycle_entries, + + # Policy violations grouped by rule + "violations": violation_groups, + + # Layer coverage — confirms all modules are classified + "group_coverage": grouped_counts, + + # Internal: raw graph for HTML rendering (not included in --json output) + "_graph": graph, + "_sccs": comps, + } + + +def build_diff_payload(repo: Path, from_snapshot: str, to_snapshot: str, strategy: Dict[str, Any]) -> Dict[str, Any]: + # Run full scan on both snapshots + from_scan = build_scan_payload(repo, from_snapshot, strategy, top_n=0) + to_scan = build_scan_payload(repo, to_snapshot, strategy, top_n=0) + + # ── Health delta ────────────────────────────────────────────────── + fh = from_scan["health"] + th = to_scan["health"] + + health_comparison = { + "cycles": { + "count": {"from": fh["cycles"]["count"], "to": th["cycles"]["count"], + "delta": th["cycles"]["count"] - fh["cycles"]["count"]}, + "largest": {"from": fh["cycles"]["largest"], "to": th["cycles"]["largest"], + "delta": th["cycles"]["largest"] - fh["cycles"]["largest"]}, + }, + "violations": { + "from": fh["violations"]["count"], "to": th["violations"]["count"], + "delta": th["violations"]["count"] - fh["violations"]["count"], + }, + "ungrouped": { + "from": fh["ungrouped"]["count"], "to": th["ungrouped"]["count"], + "delta": th["ungrouped"]["count"] - fh["ungrouped"]["count"], + }, + } + + # ── Violation diff (edge-level) ─────────────────────────────────── + from_v_edges = {(e["src"], e["dst"]): rg["rule"] for rg in from_scan["violations"] for e in rg["edges"]} + to_v_edges = {(e["src"], e["dst"]): rg["rule"] for rg in to_scan["violations"] for e in rg["edges"]} + + fixed = [{"rule": r, "src": s, "dst": d} for (s, d), r in sorted(from_v_edges.items()) if (s, d) not in to_v_edges] + new = [{"rule": r, "src": s, "dst": d} for (s, d), r in sorted(to_v_edges.items()) if (s, d) not in from_v_edges] + + # ── SCC diff ────────────────────────────────────────────────────── + from_scc_sets = [frozenset(c["members"]) for c in from_scan["cycles"]] + to_scc_sets = [frozenset(c["members"]) for c in to_scan["cycles"]] + + # Match SCCs by overlap (largest intersection) + scc_changes = [] + matched_to = set() + for f_scc in from_scc_sets: + best_match = None + best_overlap = 0 + for i, t_scc in enumerate(to_scc_sets): + if i in matched_to: + continue + overlap = len(f_scc & t_scc) + if overlap > best_overlap: + best_overlap = overlap + best_match = i + if best_match is not None and best_overlap > 0: + matched_to.add(best_match) + t_scc = to_scc_sets[best_match] + gained = sorted(t_scc - f_scc) + lost = sorted(f_scc - t_scc) + if gained or lost: + scc_changes.append({ + "from_size": len(f_scc), + "to_size": len(t_scc), + "delta": len(t_scc) - len(f_scc), + "gained_members": gained, + "lost_members": lost, + }) + else: + scc_changes.append({ + "from_size": len(f_scc), + "to_size": 0, + "delta": -len(f_scc), + "resolved": sorted(f_scc), + }) + + for i, t_scc in enumerate(to_scc_sets): + if i not in matched_to: + scc_changes.append({ + "from_size": 0, + "to_size": len(t_scc), + "delta": len(t_scc), + "new_cycle": sorted(t_scc), + }) + + # ── Edge-level changes ──────────────────────────────────────────── + from_graph = load_snapshot_graph(repo, from_snapshot) + to_graph = load_snapshot_graph(repo, to_snapshot) + added_edges = sorted(set(to_graph.edges) - set(from_graph.edges)) + removed_edges = sorted(set(from_graph.edges) - set(to_graph.edges)) + + return { + "from_snapshot": from_snapshot, + "to_snapshot": to_snapshot, + + "health_comparison": health_comparison, + + "violations_fixed": fixed, + "violations_new": new, + + "scc_changes": scc_changes, + + "edge_changes": { + "added": len(added_edges), + "removed": len(removed_edges), + "net": len(added_edges) - len(removed_edges), + }, + } diff --git a/scripts/dependency-topology/scan_topology.py b/scripts/dependency-topology/scan_topology.py new file mode 100644 index 00000000..13a92ccc --- /dev/null +++ b/scripts/dependency-topology/scan_topology.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Dependency topology scanner for Lua codebases. + +Detects policy violations (e.g. entry layer depending on infra) and +generates interactive HTML visualizations with SCC highlighting. + +Usage: + python scan_topology.py scan # Generate HTML graph, auto-open on macOS + python scan_topology.py diff # Compare HEAD vs uncommitted changes + python scan_topology.py --help # This message + +Policy: + Modules are grouped into layers (defined in topology.jsonc): + - entry_layer: plugin entry, api, keymap, handler shells, picker-type UIs + - dispatch_layer: command registry, execute gate, parse, slash, complete + - capabilities_layer: CLI mirrors, Nvim-native, UI rendering pipeline + - cli_infrastructure_layer: api_client, server_job, event_manager, opencode_server + + Policy rules forbid certain cross-layer dependencies (see topology.jsonc for the full + 7-rule matrix). Violations appear as red edges in the HTML graph. +""" + +from __future__ import annotations + +import argparse +import json +import platform +import subprocess +import sys +import textwrap +from pathlib import Path + +from graph_utils import load_snapshot_graph +from html_renderer import render_html +from scan_analysis import ( + init_policy, + load_strategy, + build_scan_payload, + build_diff_payload, +) + + +DEFAULT_STRATEGY = Path(__file__).parent / "topology.jsonc" + + +def cmd_scan(args: argparse.Namespace) -> int: + repo = Path(args.repo).resolve() + snapshot = args.snapshot or "HEAD" + strategy = load_strategy(args.strategy, DEFAULT_STRATEGY) + init_policy(strategy.get("policy", {}).get("rules", [])) + + payload = build_scan_payload(repo, snapshot, strategy, top_n=8) + + # Pop internal fields — not part of public JSON output + graph = payload.pop("_graph") + sccs = payload.pop("_sccs") + + if args.json: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + + # Generate HTML visualization — reuse already-computed graph and sccs + html_payload = { + "node_list": sorted(graph.nodes.keys()), + "edge_list": list(graph.edges), + "sccs": sccs, + } + groups = strategy.get("groups", {}) + html_content = render_html(html_payload, groups) + output = Path(args.output) if args.output else repo / "dependency-graph.html" + output.write_text(html_content, encoding="utf-8") + + # Auto-open on macOS + if platform.system() == "Darwin": + subprocess.run(["open", str(output)], check=False) + + # Summary line + violations = payload["health"]["violations"]["count"] + status = f"violations={violations}" if violations else "clean" + print(f"{payload['health']['cycles']['count']} cycles, {violations} violations, {status} → {output}") + return 0 + +def _diff_default_snapshots(repo: Path) -> tuple[str, str]: + """Smart default for diff with no args: + - If worktree has uncommitted changes: HEAD vs worktree + - If worktree is clean: HEAD~1 vs HEAD (last commit) + """ + result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=repo, capture_output=True, text=True + ) + if result.stdout.strip(): + return "HEAD", "worktree" + return "HEAD~1", "HEAD" + + +def cmd_diff(args: argparse.Namespace) -> int: + repo = Path(args.repo).resolve() + if args.from_snapshot or args.to_snapshot: + from_snap = args.from_snapshot or "HEAD" + to_snap = args.to_snapshot or "worktree" + else: + from_snap, to_snap = _diff_default_snapshots(repo) + strategy = load_strategy(args.strategy, DEFAULT_STRATEGY) + init_policy(strategy.get("policy", {}).get("rules", [])) + + payload = build_diff_payload(repo, from_snap, to_snap, strategy) + + if args.json: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + + # Human-readable — show meaningful detail, not just counts + hc = payload["health_comparison"] + cyc = hc["cycles"] + vio = hc["violations"] + + print(f"{from_snap} → {to_snap}") + print() + + # Health overview + cyc_delta = cyc["largest"]["delta"] + vio_delta = vio["delta"] + print(f" cycles: {cyc['count']['from']}→{cyc['count']['to']} " + f"largest {cyc['largest']['from']}→{cyc['largest']['to']} ({cyc_delta:+d})") + print(f" violations: {vio['from']}→{vio['to']} ({vio_delta:+d})") + ec = payload["edge_changes"] + print(f" edges: +{ec['added']}/-{ec['removed']} (net {ec['net']:+d})") + print() + + # SCC changes + scc_changes = payload.get("scc_changes", []) + if scc_changes: + print(" SCC changes:") + for ch in scc_changes: + if "resolved" in ch: + print(f" ✓ resolved size={ch['from_size']} → gone") + elif "new_cycle" in ch: + members = ", ".join(ch["new_cycle"][:4]) + ellipsis = f" +{len(ch['new_cycle'])-4} more" if len(ch["new_cycle"]) > 4 else "" + print(f" ✗ new cycle size={ch['to_size']}: {members}{ellipsis}") + else: + delta = ch["delta"] + if ch["gained_members"]: + print(f" ~ grew {ch['from_size']}→{ch['to_size']} ({delta:+d})") + for m in ch["gained_members"]: + print(f" + {m}") + if ch["lost_members"]: + print(f" ~ shrank {ch['from_size']}→{ch['to_size']} ({delta:+d})") + for m in ch["lost_members"]: + print(f" - {m}") + print() + + # Violations fixed / introduced + fixed = payload["violations_fixed"] + new_v = payload["violations_new"] + if fixed: + print(f" Fixed violations ({len(fixed)}):") + for v in fixed: + print(f" ✓ [{v['rule']}] {v['src']} → {v['dst']}") + print() + if new_v: + print(f" New violations ({len(new_v)}):") + for v in new_v: + print(f" ✗ [{v['rule']}] {v['src']} → {v['dst']}") + print() + if not fixed and not new_v: + print(" No violation changes.") + print() + + return 0 + +def main() -> int: + parser = argparse.ArgumentParser( + description="Dependency topology scanner — detect layering violations and visualize module dependencies.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + Examples: + %(prog)s scan Generate HTML graph (auto-opens on macOS) + %(prog)s scan -o /tmp/deps.html Output to specific path + %(prog)s scan --json Output analysis as JSON (no HTML) + %(prog)s diff Compare HEAD vs uncommitted changes + %(prog)s diff --from HEAD~5 Compare 5 commits ago vs uncommitted + %(prog)s diff --from v1.0 --to HEAD Compare two commits + + Policy rules are defined in topology.jsonc. Edit that file to customize + layer definitions and forbidden dependency directions. + """), + ) + parser.add_argument("--repo", default=".", help="Repository path (default: current directory)") + subparsers = parser.add_subparsers(dest="command") + + # scan + p_scan = subparsers.add_parser( + "scan", + help="Scan topology and generate interactive HTML visualization", + description="Analyze module dependencies, detect SCC cycles and policy violations, generate HTML.", + ) + p_scan.add_argument("--snapshot", help="Git ref to analyze (default: HEAD)") + p_scan.add_argument("--output", "-o", help="HTML output path (default: /dependency-graph.html)") + p_scan.add_argument("--json", action="store_true", help="Output JSON analysis instead of HTML") + p_scan.add_argument("--strategy", help="Path to strategy JSONC (default: topology.jsonc)") + + # diff + p_diff = subparsers.add_parser( + "diff", + help="Compare two snapshots and report edge changes", + description="Show added/removed edges and whether changes improved or regressed policy compliance.", + ) + p_diff.add_argument("--from", dest="from_snapshot", help="Base ref (default: HEAD)") + p_diff.add_argument("--to", dest="to_snapshot", help="Target ref (default: worktree = uncommitted changes)") + p_diff.add_argument("--json", action="store_true", help="Output JSON instead of summary") + p_diff.add_argument("--strategy", help="Path to strategy JSONC (default: topology.jsonc)") + + args = parser.parse_args() + + if args.command is None: + parser.print_help() + return 0 + + if args.command == "scan": + return cmd_scan(args) + elif args.command == "diff": + return cmd_diff(args) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/dependency-topology/topology.jsonc b/scripts/dependency-topology/topology.jsonc new file mode 100644 index 00000000..5819f33c --- /dev/null +++ b/scripts/dependency-topology/topology.jsonc @@ -0,0 +1,238 @@ +// Dependency topology strategy for opencode.nvim +// +// Target architecture (strict unidirectional): +// +// Layer 0 Entry parse / bind / handler shells / keymap +// → allowed: Dispatch, Capabilities +// × forbidden: Infrastructure +// +// Layer 1 Dispatch command registry / execute gate / hooks +// → allowed: Capabilities, Infrastructure +// × forbidden: Entry +// +// Layer 2 Capabilities CLI mirrors + Nvim-native + UI rendering +// → allowed: Infrastructure, same-layer +// × forbidden: Entry, Dispatch +// +// Layer 3 Infrastructure api_client / server_job / event_manager +// → allowed: same-layer, external +// × forbidden: Entry, Dispatch, Capabilities +// +// Foundation (layer-exempt) config / state / util / promise / log / types +// No rules — any layer may depend on these. +// +// NOTE: Module matching uses Python fnmatch — "opencode.ui.*" matches +// ALL depths (e.g. opencode.ui.renderer.flush). First match wins +// across groups, so put specific patterns before wildcards. +// +// STATUS: Draft. Needs owner review. Some placements are debatable — see +// inline "REVIEW:" comments. +{ + "groups": { + + // ── Layer 0: Entry ────────────────────────────────────────────── + // User-facing entry points. These modules receive user intent + // (keypress, command, picker selection) and forward it to Dispatch. + "entry_layer": { + "modules": [ + "opencode", // plugin setup entry + "opencode.api", // programmatic entry — wraps every action into dispatch_action() + "opencode.keymap", // keybinding → api calls + "opencode.health", // :checkhealth entry + "opencode.quick_chat", // standalone quick-chat entry + "opencode.quick_chat.*", + + // Handler shells — thin wrappers that define what each command does. + // They call into Capabilities / Dispatch, never Infrastructure directly. + "opencode.commands.handlers.*", + + // UI modules that act as entry points: after user interaction, + // they call api.lua (dispatch_action) to trigger commands. + "opencode.ui.session_picker", // user picks session → api.select_session + "opencode.ui.timeline_picker", // user picks timeline → api.select_timeline_entry + "opencode.ui.permission_window", // user grants/denies → api.permission_grant/deny + "opencode.ui.question_window", // user answers question → handler callback + "opencode.ui.contextual_actions", // user picks action → api.* + "opencode.ui.autocmds", // nvim events → api calls + "opencode.ui.debug_helper" // debug → session inspection + ] + }, + + // ── Layer 1: Dispatch ─────────────────────────────────────────── + // Command registry, parsing, and execution gate. + // Receives structured intent from Entry, routes to handler functions. + "dispatch_layer": { + "modules": [ + "opencode.commands", // command registry + execute_parsed_intent + "opencode.commands.dispatch", // dispatch.execute — the unified gate + "opencode.commands.parse", // argument parsing + "opencode.commands.slash", // slash command mapping table + "opencode.commands.complete" // command completion provider + ] + }, + + // ── Layer 2: Capabilities ─────────────────────────────────────── + // Business logic, data access, rendering. Three sub-categories: + // + // CLI mirrors — query/mutate CLI server state via api_client + // Nvim-native — editor-side capabilities (context, LSP, git) + // UI rendering — window management, rendering pipeline, display + // + "capabilities_layer": { + "modules": [ + // CLI mirrors + "opencode.session", // session data query (calls state.api_client) + "opencode.snapshot", // snapshot management + "opencode.config_file", // remote config fetch via api_client + // REVIEW: could be Foundation (passive data source, + // in-degree 14), but it calls state.api_client + // which is an active Infrastructure dependency. + + // Nvim-native capabilities + "opencode.context", // editor context collection + "opencode.context.*", + "opencode.history", // prompt history + "opencode.model_picker", // model selection UI + "opencode.variant_picker", // variant selection UI + "opencode.image_handler", // clipboard image handling + "opencode.git_review", // git diff review + "opencode.lsp.*", // LSP completion service + + // PR #360 services (split from core.lua) + "opencode.services.*", // REVIEW: only exists on clean-code-remove-core branch. + // Currently has 5 violations (services → UI entry), + // same deps core.lua had — now visible because grouped. + + "opencode.core", // REVIEW: god module (out-degree 21), being removed + // by PR #360. Violations: core→api, core→permission_window. + + // UI rendering & display + "opencode.ui.ui", // window container management + // REVIEW: currently violates no_capabilities_to_entry + // (→ api, autocmds, contextual_actions, session_picker). + // These deps should eventually be removed. + "opencode.ui.input_window", // input buffer management + "opencode.ui.output_window", // output buffer management + "opencode.ui.output", // output composition + "opencode.ui.renderer", // render pipeline + "opencode.ui.renderer.*", + "opencode.ui.render_state", // render state tracking + "opencode.ui.formatter", // message formatting + "opencode.ui.formatter.*", + "opencode.ui.footer", // status bar + "opencode.ui.topbar", // top bar + "opencode.ui.winbar", // window bar + "opencode.ui.navigation", // cursor navigation + "opencode.ui.diff_tab", // diff view + "opencode.ui.dialog", // dialog windows + "opencode.ui.completion", // completion engine + "opencode.ui.completion.*", + "opencode.ui.context_bar", // context display bar + "opencode.ui.reference_picker", // reference browser + "opencode.ui.mention", // @mention UI + "opencode.ui.file_picker", // file browser + "opencode.ui.picker", // generic picker + "opencode.ui.history_picker", // history browser + "opencode.ui.mcp_picker", // MCP tool browser + "opencode.ui.permission.permission" // permission display + ] + }, + + // ── Layer 3: CLI Infrastructure ───────────────────────────────── + // Communication with the opencode-cli process. + // These modules should have ZERO upward dependencies. + "cli_infrastructure_layer": { + "modules": [ + "opencode.api_client", // HTTP client for REST calls + "opencode.server_job", // server lifecycle + call_api/stream_api + "opencode.opencode_server", // process spawn/shutdown + "opencode.event_manager", // SSE event stream consumer + "opencode.port_mapping" // port registry + ] + }, + + // ── Foundation (no layer restrictions) ────────────────────────── + // Passive modules: widely required, but never call upward. + // Any layer may depend on these without triggering violations. + "foundation": { + "modules": [ + "opencode.config", // configuration container (in-degree 57) + "opencode.state", // reactive store (in-degree 52) + "opencode.state.*", // state sub-modules + "opencode.util", // utility functions (in-degree 30) + "opencode.promise", // async primitive (in-degree 28) + "opencode.log", // logging + "opencode.types", // type definitions (zero runtime code) + "opencode.id", // ID generation + "opencode.curl", // HTTP low-level wrapper + "opencode.throttling_emitter", // batching primitive + "opencode.model_state", // local model state file I/O + + // UI primitives — pure data or framework, no business logic + "opencode.ui.icons", // icon data map (in-degree 26) + "opencode.ui.highlight", // highlight group definitions + "opencode.ui.window_options", // window config constants + "opencode.ui.loading_animation", // animation frame data + "opencode.ui.timer", // timer wrapper + "opencode.ui.buf_fix_win", // buffer utility + "opencode.ui.prompt_guard_indicator", // display-only component + "opencode.ui.base_picker" // picker framework (no business logic) + ] + } + }, + + "policy": { + // Each rule forbids edges from one layer to another. + // Foundation is exempt — never appears as from/to in rules. + "rules": [ + { + "name": "no_entry_to_infra", + "from": "entry_layer", + "to": ["cli_infrastructure_layer"] + // Entry should go through Dispatch/Capabilities, not call infra directly. + // Current violations (3): opencode→event_manager, health→opencode_server, health→server_job + }, + { + "name": "no_dispatch_to_entry", + "from": "dispatch_layer", + "to": ["entry_layer"] + // Dispatch must not call back into Entry. Currently clean (0 violations). + }, + { + "name": "no_capabilities_to_entry", + "from": "capabilities_layer", + "to": ["entry_layer"] + // Capabilities must not drive Entry UI or call api.lua. + // Current violations (15) — the biggest category. Root causes: + // - renderer/formatter → permission_window/question_window (popup during render) + // - core/renderer/ui.ui → api (capability modules triggering commands) + // - ui.ui → autocmds/contextual_actions/session_picker (container knows entry modules) + }, + { + "name": "no_capabilities_to_dispatch", + "from": "capabilities_layer", + "to": ["dispatch_layer"] + // Current violations (2): completion.commands→commands.slash, input_window→commands.slash + // These need slash command list for completion — may need a data-only export. + }, + { + "name": "no_infra_to_entry", + "from": "cli_infrastructure_layer", + "to": ["entry_layer"] + // Currently clean (0 violations). + }, + { + "name": "no_infra_to_dispatch", + "from": "cli_infrastructure_layer", + "to": ["dispatch_layer"] + // Currently clean (0 violations). + }, + { + "name": "no_infra_to_capabilities", + "from": "cli_infrastructure_layer", + "to": ["capabilities_layer"] + // Currently clean (0 violations). + } + ] + } +}