sharp's _createInputDescriptor carries the top risk — CC 183, five times the next hotspot

Analysis of lovell/sharp at e000d0b finds _createInputDescriptor in lib/input.mjs carrying a cyclomatic complexity of 183 — nearly five times the next highest function — while being actively committed to, making it a live regression risk in a library used for high-throughput image processing.

Stephen Collins ·
Generated by hotspots · free & open source
Activity Risk20.24Low
Hottest Function_createInputDescriptor

Antipatterns Detected

exit_heavy5complex_branching4long_function3deeply_nested1god_function1

Run this on your own codebase

Hotspots runs locally in under a minute — no account, no data leaves your machine.

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

Key Points

What is an exit-heavy function and why does it matter in sharp?

An exit-heavy function has many distinct return or throw paths — every early-exit guard, every validation failure that throws, and every successful branch that returns a different value counts as a separate exit. Each exit point is a required test case: to verify correct behavior you need a test that reaches it, and if a path goes untested, a regression there can ship undetected. In sharp's top five functions, all five carry the exit-heavy pattern — the validation-heavy structure of input and output option handling means that covering every invalid-parameter branch alone requires a large number of discrete test cases. For a library where incorrect parameters passed to native libvips operations can produce silent failures or unexpected output, gaps in that coverage carry real user-facing risk.

How do I reduce cyclomatic complexity in JavaScript?

The most direct technique is extract-method: identify a coherent sub-task inside the function — validating a parameter group, resolving a polymorphic argument, applying a cross-parameter rule — and move it into a named function. A cyclomatic complexity above 15 is a reasonable threshold to start splitting; above 30 it should be treated as immediate action. For `_createInputDescriptor`, which has a CC of 183, I would extract the input-type resolution (the outer if-else chain over string, Buffer, ArrayBuffer, TypedArray, object, and array) into `_resolveInputSource` as a first step — that alone removes the majority of the branching from the parent function and makes each case independently testable. In JavaScript specifically, parameter-object normalization at function entry — collapsing polymorphic arguments like `widthOrOptions` into a canonical shape before any branching — is a complementary technique that reduces CC without increasing the number of exported functions.

Is sharp actively maintained?

The data strongly suggests yes. Every function in the critical band sits in the fire quadrant — high complexity combined with recent commit activity — and there are zero functions in the debt quadrant, meaning no high-complexity code has gone untouched for an extended period. The top five hotspots collectively received 15 commits in the last 30 days: `affine` and `resize` each received 4 touches, `_createInputDescriptor` received 3, and `tile` and `heif` each received 2. `affine` was last changed 1 day ago, `resize` 3 days ago, and `tile` and `heif` 5 days ago. Active maintenance and structural complexity are not mutually exclusive — in fact the fire quadrant exists precisely because a codebase can be both well-maintained and structurally risky at the same time.

How do I reproduce this analysis?

The analysis was run against lovell/sharp at commit e000d0b using the Hotspots CLI, available at github.com/hotspots-dev/hotspots. To reproduce it, check out the repo at that commit with `git checkout e000d0b` and then run `hotspots analyze . --mode snapshot --explain-patterns --force` from the repository root. The same command works on any local git repository without additional configuration — no hotspots account or project setup is required for a local snapshot analysis.

What does activity-weighted risk mean?

Activity-weighted risk combines structural complexity — derived from cyclomatic complexity, nesting depth, and fan-out — with recent commit frequency. A function that is structurally complex but hasn't been touched in a year scores lower than one that is moderately complex but is being changed every few days, because the actively changing function is where new bugs are most likely to be introduced right now. A CC-183 function like `_createInputDescriptor` that has been touched 3 times in 30 days and has a days_since_changed of 0 is a live regression risk; the same function with no recent activity would still be structural debt but with lower near-term probability of harm. This framing helps teams direct refactoring effort toward code where the complexity and the active change rate are both high — not just where the code looks most complicated in the abstract.

Across 235 functions analyzed in lovell/sharp at commit e000d0b, 31 reach the critical band and every single high-complexity function sits in the fire quadrant — actively changing, not dormant. I would start with _createInputDescriptor in lib/input.mjs, which has a cyclomatic complexity of 183 against a next-highest of 35: a gap of 148 points that signals a categorically different level of structural risk, not just a ranking difference. With 3 commits touching it in the last 30 days and an activity-weighted risk score of 20.24, this is a live regression surface, not a backlog item.

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
_createInputDescriptorlib/input.mjs20.2183918
tilelib/output.mjs15.43548
affinelib/operation.mjs15.122410
heiflib/output.mjs14.92548
resizelib/resize.mjs14.73538

Large Repo Analysis

sharp 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.

Quadrant and pattern overview

Triage Band Distribution
Fire56Watch179

235 functions analyzed

