Four of the five riskiest functions in mitchellh/cli sit in a single file, cli.go, and that concentration is the real story here, not any one function in isolation. The top-ranked function, Run, carries an activity-weighted risk score of 14.4, but it falls in the debt quadrant — meaning the risk comes from structural weight (37 independent execution paths, 18 called functions) sitting untouched for 1582 days, not from live churn. Out of 57 total functions across the codebase, 6 land in the critical band, and I’d treat cli.go as the module to review before anyone builds new subcommand or autocomplete behavior on top of it.
The table below ranks functions by activity-weighted risk — a score that multiplies structural complexity by recent commit frequency. A function that is both hard to understand (high cyclomatic complexity) and actively changing is a higher priority than one that is complex but untouched. CC = cyclomatic complexity (independent execution paths); ND = max nesting depth; FO = fan-out (distinct callees).
Top 5 Hotspots
| Function | File | Risk | CC | ND | FO |
|---|---|---|---|---|---|
Run | cli.go | 14.4 | 37 | 3 | 18 |
processArgs | cli.go | 12.3 | 50 | 5 | 8 |
init | cli.go | 11.4 | 25 | 2 | 13 |
ask | ui.go | 10.9 | 11 | 1 | 13 |
commandHelp | cli.go | 10.7 | 27 | 3 | 24 |
<BandChart fire=‘0’ debt=‘12’ watch=‘0’ ok=‘45’ />
The quadrant split tells its own story: zero functions are in active fire-quadrant churn right now, but 12 sit in structural debt, and the five highest-scoring ones anchor that debt in cli.go and ui.go. Nothing here is mid-refactor or destabilizing on a weekly basis — the risk is dormant, concentrated, and waiting for the next feature request to reactivate it.
<PatternCloud patterns=‘long_function:4,stale_complex:4,god_function:3,exit_heavy:2,complex_branching:1,deeply_nested:1’ />
Four of the five functions are tagged long_function, four are stale_complex, and three are god_function. That combination is consistent across the set: large functions that haven’t moved in years, each pulling in a wide set of collaborators. None show bug-linked commits or reverts in the historical signals — the bug-fix rate on cli.go’s commit history sits at 0.1639 across 61 total commits, unremarkable for a file this size. I’m not reading defect history into this; I’m reading blast radius.
Run — cli.go
<FunctionCard fn=‘Run’ file=‘cli.go’ risk=‘14.4’ band=‘critical’ cc=‘37’ nd=‘3’ fo=‘18’ touches=‘0’ />
Run is the entry point for the whole CLI: it handles autocomplete short-circuiting, version and help flag detection, install/uninstall of autocomplete, subcommand lookup through the radix tree, and command factory invocation, all in one function with 37 independent paths and 18 distinct called functions. The excerpt shows at least six early-return branches before the actual command dispatch even happens — autocomplete completion, version display, top-level help, autocomplete name validation, install/uninstall flag conflict, then install, then uninstall. That’s a lot of exit points guarding a small number of downstream branches, matching the exit_heavy and god_function tags on this entry. It hasn’t been changed in 1582 days and shows zero touches in the last 30 days, so this is pure structural debt: nobody is breaking it today, but anyone adding a new top-level flag or autocomplete mode has to reason through all 37 paths to avoid breaking an existing one. My first move would be extracting the autocomplete install/uninstall block into its own method — it’s self-contained, guarded by a single c.Autocomplete check, and pulling it out would meaningfully cut both the path count and the nesting in the parent function.
processArgs — cli.go
<FunctionCard fn=‘processArgs’ file=‘cli.go’ risk=‘12.27’ band=‘critical’ cc=‘50’ nd=‘5’ fo=‘8’ touches=‘0’ />
This is the highest cyclomatic complexity in the set at 50, and it also has the deepest nesting at 5 levels — the pattern tags confirm it, tagged complex_branching and deeply_nested on top of long_function and stale_complex. The excerpt shows why: it’s a single loop over the incoming argument slice that has to simultaneously detect help flags, autocomplete install/uninstall flags, version flags, arbitrary top-level flags, and then figure out whether the current argument starts a subcommand — including a nested lookup path that walks the argument list forward, joins it into a search string, and does a longest-prefix match against the command tree with a regex boundary check. That’s argument parsing, subcommand boundary detection, and nested-command disambiguation all interleaved in one loop body. In a CLI library this is the classic shape: dispatch and parsing logic accumulate branches as flags get added, and nobody wants to touch it once it works. It’s gone 801 days without a change, so the debt is real but not urgent on its own — the urgency comes from the fact that any new flag type has to be threaded through this exact loop. My first move would be pulling the nested-subcommand resolution (the block building searchKey and running the prefix match) into its own helper — it’s already a distinct sub-problem with its own local variables, and isolating it would let the outer loop’s complexity drop independently of any future nested-command changes.
init — cli.go
<FunctionCard fn=‘init’ file=‘cli.go’ risk=‘11.38’ band=‘critical’ cc=‘25’ nd=‘2’ fo=‘13’ touches=‘0’ />
init runs once via sync.Once and sets up everything Run depends on: help function defaults, writer defaults, hidden-command lookup, the radix command tree, nested-command detection, and a recursive walk function that back-fills missing parent commands for nested subcommand trees. The recursive walkFn closure it builds inline is the interesting piece — a self-referential radix tree walk that inserts placeholder MockCommand entries for implied parent commands, doing real structural work inside a setup function rather than in a named, testable unit. With 13 called functions and 25 paths, this is tagged god_function for a reason: it’s the single place that has to stay correct for every downstream command-tree lookup, including the commandTree.Get and LongestPrefix calls that Run and processArgs both depend on. It hasn’t moved in 1582 days, same as Run, which tracks — they were likely last touched together. I’d extract the parent-command backfill loop (the walkFn closure and its insertion logic) into a named top-level function; it has no dependency on the rest of init’s state beyond the tree itself.
ask — ui.go
<FunctionCard fn=‘ask’ file=‘ui.go’ risk=‘10.87’ band=‘critical’ cc=‘11’ nd=‘1’ fo=‘13’ touches=‘0’ />
ask is the outlier in this set — its cyclomatic complexity (11) and nesting (1) are both far lower than the cli.go functions, but its fan-out of 13 and the concurrency shape push its score up. The excerpt shows why this one deserves a different kind of attention: it spins up a signal-notification channel for interrupts, launches a goroutine that reads a line from stdin (or via speakeasy.Ask for secret input), and then selects across an error channel, a line channel, and the interrupt channel. That’s textbook goroutine-and-channel coordination, and structural metrics like cyclomatic complexity don’t fully capture the risk in that kind of code — a mishandled channel close or an unregistered signal path can produce a leak or a hang that a path count won’t reveal. It’s on the exit_heavy list, which fits: three separate return points inside the select, each needing separate test coverage for the interrupt-during-input case specifically. This function has gone 3126 days without a change, the longest dormant period in the set, with only 10 total commits on record for the file, so this is old, stable code — but if anyone touches it, I’d want a test that explicitly forces the interrupt-channel branch, since that’s the path most likely to be undertested given the concurrency involved.
commandHelp — cli.go
<FunctionCard fn=‘commandHelp’ file=‘cli.go’ risk=‘10.69’ band=‘critical’ cc=‘27’ nd=‘3’ fo=‘24’ touches=‘0’ />
This function has the highest fan-out in the entire top five at 24 distinct called functions, against 27 paths and 3 levels of nesting. Looking at the excerpt, it’s doing template selection, template parsing with fallback error handling, building a data map for the template, and — when the CLI is in nested-command mode — iterating subcommands to compute name alignment padding and build a subcommand list for the help template. That padding-alignment loop (finding the longest key, then repeating spaces per entry) is exactly the kind of formatting-detail code that has no business sharing a function with error-handling template fallback logic. A fan-out of 24 means a change to any of those called helpers — helpCommands, Subcommand, the sprig template functions, sub.Help(), sub.Synopsis() — has a real chance of needing a corresponding check here. It’s been dormant for 1398 days. Splitting the subcommand-list-building block into its own function would cut fan-out immediately and let the template-execution path be tested independently of the padding logic.
Patterns Found
Antipatterns detected across the top functions in this snapshot:
| Pattern | Occurrences |
|---|---|
long_function | 4 |
stale_complex | 4 |
god_function | 3 |
exit_heavy | 2 |
complex_branching | 1 |
deeply_nested | 1 |
These labels belong to two tiers — Tier 1 (structural): complex_branching, deeply_nested, exit_heavy, long_function, god_function. Tier 2 (relational/temporal): hub_function, cyclic_hub, middle_man, neighbor_risk, stale_complex, churn_magnet, shotgun_target, volatile_god.
See more analyses with these patterns: complex_branching, deeply_nested, exit_heavy, god_function, long_function, stale_complex.
Reproduce This Analysis
git clone https://github.com/mitchellh/cli
cd cli
git checkout e73bd003a976863797bccc7d5630e83969f20bb8
hotspots analyze . --mode snapshot --explain-patterns --force
To run the same analysis on your own codebase, run hotspots analyze . --mode snapshot in any local git repo — no configuration required.
I use Hotspots to highlight structural and activity risk — not “bad code.” I treat these findings as a prioritization aid, not a bug predictor. Editorial policy →