| name | code-hotspot-analysis |
| description | Ground architecture/refactoring reviews in tool-generated evidence instead of code-reading alone โ static coupling graphs (package dependency, call graph, struct/interface size) plus temporal coupling (files that change together in git history, independent of imports) combined into a complexity ร churn hotspot score. The open-source technique behind CodeScene. Use before `architecture-review`/`find-refactor-candidates` to find WHERE to look; use those commands to analyze WHY once you're there. |
Code Hotspot & Coupling Analysis
CodeScene's core insight, stated plainly: complexity alone doesn't predict maintenance pain, and churn alone doesn't either โ but complexity ร churn does. A gnarly 2000-line file nobody has touched in two years is a museum piece, not a risk. A simple 50-line file that changes in every other commit is fine. A complex file that changes constantly is where incidents come from. CodeScene is a commercial product; this skill documents the same technique with open-source tooling, primarily for Go codebases (with pointers for other languages).
This is a prioritization tool, not a judgment tool. It tells you where to point architecture-review's SOLID/DDD/Clean-Architecture analysis first. Running principle-checklists across an entire codebase uniformly wastes review effort on files nobody is actually struggling with.
The Two Axes
1. Static structural coupling (what imports/references what, right now)
| Signal | Go tool | What it tells you |
|---|
| Package dependency graph | goda graph | Afferent coupling (who depends on this package โ a chokepoint if too high), dependency cycles, direction violations against your own layering |
| Call graph | go-callvis | Visualizes which functions call which, clustered by package; a huge .dot file size for one package's focused graph is itself a density signal, even unrendered |
| Struct/interface size (UML-ish) | goplantuml | .puml class-diagram text for a package โ the field/method list surfaces God Objects fast, even unrendered to an image |
| Complexity | gocyclo, gocognit | Cyclomatic and cognitive complexity per function โ the "complexity" half of the hotspot score. Prefer cognitive complexity when ranking review-difficulty: it penalizes nesting depth, cyclomatic only counts branches, so two functions with equal cyclomatic complexity can differ wildly in how hard they actually are to hold in your head |
| Structural pattern queries | ast-grep (sg) | Anything a graph tool won't catch directly: largest structs by field count, functions with the most parameters (primitive-obsession signal โ see type-driven-design skill), direct cross-package field access (pkg.Thing.Field = x from outside pkg โ an encapsulation violation graph tools don't flag, and the single highest-value query below) |
Run all of these against the whole repo or a suspect package; save dot/svg/.puml output somewhere durable if the finding is worth referencing later (this repo's convention: docs/architecture-audit-<date>.md plus any generated artifacts alongside it).
Working commands and gotchas actually hit running this stack (Go 1.25, this repo):
go install github.com/loov/goda@latest
goda graph "github.com/yourorg/yourrepo/..." > full-graph.dot
goda list "incoming(github.com/yourorg/yourrepo/..., github.com/yourorg/yourrepo/somepkg)"
goda list "reach(session/..., server/...)"
goda list "reach(config, session/...)"
go install github.com/ofabry/go-callvis@latest
GODEBUG=gotypesalias=1 go-callvis -algo=static -format=dot \
-focus=github.com/yourorg/yourrepo/somepkg \
-group=pkg,type -limit=github.com/yourorg/yourrepo -nostd .
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
go install github.com/uudashr/gocognit/cmd/gocognit@latest
gocyclo -top 60 -avg .
gocognit -top 40 .
go install github.com/jfeliu007/goplantuml/cmd/goplantuml@latest
goplantuml -recursive somepkg > somepkg.puml
sg run --pattern 'type $NAME struct { $$$FIELDS }' --lang go --json=compact .
sg run --pattern 'func $NAME($$$PARAMS) $$$RET { $$$ }' --lang go .
grep -rn "^func " --include="*.go" somepkg/*.go | grep -v _test.go | wc -l
grep -rn '\b\(instance\|inst\|sess\|session\)\.\w\+ = ' server/ daemon/ main.go \
--include="*.go" | grep -v _test.go
2. Temporal coupling (what changes together, in git history โ independent of imports)
This is the half most reviews skip, and it's the one that finds coupling static analysis structurally cannot see: two files in unrelated packages that always change together because a feature spans both, with no import relationship connecting them. That's a missing abstraction boundary hiding in plain sight.
Tool: code-maat (https://github.com/adamtornhill/code-maat) โ the actual open-source tool Adam Tornhill built before commercializing the same technique as CodeScene. It's a Clojure JAR that consumes git log output and computes coupling/hotspot/complexity-trend analyses directly.
Fallback (code-maat's Leiningen/Clojure setup can be finicky in a sandboxed environment): a short co-change script is a fine substitute for the core metric โ
git log --since="6 months ago" --name-only --pretty=format:'--%H--' \
| awk '/^--/{if(NR>1)print ""; next} NF{print}' \
(a Python script pairing up each commit's changed-file list with itertools.combinations and a Counter is the simplest correct implementation โ don't over-invest in a shell one-liner if the awk/sort pipeline gets unreadable)
Hotspot score: for each file, (commit count touching it in the window) ร (complexity proxy). Cyclomatic/cognitive complexity from gocyclo/gocognit is the real proxy if you have it; line count is an acceptable fallback. Rank descending โ the top of this list is where bugs and slow PRs concentrate, regardless of what the static dependency graph says.
Cross-check: a file that is both a top hotspot AND already has multiple ADRs/design docs written about it (grep docs/adr/, project_plans/*/requirements.md for the filename) is a strong signal of a genuinely contested, high-churn architectural area โ not a false positive.
Working script (code-maat fallback)
code-maat itself is frequently impractical to stand up in a sandboxed/CI environment โ its nixpkgs package can pull a 500+ MiB unrelated transitive closure (GTK/cairo/dbus, apparently from a shared build environment) with no cached jar, and it otherwise needs Leiningen/Clojure tooling that may not be installed. When that's the case, this self-contained script implements the same core algorithm code-maat's coupling analysis uses โ used successfully on this repo's own 1000-commit window:
"""Temporal coupling + hotspot analysis โ code-maat's coupling algorithm, self-contained.
Usage: python3 hotspot_analysis.py [--commits N] [--since "6 months ago"] [--max-files-per-commit 60]
Requires only stdlib + a git checkout; no network, no JVM.
"""
import argparse
import itertools
import subprocess
from collections import Counter, defaultdict
def get_commits(n=None, since=None):
"""Returns list of (commit_hash, [changed_files]) tuples, newest first."""
cmd = ["git", "log", "--name-only", "--pretty=format:--%H--"]
if n:
cmd.insert(2, f"-n{n}")
if since:
cmd.insert(2, f"--since={since}")
out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
commits, current_hash, current_files = [], None, []
for line in out.splitlines():
if line.startswith("--") and line.endswith("--") and len(line) == 44:
if current_hash:
commits.append((current_hash, current_files))
current_hash, current_files = line[2:-2], []
elif line.strip():
current_files.append(line.strip())
if current_hash:
commits.append((current_hash, current_files))
return commits
def author_of(commit_hash):
return subprocess.run(
["git", "log", "-1", "--pretty=format:%an", commit_hash],
capture_output=True, text=True, check=True,
).stdout.strip()
def compute_coupling(commits, max_files_per_commit=60, min_shared=3, exclude_authors=()):
"""Returns (revisions: Counter[file]->count, coupling: Counter[(fileA,fileB)]->shared_commits)."""
revisions, coupling = Counter(), Counter()
for commit_hash, files in commits:
if exclude_authors and author_of(commit_hash) in exclude_authors:
continue
for f in files:
revisions[f] += 1
if len(files) > max_files_per_commit:
continue
for a, b in itertools.combinations(sorted(set(files)), 2):
coupling[(a, b)] += 1
coupling = Counter({pair: n for pair, n in coupling.items() if n >= min_shared})
return revisions, coupling
def hotspot_score(revisions, complexity_by_file=None, line_counts=None):
"""complexity_by_file (e.g. from gocyclo/gocognit) is the real proxy if you have it;
line_counts is the fallback. Returns sorted [(file, score)] descending."""
proxy = complexity_by_file or line_counts or {}
scores = {f: revisions[f] * proxy.get(f, 0) for f in revisions if f in proxy}
return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--commits", type=int, default=1000)
p.add_argument("--since", default=None)
p.add_argument("--max-files-per-commit", type=int, default=60)
p.add_argument("--min-shared", type=int, default=3)
p.add_argument("--exclude-author", action="append", default=[],
help="e.g. --exclude-author 'github-actions[bot]' to drop CI automation noise")
args = p.parse_args()
commits = get_commits(n=args.commits, since=args.since)
revisions, coupling = compute_coupling(
commits, args.max_files_per_commit, args.min_shared, tuple(args.exclude_author)
)
print("# Top 20 files by revision count")
for f, n in revisions.most_common(20):
print(f"{n:5d} {f}")
print("\n# Top 20 co-change pairs (>= min-shared commits)")
for (a, b), n in coupling.most_common(20):
ratio = n / min(revisions[a], revisions[b])
print(f"{n:3d} ratio={ratio:.2f} {a} <-> {b}")
Notes from actually running this (this repo, 1000-commit window):
github-actions[bot] automation (--exclude-author 'github-actions[bot]') removed 551/1000 commits that were pure CI benchmark/demo regeneration noise โ always check git log --author on your top revision-count entries before trusting them; automated commits touching the same few files on every run will dominate raw counts without being an architectural signal.
- Generated code (ORM mutation files, protobuf bindings) should be excluded from the hotspot ranking (a 23K-line generated file will top any line-count-based score meaninglessly) but kept in the coupling data โ coupling between a generated file and its source (
.proto โ .pb.go) is a legitimate, if uninteresting, confirmation signal, and coupling that flows through a generated file to other hand-written files is informative.
line_counts is a real but weak complexity proxy โ prefer summing gocyclo/gocognit per-function output by file if that data is available from a parallel static-analysis pass (see axis 1 above); fall back to line count only when it isn't.
Workflow
- Run the static structural pass (goda/go-callvis/gocyclo/gocognit/goplantuml/ast-grep) โ cheap, deterministic, no time window to pick.
- Run the temporal pass (code-maat or the fallback script) over a deliberately bounded window (e.g. last 500-1000 commits or 6 months โ pick something that finishes in reasonable time; don't scan entire project history by default).
- Compute the hotspot score (axis 2's complexity ร churn) and the top co-change pairs (axis 2's pairwise coupling).
- Cross-reference axis 1's static coupling against axis 2's temporal coupling for the same files/packages โ agreement between them (a file that's both structurally central AND a temporal hotspot) is your highest-confidence target.
- Hand the ranked list to
architecture-review (--target=class:X/--target=package:Y, targeted not --depth=deep full-codebase) for the principle-level "why is this bad and how do we fix it" analysis. Don't run a full SOLID/DDD sweep uniformly across a whole codebase when you have a ranked list telling you where the actual pain is.
- If a fix is warranted, hand it to
find-refactor-candidates/code-refactoring โ informed by which axis flagged it (a static-coupling problem wants an interface/boundary extraction; a temporal-coupling problem across packages wants investigating whether a shared concept should be extracted into its own type/package).
When NOT to Use This
- A single file/PR review. This is a whole-codebase or whole-package prioritization technique. For "is this one file well-designed," go straight to
architecture-review --context=current.
- A young codebase with little git history. Temporal coupling needs enough commits to be statistically meaningful โ a few dozen commits won't produce a reliable signal. Static coupling (axis 1) still works fine on day one.
- As a scoring mechanism for people, not code.
find-refactor-candidates' existing "files with most contributors" metric edges toward this โ resist using churn/coupling data to evaluate who wrote what; it's a codebase signal, not a performance metric.
Anti-Patterns
- Treating the hotspot score as ground truth. It's a proxy that correlates with maintenance pain; a high score means "look here first," not "this is definitely broken." Always read the actual code before recommending a fix.
- Scanning entire project history by default. Pick a bounded, recent window โ ancient history dilutes the signal with code nobody touches anymore and makes the analysis slow for no benefit.
- Running every tool in the stack every time. Static coupling (goda/gocyclo/ast-grep) is cheap and always worth running; the temporal pass (code-maat/co-change script) is the expensive, judgment-call part โ reach for it when static coupling alone doesn't explain why an area feels painful to work in.
- Skipping the cross-reference step. A file flagged by only one axis is a weaker signal than one flagged by both โ don't treat every hit as equally urgent.
Related Skills
| Skill | When to apply |
|---|
architecture-best-practices | The principle framework (SOLID/DDD/Clean Architecture) this analysis prioritizes work for |
go-development | Idiomatic Go patterns to apply once a hotspot is identified |
code-ast-grep | Deeper sg pattern syntax for the structural-query half of axis 1 |
code-refactoring | Executing the fix once a target is chosen |
type-driven-design | If a hotspot's root cause is primitive obsession / missing invariant encoding, not just size |