Every function in the critical and high bands sits in the fire quadrant — high complexity combined with recent commit activity. There are no dormant debt functions in this repo at all, which means structural complexity and active development are coexisting across the board. The pattern distribution across the top five reinforces this: the dominant antipatterns are exit-heavy validation chains and complex branching, with one function earning the god_function label outright.

Detected Antipatterns
Exit Heavy×5Exit Heavy
Multiple return or throw paths dispersed through the body — each exit needs separate test coverage.
Complex Branching×4Complex 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.
Deeply Nested×1Deeply Nested
Control structures nested 4+ levels deep, making it hard to reason about the full execution state at inner branches.
God Function×1God Function
Calls an unusually large number of distinct functions (high fan-out), making it the structural centre of gravity for a subsystem.

_createInputDescriptor — input.mjs

_createInputDescriptor
lib/input.mjs
20.24
critical
CC 183
ND 9
FO 18
touches/30d 3
Cyclomatic Complexity 183
threshold: 30

A cyclomatic complexity of 183 is not just a high number — it represents 183 independent execution paths through a single function, each of which is a required test case and a potential bug surface. The fifth-ranked function in this list has a CC of 35, which itself warrants attention. The gap of 148 points means _createInputDescriptor is a different category of problem entirely.

From the source excerpt, the function accepts three argument forms — filesystem path, Buffer, ArrayBuffer, TypedArray, plain object, undefined stream input, and Array — and then applies a second large validation pass over a rich inputOptions object covering parameters like failOn, autoOrient, density, and more. The nesting depth of 9 reflects this two-phase structure: outer branches discriminate input type, inner branches validate each option field, and the deepest paths combine both. Fan-out of 18 means the function reaches into nearly a fifth of the callable surface the module exposes, and in JavaScript’s dynamic dispatch model, some of those calls — particularly through is.* helpers and recursive this._createInputDescriptor() calls for array joins — may not be fully captured by static analysis.

The god_function and long_function pattern tags confirm what the metrics imply: this function is doing the work of several. It normalizes every supported input format, applies all input-level options, handles the recursive join case, and throws typed errors for every invalid branch. The exit_heavy tag is equally telling — with this many exit paths, test coverage that actually exercises all branches is extremely difficult to achieve or verify.

Three commits have touched this file in the last 30 days and it was last modified today. That makes this a live regression risk, not technical debt to schedule for later.

Recommendation: Extract the input-type discrimination block into a dedicated _resolveInputSource function and the options validation block into _applyInputOptions. That single decomposition would cut the CC by roughly half and bring nesting depth down from 9 to a more tractable level. The recursive join path — this.options.join = input.map(i => this._createInputDescriptor(i)) — is a strong candidate for its own named function as well.


tile — output.mjs

tile
lib/output.mjs
15.39
critical
CC 35
ND 4
FO 8
touches/30d 2

The tile function configures deep-zoom tiling output — tile size, overlap, container format (fs or zip), layout (dz, google, iiif, iiif3, zoomify), rotation angle, depth, blank-tile skipping, centering, and IIIF ID. The source excerpt shows each parameter handled by a sequential if (is.defined(...)) guard followed by a type-and-range check, with an immediate throw on failure. That pattern, repeated ten-plus times, is exactly what drives CC to 35 and earns the complex_branching and exit_heavy tags.

Nesting depth of 4 is not alarming on its own, but it combines with the branching count to produce a function that is hard to reason about holistically — the skipBlanks parameter, for instance, has a secondary default path specifically for google layout that is easy to miss in the surrounding noise. Two commits touched lib/output.mjs in the last 30 days; tile was last changed 5 days ago.

Recommendation: Introduce a parameter-object validation helper — something like _validateTileOptions(options) — that centralizes the guard-and-throw logic. Each validated field then becomes a single line in tile itself. This reduces CC, makes the function scannable, and makes the google-layout special case for skipBlanks explicit rather than buried.


affine — operation.mjs

affine
lib/operation.mjs
15.09
critical
CC 22
ND 4
FO 10
touches/30d 4

The affine function applies an affine transformation matrix to an image and accepts a companion options object for translation offsets (idx, idy, odx, ody) and an interpolator. The source excerpt shows the matrix validated first — accepting either a flat 1×4 array or a 2×2 nested array, flattened and re-checked — before the options block opens a second nested validation layer for each offset parameter.

At CC 22 and fan-out 10, affine is not in the same structural tier as _createInputDescriptor, but it is the most actively touched function in this list: 4 commits in the last 30 days, last changed just 1 day ago, with 3 distinct authors contributing over the last 90 days. That author spread combined with high recent activity is worth noting — when multiple contributors are making frequent changes to a function with 22 branching paths, the probability of a missed interaction between the matrix validation and the options validation rises.

