k9s's internal layer carries the highest structural debt — 5 functions to address first

All five top hotspots in derailed/k9s sit in the debt quadrant — structurally complex functions untouched for up to 41 days, with high blast radius when the next development push arrives.

Stephen Collins ·
Generated by hotspots · free & open source
pip
$ pip install hotspots-cli
Activity Risk14.23Low
Hottest FunctiontailLogs

Antipatterns Detected

exit_heavy6god_function4deeply_nested4complex_branching3long_function3

Run this on your own codebase

See if your own repo has a tailLogs-style hotspot — run this in any local git repo:

pip
$ pip install hotspots-cli
npm
$ npm install -g @stephencollinstech/hotspots
Run in any repo
$ hotspots analyze .
★ Star on GitHub

Key Points

What is a god function and why does it matter in k9s?

A god function is one that handles too many distinct responsibilities at once, which shows up structurally as a high count of distinct functions directly called from a single location — fan-out. In k9s, `ShowPluginInputs` has a fan-out of 44, meaning it directly calls 44 distinct functions across form construction, styling, validation, and callback dispatch. This creates two concrete problems: first, any change to one concern (say, adding a new input type) requires reasoning about all 44 callees and their interactions; second, the function cannot be unit-tested in isolation without satisfying the full dependency surface. God functions are also the most likely site for unintended side effects when a seemingly local change ripples into an unrelated call path.

How do I reduce fan-out in Go?

The primary technique is extract-method: identify logical sub-responsibilities within the function and move each into a named helper, so the original function becomes a coordinator that calls a small number of well-named functions rather than a large number of implementation-level ones. A fan-out above 15 is a strong signal to start extracting; above 30 it warrants immediate attention. For `ShowPluginInputs` (fan-out 44), a concrete first step is moving each input-type case into its own `addXxxInput` function — that alone could bring the fan-out below 15 while making each input type independently testable. In Go specifically, interfaces can further reduce coupling by letting the coordinator depend on an abstraction rather than concrete callees, which also makes goroutine-based code easier to mock in tests.

Is k9s actively maintained?

The data presents a mixed picture. The fire-quadrant functions in `internal/model1/table_data.go` — `Render` (risk score 11.01), `rxFilter` (10.98), `ComputeSortCol` (10.55), and `sortCol` (9.21) — each show 1 touch in the last 30 days and 0 days since last change, which is clear evidence of active development on the table rendering layer. `Render` in `internal/render/job.go` (risk score 8.88) is also in the fire quadrant with 1 recent touch. At the same time, the five highest-risk functions haven't been touched in 39 to 41 days, and 757 functions across the codebase sit in the debt quadrant — structurally complex but dormant. Active development and accumulated structural debt are not mutually exclusive; they often coexist in projects where feature velocity outpaces refactoring cadence. k9s is being actively worked on, but the debt quadrant is large enough to warrant a dedicated refactoring pass.

How do I reproduce this analysis?

The Hotspots CLI is available at github.com/hotspots-dev/hotspots. This analysis was run against derailed/k9s at commit `436ea2e` — check out that SHA with `git checkout 436ea2e` in a local clone of the repository, then run `hotspots analyze . --mode snapshot --explain-patterns --force` to reproduce the scores. The same command works on any local git repository without any configuration file.

What does activity-weighted risk mean?

Activity-weighted risk combines two independent signals: structural complexity (derived from cyclomatic complexity, maximum nesting depth, and fan-out) and recent commit frequency. A function that is structurally complex but hasn't been modified in months scores lower than one with moderate complexity that is being changed every week, because the frequently-changed function is where bugs are most likely to be introduced right now. In k9s, all five top hotspots are in the debt quadrant — their structural complexity is high, but their recent activity is zero, so their risk score reflects the blast radius they carry for the next developer who touches them rather than an immediate regression in progress. This framing helps teams distinguish between code that needs refactoring before the next feature push and code that is actively dangerous today.

