At commit 31c917b, beets has 2,166 analysed functions, of which 208 land in the critical band — nearly 1 in 10. The single highest-priority function, update_items in beets/ui/commands/update.py, carries an activity-weighted risk score of 18.46, was touched 4 times in the last 30 days, and was last changed 0 days ago: it is actively changing right now, which transforms its structural complexity from a maintenance concern into a live regression risk. I would start there before any other refactoring, then work through the import pipeline where three debt-quadrant god-functions — some dormant for months — are accumulating structural debt with broad blast radius.
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 |
|---|---|---|---|---|---|
update_items | beets/ui/commands/update.py | 18.5 | 67 | 5 | 29 |
albums_in_dir | beets/importer/tasks.py | 17.3 | 50 | 6 | 18 |
choose_candidate | beets/ui/commands/import_/session.py | 16.4 | 44 | 5 | 12 |
albums | beetsplug/mbsync.py | 15.7 | 42 | 6 | 22 |
importer_edit | beetsplug/edit.py | 15.5 | 38 | 4 | 24 |
Codemod / Tooling Files in Results
Two functions in context_only — Sizzle in beetsplug/web/static/jquery.js and eq in beetsplug/web/static/underscore.js — are vendored JavaScript libraries bundled with the beets web plugin. Their high structural complexity scores are properties of jQuery’s selector engine and Underscore’s equality implementation respectively, not of beets application code. To exclude them from future analyses, add the following to .hotspotsrc.json: { "exclude": ["beetsplug/web/static/"] }. That pattern will suppress the entire static asset directory without affecting any beets Python source.
Risk landscape
2,166 functions analyzed
The distribution tells a clear story: 566 functions sit in the debt quadrant — structurally complex but not recently touched — and 168 are in the fire quadrant, where complexity and active change overlap. That fire population is where near-term regression risk concentrates, and two of the top five hotspots live there.
High cyclomatic complexity — many independent execution paths, each a potential bug surface and required test case.Exit Heavy×7Exit Heavy
Multiple return or throw paths dispersed through the body — each exit needs separate test coverage.God Function×7God Function
Calls an unusually large number of distinct functions (high fan-out), making it the structural centre of gravity for a subsystem.Long Function×7Long Function
Function body is too long to review in a single pass; likely contains multiple distinct responsibilities.Deeply Nested×6Deeply Nested
Control structures nested 4+ levels deep, making it hard to reason about the full execution state at inner branches.
Across the top hotspots, complex branching and god-function patterns dominate. These are not independent problems: a function that does too much inevitably acquires too many branches, and too many branches make each additional change harder to reason about. The exit-heavy pattern — multiple return paths scattered through a long body — compounds test-coverage burden on top of that.
update_items — update.py
update_items is the core of beets’ beet update command: given a sequence of library items, it reconciles the database against what is actually on disk — handling deletions, mtime checks, tag re-reads, field filtering, album metadata propagation, and file moves, all inside a single database transaction. The source excerpt makes the scope visible: within one function body there are field-set computations for both items and albums, an exclude_fields filtering pass, and a per-item loop that branches on deletion, mtime staleness, read errors, and a special case for album-artist matching. That accounts for much of the cyclomatic complexity of 67 — more than six times the threshold of 10 where splitting becomes warranted.
With a fan-out of 29 in Python, the implicit coupling is even broader than the number suggests: duck typing means each of those 29 callees is resolved at runtime, so a change to any collaborator’s interface may surface only as a runtime failure. The nesting depth of 5 means the innermost logic sits inside a transaction, a loop, a conditional chain, and at least one try/except — four levels of context an engineer must hold simultaneously.
The file-level signals add texture: 25% of commits to this file have been tagged as bug fixes, and the PR review comment density is 0.8 per commit. Neither figure proves a current defect, but together they suggest this surface attracts correctional attention. With 4 touches in the last 30 days and a last-changed timestamp of today, this is the one function in the repository where a refactoring mistake could ship in the next PR.
Recommendation: Extract the field-set computation (items vs. albums, path injection, exclude filtering) into a dedicated helper before touching any other logic. That alone removes a significant branch cluster from the top of the function and makes the per-item loop easier to isolate and test independently.
albums_in_dir — tasks.py
albums_in_dir walks a directory tree and groups media files into probable albums, with special handling for multi-disc structures. The source excerpt shows a stateful collapse machine: it maintains collapse_paths, collapse_items, and collapse_pat across iterations of a sorted_walk loop, branching on whether the current directory continues or terminates a multi-disc sequence. Inside that loop, a second pass checks for the start of a new multi-disc sequence by testing all subdirectory names against compiled regex patterns — including an inner loop over dirs that can exit early in multiple ways. The nesting depth of 6 reflects exactly that structure: walk loop → collapse check → start-collapsing check → marker-pattern loop → subdir loop → pattern match branch.
This function has not been touched in 44 days and has zero commits in the last 30 days — structural debt, not an active fire. But the collapse state machine is the kind of logic that is easy to break with an apparently innocuous change, and the review-comment density of 9.5 per commit across this file’s 21-commit history is the highest of any file in the top five. That density suggests reviewers have historically found this surface difficult to reason about — a pattern that will repeat the next time someone needs to add a new multi-disc detection heuristic.
Recommendation: The collapse state machine and the multi-disc start-detection logic are two distinct responsibilities living in one function. Extracting the collapse accumulator into a small class or named helper with explicit state transitions would cut the nesting depth substantially and make the walk loop’s main path readable in isolation.
choose_candidate — session.py
choose_candidate is the interactive prompt at the heart of beets’ import UI: given a list of album or track match candidates and an autotagging recommendation, it presents choices to the user and returns whatever the user selects — which may be a candidate object, a skip action, an as-is decision, or an arbitrary PromptChoice. The source excerpt shows three major control surfaces nested inside a while True loop: a zero-candidates branch (with track vs. album sub-branching), a bypass path for high-confidence recommendations, and a candidate-display path that builds colored output line by line and truncates penalty keys at three items.
The cyclomatic complexity of 44 means 44 independent paths through this function — each a required test case and a potential regression surface. It was touched 4 times in the last 30 days and last changed 4 days ago, placing it squarely in the fire quadrant alongside update_items. The bug-fix commit share of 27% across the file’s 11 commits — the highest fraction in the fire-quadrant top entries — is a worth-noting historical signal: more than a quarter of commits here have been corrective.
In Python, the combination of a while True loop, multiple return paths, and a choices parameter with a mutable default argument (choices: list[PromptChoice] = []) is a subtle trap — mutable defaults are shared across calls. That pattern is visible in the excerpt and is exactly the kind of implicit coupling that high fan-out in dynamic dispatch amplifies.
Recommendation: Fix the mutable default argument immediately — that is a correctness risk independent of complexity. Then decompose the zero-candidates branch, the bypass path, and the candidate-display loop into separate functions. The while True retry loop can then be reduced to a thin coordinator calling those helpers.
albums — mbsync.py
albums in the MusicBrainz sync plugin queries the library for albums matching a user query, fetches their release data from MusicBrainz (or an alternate data source), constructs MBID-to-track-info indices, resolves item-to-track pairings by release track ID and then recording ID, disambiguates multi-copy recordings by disc and track number, and finally applies metadata changes — all in one method. The source excerpt makes the indexing and disambiguation logic visible: releasetrack_index and track_index are built in one pass, then consumed in a second pass that branches on whether a release-track MBID is available, whether there is one candidate or many, and whether disc/track numbers disambiguate the remainder.
This function has not been touched in 102 days and has zero commits in the last 30 days — structural debt carrying wide blast radius, not an active fire. With a cyclomatic complexity of 42 and a fan-out of 22 — reaching into library queries, metadata plugins, album match application, item change detection, and file operations — any future change here will touch many runtime-resolved callees. The one bug-linked commit in the file’s history is not enough to call this a proven defect surface, but a fan-out of 22 in Python means 22 runtime-resolved callees, any of which could silently change behaviour under duck typing. Two authors were active on this file in the last 90 days, so it will be touched again.
Recommendation: The MBID indexing and the item-to-track pairing resolution are separable concerns that could each become a dedicated method. Isolating the disambiguation logic in particular (the multi-copy recording path) would make it independently testable without requiring a full MusicBrainz round-trip in tests.
importer_edit — edit.py
importer_edit is the callback that launches beets’ interactive YAML editor during an import session. The source excerpt shows the full scope: it assigns temporary negative IDs to un-persisted items, computes which fields to show at album vs. track level, builds a YAML document list (with an optional album header), enters a retry loop that calls the external editor, validates the returned document count, splits the result back into header and per-track documents by the presence of an id field, and snapshots originals for diff display. Any one of those responsibilities is individually testable; combined into one function with a cyclomatic complexity of 38 and a fan-out of 24, the interaction surface is large enough that a change to field-selection logic can inadvertently affect YAML splitting, and vice versa.
At 47 days without a touch and zero commits in the last 30 days, this is structural debt. The file’s history is worth noting: 2 bug-linked commits out of 21 total, a bug-fix commit share of 24%, and 5 distinct authors active in the last 90 days — the highest author count of any file in the top five. When five engineers each familiar with different parts of the plugin make changes to a 38-CC function, the probability that two changes interact in an untested path is meaningfully higher than for a well-decomposed equivalent.
Recommendation: The YAML document splitting logic — identifying header vs. per-track documents by the absence or presence of id — is a natural extraction target. Pulling it into a named helper with its own test coverage would both reduce this function’s complexity and document an implicit invariant (the id-field contract) that is currently only evident from reading the full function body.
Patterns Found
Antipatterns detected across the top functions in this snapshot:
| Pattern | Occurrences |
|---|---|
complex_branching | 9 |
exit_heavy | 7 |
god_function | 7 |
long_function | 7 |
deeply_nested | 6 |
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/beetbox/beets
cd beets
git checkout 31c917b8c803f40c40f84948412fe239950e9124
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 →