Architectural Hotspots
Static dependency-graph analyzer that ranks files along five orthogonal dimensions instead of inventing a single composite score:
- Hubs — many other files depend on them (high fan-in).
- Tangles — they depend on many other files (high fan-out).
- God modules — large files concentrating too much responsibility.
- Churn — files most frequently touched by commits (git repos only).
- Cycles — strongly-connected components of size > 1 in the import graph.
Each dimension answers a different question; the human reader combines them. A file showing up in two sections is more interesting than one with a high score in any single dimension.
When to run this
Run it when the user is asking a strategic question about the codebase — "where should I start refactoring", "what's tightly coupled", "is this app architecturally sound", "which files keep biting us". Do not run it for narrow questions about a specific file or function.
Skip it on:
- Single-file edits or bugfix tasks.
- Codebases with fewer than ~30 source files — the analyzer needs scale to be useful, and on small repos the user can read the structure directly.
- Repos that are mostly generated code or templates.
How to run
The analyzer is a single Python 3 script with no third-party dependencies.
python plugins/architectural-analysis/skills/architectural-hotspots/scripts/find_hotspots.py <repo_path>
Useful flags:
--top N— rows per section (default 15).--god-loc N— LOC threshold for the god-modules section (default 400).--churn-months N— window for the git-churn section (default 12; 0 disables it). Widen on slow-moving repos, narrow on very active ones.--output report.md— write to file instead of stdout.--exclude GLOB(repeatable) — skip files matching a glob against the repo-relative path. Matches at any depth, so--exclude '*_tests.rs'and--exclude 'design/probes/**'both work. Reach for it when the user scopes the request ("everything except the probes") and when test files crowd the god-modules table.--explain— append a per-import trace: source file, raw import token, what it resolved to, and whether it counted as a dependency, a declaration, or was dropped. Run this whenever a number looks wrong. A surprising fan-in or a cycle you can't explain is nearly always a resolution artifact, and this flag is the only way to see it without reading the analyzer's source.--ext .EXT=LANG(repeatable) — force a file extension to be parsed as a specific language.LANGcan be any precise tag (python,js,go,rust,java,c,ruby,csharp,php,swift,scala,qml,nix) orgeneric. Use when a project uses unusual extensions (--ext .nut=genericfor Squirrel scripts).--sniff— parse files with unknown extensions if they look like text. Catches obscure languages at the cost of more false positives. Off by default; turn on for polyglot or unusual repos.
Two test scripts live next to the analyzer. Run both after any change to import patterns, resolution, or the graph build:
python3 plugins/architectural-analysis/skills/architectural-hotspots/scripts/test_resolver.py
python3 plugins/architectural-analysis/skills/architectural-hotspots/scripts/test_mutations.py
test_resolver.py builds synthetic Rust, Python and Go repos whose correct
graphs are known and asserts the analyzer agrees. Four resolver defects shipped
together precisely because the language with the most special-case machinery
had no fixture.
test_mutations.py disables each fix in turn and checks that exactly the
declared assertions go red. It exists because an assertion that has only ever
been green is not known to work — and because the failure that kept recurring
here was not a wrong answer but a check passing for the wrong reason: a
one-sided cycle assertion, a mutation whose filter matched nothing, and defects
every existing eval was structurally blind to. Comparing against an exact set
catches both an assertion gone inert and a fix that has grown an unintended
dependency.
The script enumerates files via git ls-files when the target is a git repo
(so .gitignore is honoured for free), and falls back to os.walk with a
hardcoded ignore list (node_modules, .venv, target, dist, …) otherwise.
Precise parsers (per-language regex, lower false-positive rate): Python, JS/TS, Vue, Svelte, Rust, Go, Java/Kotlin, C/C++, Ruby, C#, PHP, Swift, Scala, QML, Nix.
Generic fallback (best-effort regex on common import keywords —
import, require, use, include, from, open, with, mod,
extern crate): Dart, Elixir/Erlang, Haskell, OCaml, F#, Lua, R, Julia,
Nim, Zig, V, Crystal, Perl, shell, Clojure, Lisp, Scheme, Racket, Ada,
Pascal, D, Groovy, GDScript, Gleam, Solidity, Move, Pkl, Elm. Lossier than
the precise parsers — extra false positives possible — but means the
analyzer degrades gracefully on language families without dedicated
support. For everything else: see the fallback clause below.
Imports are resolved to internal repo files by:
- Path-alias expansion for TypeScript / JavaScript projects — the
analyzer reads
tsconfig.json/jsconfig.jsonand resolves aliases like@/lib/fooaccording to the project's declaredpaths. - Relative path resolution where the language supports it (
./,../, leading-dot Python imports; Rustcrate::/self::/super::virtual roots andmod foo;declarations — including#[path = "..."]— resolve by Rust's module-file conventions, never by name matching). - Basename match across the repo otherwise — the last segment of the import path is matched against file stems, with ties broken by directory proximity to the source file.
For files without a recognised extension the analyzer additionally tries:
- The first line as a shebang (
#!/usr/bin/env python3→ Python). - A Vim / Emacs modeline (
# vim: ft=lua). - With
--sniff, treat the file as generic if it looks like text.
External, third-party, and unresolved imports are dropped. The graph only reflects intra-repo coupling.
When the analyzer can't help
If the report says "Analyzed 0 source files" or shows a non-empty repo producing 0 internal import edges, the codebase is in a language the analyzer doesn't parse, or the language uses a coupling mechanism the analyzer can't see (component-instantiation in QML, runtime DI in some Java projects, codegen in others). Do not pretend the report is the answer in that case. Say so explicitly to the user, fall back to reading the codebase manually (or with grep/LSP), and offer to extend the analyzer if this language family will come up again. A blank report with no caveat is worse than no report at all — it implies the codebase is healthy when really the tool just didn't see it.
Sanity-check the ranking before you believe it
Total failure is easy to spot. Partial garbage is the common case and looks exactly like a real finding, so run these four checks on every report before you write a word of interpretation. Each one takes seconds and each has caught a shipped defect:
- Is the top tangle a file whose LOC is roughly its fan-out? A 22-line file with fan-out 22 is a list of declarations, not a coordination layer.
- Does a hub's fan-in survive
--explain? Spot-check the highest fan-in file. If its inbound edges are standard-library paths whose segments happen to match a directory name (std::os::unix::fsbinding to yourfs/), the ranking is measuring your directory names. - Is a reported cycle made of real value-level dependencies? Open both files and find the two imports. Type-only imports, containment declarations, and test modules that compile into their parent are not cycles.
- Are the god-module rows test files? The table marks them. Test files are
supposed to be big; re-run with
--excludeto rank production code.
If a check fails, say so to the user, re-run with --explain or --exclude,
and only then interpret. Presenting an artifact as a finding costs the user a
refactor; saying "the tool got this wrong" costs a sentence.
Reading the output
The report is markdown with seven sections in this order:
Hubs
Sorted by fan-in (descending). High fan-in is not automatically a problem — core libraries, type modules, and shared clients are supposed to be hubs.
A hub becomes a refactor candidate when at least one of the following is also true:
- Its name is generic / grab-bag (
utils.py,helpers.ts,common.go,misc.rs). That naming usually means the file accumulated unrelated responsibilities because nobody knew where else to put them. - It also appears in the god-modules table (large + many dependents = the blast radius of changes is huge).
- It also ranks high in the Churn table, or the user mentions it changes often, breaks often, or is constantly in merge conflicts — observable symptoms of an over-loaded hub.
If a hub is small, focused, and well-named (e.g. db/client.py at 80 LOC),
note it as healthy and move on.
Tangles
Sorted by fan-out (descending). High fan-out means the file reaches into many parts of the repo — a sign of either:
- Coordination / glue code doing too much orchestration (a god service, a single huge handler).
- Wrong layer — a low-level module reaching up into application code, or a view layer reaching directly into data layer internals instead of going through a seam.
A fan-out of 2–3 in a small file is usually fine. The interesting cases are files with fan-out in the double digits, especially when LOC is also high.
Declaration edges are excluded here. A Rust mod x; line says x lives inside
this file, which is containment, not dependency — counting it made every
mod.rs the top tangle. If a file's fan-out still looks implausible next to
its size, that is check 1 in the sanity list above.
God modules
Files whose LOC is at or above the threshold. Cross-reference these against the Hubs and Tangles tables: a file that is both god-sized and a hub is a top refactor priority because every change to it ripples widely.
The Test column marks files the analyzer believes are tests (by directory
name or filename convention). Test files are supposed to be large, so they
tell you nothing about production structure. When they crowd the table, re-run
with --exclude and rank the real code.
The table shows two size columns: LOC (non-blank lines, comments
included — the --god-loc threshold applies to this one) and Code
(line-leading comments stripped; the table is ranked by this one, so a
400-line file that is 60% comments no longer outranks a dense 400-line
file). The comment stripping is a crude line-prefix heuristic, not a
parser — treat both as rough size signals.
Churn
Commit-touch counts from git history over the configured window (default 12 months). Skipped with an explicit note when the target is not a git repo.
Churn is a multiplier, not a problem by itself — high churn on a small, well-factored file just means active development. The signal is the combination (this is the CodeScene-style hotspot insight: bug density concentrates in files that are both complex and frequently changed):
- Churn + god-size — complexity the team pays for on every change. Highest-priority refactor target in the whole report.
- Churn + hub — every change risks rippling to all dependents; expect frequent breakage and merge conflicts.
- God-size, low churn — large but stable. Usually fine to leave alone; refactoring it has little payoff.
Use churn to order the candidates the structural tables surfaced: between two god modules, fix the high-churn one first.
Caveats: churn counts commits, not diff size; a rename resets a file's history; bulk reformat or license-header commits inflate everything they touch.
Cycles
Files inside a strongly-connected component cannot be understood, tested, or deployed in isolation — they all need each other. Cycle size matters:
- Size 2 — often a missing seam. Two modules import each other because there is a shared concept that wants its own home. Extract a third module both depend on.
- Size 3+ — usually a layering violation. Some abstraction has been inverted: a "lower" layer is reaching back into a "higher" one. Identify the seam where the cycle crosses a conceptual boundary and break it there (dependency inversion, callbacks, events).
Cycles make the strongest and most expensive claim in the report, so verify every one before repeating it. Open both files and find the two imports. A false positive in Hubs costs a second look; a false positive here costs a refactor of an architecture that was already correct. Worse, a phantom cycle swallows real ones — a spurious 7-file component hides a genuine 2-file cycle inside it, and the reader correctly dismisses the whole thing as too big to act on.
Cross-reference
Lists every file appearing in two or more of the other tables, ranked by how many. This is the section to act on: the individual tables are single dimensions, and a file that is simultaneously god-sized, high-churn, and a hub is a far stronger signal than the top row of any one table. Read the other tables for the evidence behind a row here.
Limitations
The output already lists these, but worth repeating because they shape how much weight to give the report:
- Path-alias indirection (
@/components/...in Next.js,~/lib/...in some TS configs), dynamic imports, re-exports through barrel files, and codegen targets can all cause the resolver to miss real edges. - TypeScript type-only imports (
import type ... from,export type ... from) are excluded from the graph — they are erased at compile time, so they are not runtime dependency edges and must not create cycles. Inline type specifiers (import { type X } from ...) are still counted even when every specifier is type-only, so a rare false edge of that shape remains possible. - Basename-match resolution can produce false positives when two unrelated files share a stem. The proximity tie-breaker helps but is not perfect.
- External imports whose first path segment matches a project directory
name can bind to a repo file. Language standard libraries and crates
declared in
Cargo.tomlare filtered out, but an undeclared dependency — or a language whose stdlib the analyzer doesn't enumerate (JS, C, and the generic fallback set) — is not. The colliding names are exactly what people call directories:fs,os,io,net,sync,time,path. - A directory-name match is a guess about which file inside is meant. It
resolves to the module root (
mod.rs,__init__.py,index.*) when one exists, and otherwise picks the nearest file in the directory, which may be the wrong one. - Rust
mod x;declarations and#[cfg(test)]child modules are excluded — the first is containment rather than dependency, the second compiles into its parent. Both used to manufacture cycles. - The graph is file-level. A god-class buried inside a moderate-sized file is invisible to this analysis.
State these caveats in your summary to the user so the report is taken as a starting point for human judgement, not an authoritative verdict. Stating a caveat is not a substitute for the sanity checks above — a reader who is told "basename matching is imperfect" and then handed a phantom cycle will act on the cycle.
Communicating findings
After running the script, do not just paste the table. The report is the evidence; the user wants the interpretation.
A good response looks like:
Ran the hotspot analyzer on
src/. Three files are worth your attention:
src/utils/helpers.ts(LOC 312, fan-in 47) — both a god module and a hub. Generic name + accumulated dependents = high blast radius. Worth splitting by domain.src/services/order_service.py(LOC 612, fan-out 18) — large coordination layer reaching into many modules. Candidate for breaking into orchestrator + steps.- One cycle of 3 files between
models/user.py,models/order.py,models/audit.py— likely a shared concept (audit context?) wanting its own module.Full report at
<path>if you want the raw numbers. Caveat: the resolver uses basename matching, so path-aliased imports may be undercounted.
Always include:
- The 2–4 files that stand out, with the reason they stand out (which tables they hit, what the combination implies).
- One concrete suggestion per finding — even if hedged. "Split by domain", "extract a shared module", "invert the dependency".
- The caveat about resolution limits, so the user calibrates trust.
Do not dump the entire table unless the user asks for it. The script writes markdown that can be shown verbatim — but lead with the human-readable summary first.
Chain to code-audit-deep
File rankings are evidence of where to look, not findings the user can act
on directly. "Refactor commit.rs" is not actionable; "commit.rs:426 —
SystemTime::now() called per nonce, hoist to constructor" is.
Validate the ranking before you chain. Run the four sanity checks from
"Sanity-check the ranking before you believe it". Auditing a 22-line
declaration file and a 55-line main.rs because they topped a broken tangle
table burns a full audit pass and returns nothing — and the audit's silence
then reads as "this code is fine" rather than "we audited the wrong files."
If a check fails, fix the run (--explain, --exclude) and re-rank first.
After producing the file-level summary above, if the user asked the broad "what are the hotspots?" question (or any equivalent open-ended hotspots question that didn't name a specific file), do not stop at the ranking. Continue:
- Pick the top 3-5 files from the Cross-reference table — it already ranks by combined signal (god + hub, god + tangle, high churn + any structural table, files in cycles). Skip rows marked as test files unless the user asked about test structure.
- Invoke the
code-audit-deepskill on those files. That skill reads the files end-to-end and emits categorized line-level findings (perf, correctness, durability, memory, complexity, coupling). - Present both layers in the final response — rankings first ("here is the shape of the problem"), then line-level findings per top file ("here is what is wrong inside them, with line numbers and fix sketches").
Skip the chain step only when:
- The user explicitly asked for rankings only ("just the file list", "no details, just rank them").
- The repo is too small for hotspots to be meaningful (< ~30 files) — in
that case run
code-audit-deepdirectly on the whole tree's source files instead of going through this skill. - The user named specific files up front. Then
code-audit-deepshould have been picked instead of this skill from the start.
The reason for chaining: this skill's iter-0 failure mode (observed by the author) was producing only file rankings when the user actually wanted the line-level findings. The user's natural phrasing for what's broken in this codebase is "what are the hotspots?" — and that question is answered properly only when both layers are presented together.
When the analyzer disagrees with the user
If the user pushes back ("utils.ts is fine, leave it alone") trust them —
they know the codebase. The analyzer surfaces candidates; humans confirm.
Update your reading of the codebase based on their pushback rather than
re-arguing from the metrics.