The most urgent finding from this k9s analysis is not something that’s breaking right now — it’s a time-bomb. tailLogs in internal/dao/pod.go carries a risk score of 14.23 and hasn’t been touched in 41 days, but its cyclomatic complexity of 15, fan-out of 23, and goroutine-based retry loop make it a high blast-radius function the moment anyone reopens it. Across 2,888 total functions, k9s has 131 in the critical band; every single one of the top five sits in the debt quadrant — complex code that has been left to accumulate without recent cleanup. I would start reviewing tailLogs before the next log-streaming feature lands.

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

FunctionFileRiskCCNDFO
tailLogsinternal/dao/pod.go14.215423
ShowPluginInputsinternal/ui/dialog/plugin_inputs.go14.014544
ConfigWatcherinternal/ui/config.go13.87519
pipeinternal/view/exec.go13.24519
hydrateinternal/render/cust_cols.go13.115624

Large Repo Analysis

k9s is a large repository. To stay within memory constraints, this analysis used hybrid touch mode: structural complexity — CC, ND, FO — is measured precisely for every function. Git activity is tracked at the function level (via git log -L) only for files with 5 or more commits in the last 30 days; other files use a file-level approximation. Rankings therefore surface functions that are both structurally complex and in the most actively-changing parts of the codebase. Dormant code with high structural complexity will rank lower than it would under a full per-function analysis — to surface it, run hotspots analyze . --per-function-touches on a machine with sufficient memory.

Risk Distribution

Triage Band Distribution
Fire12Debt757Watch33OK2086

2,888 functions analyzed

The quadrant picture is dominated by debt: 757 functions carry high structural complexity but have seen no recent commit activity. Only 12 are in the fire quadrant — actively changing right now — and those are clustered in internal/model1/table_data.go and internal/render/job.go, which I’ll touch on briefly after the main analysis. The 2,086 functions in the ok quadrant are the baseline noise you’d expect from any mature Go project of this size.

Detected Antipatterns
Exit Heavy×6Exit Heavy
Multiple return or throw paths dispersed through the body — each exit needs separate test coverage.
God Function×4God Function
Calls an unusually large number of distinct functions (high fan-out), making it the structural centre of gravity for a subsystem.
Deeply Nested×4Deeply Nested
Control structures nested 4+ levels deep, making it hard to reason about the full execution state at inner branches.
Complex Branching×3Complex Branching
High cyclomatic complexity — many independent execution paths, each a potential bug surface and required test case.
Long Function×3Long Function
Function body is too long to review in a single pass; likely contains multiple distinct responsibilities.

The antipattern picture is consistent across all five hotspots: god functions with broad fan-out, deep nesting that makes control flow hard to follow, and multiple exit paths that multiply the test surface. None of these are surprising in a TUI application that orchestrates Kubernetes API calls, file watchers, and plugin execution — but the combination is what makes these functions expensive to modify safely.


tailLogs — pod.go

tailLogs
internal/dao/pod.go
14.23
critical
CC 15
ND 4
FO 23
touches/30d 0

tailLogs manages the full lifecycle of a Kubernetes pod log stream: it allocates a buffered channel, spawns a goroutine, runs an exponential-backoff retry loop, and dispatches to readLogs for the actual stream consumption. The source excerpt confirms this structure in detail — a sync.WaitGroup-guarded goroutine wraps a for range logRetryCount loop that handles two distinct failure modes (log request failure and stream establishment failure) with near-identical error branches, each containing a select over ctx.Done() and a time.After(delay) case.

The cyclomatic complexity of 15 comes directly from that branching structure: every retry path, every shouldStopRetrying type-assertion check, every switch on stream result codes (streamEOF, streamError) adds an independent execution path. Fan-out of 23 means this single function reaches into 23 distinct callees — backoff configuration, pod status introspection, logging, channel management, and the Kubernetes client interface all live here simultaneously. That’s a god-function profile: changes to any one concern require reasoning about all the others.

The Go-specific risk is real. The goroutine launched here outlives the function call; if the ctx cancellation path has any gap — and the nested select blocks are exactly where those gaps hide — the goroutine can leak. All 33 bug-linked commits touching this file were bug fixes, which is historical context suggesting this file has attracted quality-related attention before.

This function hasn’t been touched in 41 days. The structural debt is already significant; adding a new log-streaming feature on top of it without refactoring first is the scenario I’d want to avoid.