The complex_branching and exit_heavy tags both apply, and the Object.values(this.constructor.interpolators) lookup in the interpolator validation introduces dynamic dispatch that static fan-out metrics will undercount.

Recommendation: Extract matrix validation into a standalone _validateAffineMatrix(matrix) function and options validation into _validateAffineOptions(options). Given the 3-author, 4-commit recent history, reducing the shared mutable surface area here will lower the chance of concurrent changes producing inconsistent validation behavior.


heif — output.mjs

heif
lib/output.mjs
14.92
critical
CC 25
ND 4
FO 8
touches/30d 2

The heif function sets encoding options for HEIF output — compression codec (av1 or hevc), quality, lossless mode, encoding effort, chroma subsampling, bit depth, and tuning mode. Each parameter follows the same guard-and-throw pattern seen in tile, with one notable interaction: the tune parameter silently overrides iq to ssim when lossless is also set. That conditional cross-parameter dependency — buried inside the options block — is the kind of behavior that is easy to break when someone adds a new codec or tuning mode.

CC 25 across 4 nesting levels with fan-out 8 puts heif below tile in raw complexity, but the complex_branching and exit_heavy tags apply here too. Two commits touched lib/output.mjs in the last 30 days; heif was last modified 5 days ago, the same day as tile — suggesting a coordinated output-layer change that touched both functions.

Recommendation: The lossless/tune interaction deserves an explicit named check — something like _resolveHeifTune(lossless, tune) — so the override logic is testable in isolation. The remaining parameter guards are candidates for the same parameter-object validation helper recommended for tile, which would bring both lib/output.mjs functions into alignment structurally.


resize — resize.mjs

resize
lib/resize.mjs
14.71
critical
CC 35
ND 3
FO 8
touches/30d 4

The resize function is sharp’s most user-facing API entry point — it accepts width, height, and a full options object covering fit mode, position, background color, resampling kernel, enlargement and reduction constraints, and more. At CC 35 — matching tile — it is structurally complex, but its nesting depth of 3 is the shallowest in the top five, which is a meaningful distinction: the branching is wide rather than deep, making individual paths somewhat easier to follow in isolation.

The exit_heavy and long_function tags reflect the sheer surface area. The source excerpt shows resize accepting widthOrOptions as either a number or an options object — a polymorphic signature that immediately adds branching before the main options block even begins. The position lookup — strategy[options.position] || position[options.position] || gravity[options.position] — chains three separate lookups through different maps, any one of which could silently return undefined if a caller passes an unrecognized value, before the integer range check catches it.

Four commits have touched lib/resize.mjs in the last 30 days and resize was last changed 3 days ago. With 2 authors contributing recently, this is a function under active development.

Recommendation: The polymorphic widthOrOptions argument is the most immediate refactoring target — normalizing it into a canonical options object at the top of the function before any branching reduces CC by several paths and makes the rest of the function’s logic uniform. The position lookup chain is a second, smaller candidate: a single resolvePosition(value) helper that encapsulates the three-map fallback makes the silent-undefined risk explicit and testable.


Contextual signals from the watch quadrant

Several functions in lib/output.mjs and lib/libvips.mjs — including toFormat, globalLibvipsVersion, isUnsupportedNodeRuntime, and yarnLocator — sit in the watch quadrant with low structural complexity and moderate recent activity. These do not need refactoring attention now, but toFormat in particular is worth monitoring: it sits in the same file as tile and heif, and output-layer changes that touch those functions may propagate to it.

Summary takeaways

  1. Start with _createInputDescriptor before the next development push. Its CC of 183 and nesting depth of 9 mean any change — however small — navigates 183 possible execution paths. The extract-method decomposition I described above is the highest-leverage single action available in this codebase right now.
  2. Standardize output-layer validation across tile and heif. Both functions live in lib/output.mjs, were both changed 5 days ago, and share the same guard-and-throw structure. A shared _validateOutputParam helper would reduce duplication and make future codec additions cheaper to add correctly.
  3. Treat affine’s author spread as a risk multiplier. Three authors, 4 commits, 1 day since last change — that combination on a CC-22 function with dynamic interpolator dispatch deserves test coverage that explicitly exercises every matrix shape and every option combination.
  4. The nesting depth of 9 in _createInputDescriptor is the only deeply_nested flag in the entire top five. That makes it structurally unique, not just highest-ranked. No other function in the top five has both a god_function tag and a deeply_nested tag — that combination is the clearest signal for immediate attention.

Patterns Found

Antipatterns detected across the top functions in this snapshot:

PatternOccurrences
exit_heavy5
complex_branching4
long_function3
deeply_nested1
god_function1

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.

Reproduce This Analysis

git clone https://github.com/lovell/sharp
cd sharp
git checkout e000d0b5e128e05bb6c499600f37e2a6a4b74314
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 →

Related Analyses