Recommendation: Extract the per-attempt retry logic (request failure branch and stream failure branch) into a shared helper that returns a typed retry signal. That alone collapses two near-duplicate select blocks into one, meaningfully reducing the cyclomatic complexity and making the backoff state machine testable in isolation.


ShowPluginInputs — plugin_inputs.go

ShowPluginInputs
internal/ui/dialog/plugin_inputs.go
14
critical
CC 14
ND 5
FO 44
touches/30d 0

ShowPluginInputs builds and presents the interactive dialog that collects user input before a k9s plugin command runs. The source excerpt shows it handling four distinct input types — string, number, boolean (checkbox), and dropdown — each in its own switch case, each registering a closure that writes into a shared PluginInputValues map.

The fan-out of 44 is the most striking number here. This function directly calls tview form construction methods, style configuration helpers, closure-capturing field builders, validation functions, and the ok/cancel callback chain. A fan-out of 44 means this function is structurally coupled to a very large surface area of the UI layer. Any change to how input types are styled, validated, or dispatched touches this function.

The nesting depth of 5 comes from the combination of the switch on input type and the per-type closures, some of which contain their own conditionals (the number validator has four early-return branches, the dropdown guards on len(input.Options) > 0). With cyclomatic complexity at 14, there are 14 distinct paths a reviewer needs to reason about — and every new input type the plugin system acquires adds more.

This function hasn’t been touched in 41 days. There’s no bug-fix history to flag here, but the long_function and deeply_nested patterns together with that fan-out make this a maintenance burden the moment plugin input types expand.

Recommendation: Each input type case is already self-contained enough to extract into its own addStringInput, addNumberInput, addBoolInput, addDropdownInput helper. That reduces ShowPluginInputs to a dispatcher, brings the fan-out down substantially, and makes each input type independently testable.


ConfigWatcher — config.go

ConfigWatcher
internal/ui/config.go
13.79
critical
CC 7
ND 5
FO 19
touches/30d 0

ConfigWatcher starts an fsnotify file watcher and dispatches config reload events from a goroutine, handling both the main app config file and the per-context config file. The source excerpt shows a goroutine containing a for/select loop that switches on fsnotify event type, watcher errors, and context cancellation — a classic Go concurrency pattern, but one whose nesting depth of 5 makes the control flow non-trivial.

The cyclomatic complexity of 7 is moderate, but that number undersells the reasoning burden here. The deepest nesting comes from the combination of the outer goroutine, the for loop, the select, the if evt.Has(...) check, and the if evt.Name == config.AppConfigFile branch — each level requiring the reader to track a different kind of state simultaneously (file system events, goroutine lifetime, context cancellation, UI thread queuing via QueueUpdateDraw).

Fan-out of 19 reflects the breadth of coordination: this function touches the fsnotify watcher, two config reload paths, the flash/logo UI components, and the RefreshStyles pipeline. The exit_heavy pattern is present — the goroutine has three distinct return points (watcher error, context cancellation, and the implicit case where w.Add fails on the context config file).

The file carries 22 bug-linked commits, all of them bug fixes. That’s historical signal worth noting when prioritizing review: config-reload behavior in a TUI that watches live Kubernetes contexts is a plausible source of edge-case failures, and the goroutine’s error return path (w.Errors channel) terminates silently with only a slog.Warn.

This function hasn’t been modified in 41 days.

Recommendation: Extract the goroutine body into a named watchConfigEvents function. This separates the watcher setup (synchronous, testable) from the event dispatch loop (concurrent, harder to test), reduces the nesting depth by one level, and makes the three exit paths explicit in their own function scope.


pipe — exec.go

pipe
internal/view/exec.go
13.23
critical
CC 4
ND 5
FO 19
touches/30d 0

pipe executes one or more exec.Cmd instances, optionally chaining their stdout/stdin with io.Pipe to form a shell pipeline. The source excerpt reveals three distinct execution modes handled in a single function body: a backgrounded single command (goroutine-based, closes statusChan on completion), a foreground single command (runs synchronously, handles exec.ExitError signal detection), and a multi-command pipeline (iteratively wires pipes, starts each command, waits on the last).

A cyclomatic complexity of 4 might look benign in isolation, but the nesting depth of 5 tells a more accurate story. The backgrounded-single-command path alone nests a goroutine, an if err check, an else branch, and a strings.Split loop — and that’s before reaching the foreground path with its errors.As signal-detection logic. This is an exit-heavy, god-function pattern: multiple early returns, multiple statusChan close sites, and three fundamentally different command execution strategies living in one function body.

Fan-out of 19 reflects the coordination surface: OS stdin/stdout/stderr, io.Pipe, statusChan, exec.Cmd lifecycle management, and error formatting all pass through here. The pipe file has seen 28 bug-linked commits, all of them bug fixes, and it’s one of only two top-five files touched by just 2 distinct authors in the last 90 days, with a moderate rate of PR review comments — modest signals that this file has drawn attention during review.

This function hasn’t been touched in 39 days.

Recommendation: Split the three execution modes into runBackground, runForeground, and runPipeline helpers, with pipe acting as a router. Each mode then has its own clearly bounded exit paths and can be tested without exercising the others. This also eliminates the multiple statusChan close sites, which are the most likely source of future channel-close panics.


hydrate — cust_cols.go

hydrate
internal/render/cust_cols.go
13.07
critical
CC 15
ND 6
FO 24
touches/30d 0

hydrate populates a row of rendered column values for a Kubernetes resource by evaluating a list of JSONPath parsers against a runtime.Object. The source excerpt shows the function handling several distinct resolution strategies in a single pass: falling back to header-indexed row fields when no parser is present, short-circuiting on nil objects, dispatching between runtime.Unstructured (with a jq-parse fast path) and reflected structured objects, and finally iterating over multi-dimensional reflect.Value results to format each cell value.

With a cyclomatic complexity of 15 and nesting depth of 6, this is the deepest-nesting function in the top five. The depth comes from layered type-switches and reflect-based dispatch: the outer loop over parsers, the if parser == nil branch, the if unstructured, ok := o.(runtime.Unstructured) type assertion, the if vals, ok := jqParse(...) fast path, and finally the for i / for j double loop over reflect values — each containing a switch on column header type (with cases like resource.Quantity for milli-value formatting). That innermost switch is where the complex_branching pattern originates.

Fan-out of 24 means this function reaches into JSONPath evaluation, jq parsing, reflection, Kubernetes resource quantity formatting, and the row/header model simultaneously. Any change to how custom columns are specified, parsed, or rendered has a high probability of touching this function.

This function hasn’t been modified in 41 days. This file has the highest rate of PR review comments of any file in the top five — a signal that when this code has been reviewed, it has generated feedback.

Recommendation: Extract the per-column resolution into a resolveColumnValue(o runtime.Object, spec ColumnSpec, parser *jsonpath.JSONPath, row *model1.Row, rh model1.Header) RenderedCol helper. That collapses the outer loop body into a single call, reduces hydrate to orchestration, and makes each resolution strategy independently testable — which matters given the reflect-heavy paths that are difficult to cover exhaustively from a monolithic function.


What’s actively changing

While the five functions above are the structural priority, internal/model1/table_data.go is currently in motion: Render (risk score 11.01), rxFilter (10.98), ComputeSortCol (10.55), and sortCol (9.21) all show 1 touch in the last 30 days and 0 days since last change, putting them in the fire quadrant — live regression risk on the table rendering layer. Their structural complexity is lower than the debt functions above, so they aren’t a refactoring priority, but any work landing in table_data.go right now should be reviewed carefully given the active churn. Similarly, Render in internal/render/job.go (risk score 8.88) is in the fire quadrant with 1 recent touch, while toDuration, diagnose, and toContainers in the same file sit in the watch quadrant — low structural risk but worth monitoring as that file evolves.

Patterns Found

Antipatterns detected across the top functions in this snapshot:

PatternOccurrences
exit_heavy6
god_function4
deeply_nested4
complex_branching3
long_function3

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.

Reproduce This Analysis

git clone https://github.com/derailed/k9s
cd k9s
git checkout 436ea2e9f23c5dd2d8e05c3e974220657524ef17
hotspots analyze . --mode snapshot --explain-patterns --force --hybrid-touches 5

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 →

Was this useful? Let me know →

Related Analyses