Packer's plugin installer and build runner carry the highest structural risk

Five untouched god functions, led by InstallLatest in packer/plugin-getter/plugins.go (CC 95, 442 days since last change), account for the highest-risk code in hashicorp/packer.

Stephen Collins ·
Generated by hotspots · free & open source
pip
$ pip install hotspots-cli
Activity Risk19.3Low
Hottest FunctionInstallLatest

Antipatterns Detected

exit_heavy5god_function5long_function5complex_branching4deeply_nested3stale_complex3

Run this on your own codebase

See if your own repo has a InstallLatest-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 packer?

A god function does too many jobs and calls too many other functions directly, which shows up as a high cyclomatic complexity combined with a high fan-out. It matters because it is hard to test in isolation, and a change in any of the many things it calls can ripple back into it. All 5 of the top hotspots in packer carry this pattern, including `RunContext` with a fan-out of 60 and `InstallLatest` with a fan-out of 56. The practical cost is that each one needs a very large number of test cases to cover its paths.

How do I reduce cyclomatic complexity in go?

The main technique is extract-method: pull each distinct stage into a named function that returns an error, so the parent becomes a short sequence. As a rule of thumb, I would split anything above a cyclomatic complexity of 15 and treat anything above 30 as urgent. A concrete first step in packer is the TODO already sitting in `InstallLatest`, which proposes pulling the per-version install loop into its own function; that single extraction should cut a large share of the 95 paths. In Go, also consider small helper functions that wrap repeated 'append error, log, continue' sequences.

Is packer actively maintained?

The data for my top five says they are dormant, not that the project is. All five are in the debt quadrant with 0 touches in the last year, and the most dormant, `Run` in packer/build.go and `Provision` in the shell provisioner, haven't been changed in 530 days; `InstallLatest` is at 442 days. Elsewhere, three small generated enum functions in the fire quadrant were changed 1 day before the analysis, so the repository itself is still receiving commits. Active development and heavy structural debt can coexist, and here the commits are landing outside the most complex functions.

How do I reproduce this analysis?

Use the hotspots CLI from GitHub and check out the analyzed commit with `git checkout 51ba09f`. Then run `hotspots analyze . --mode snapshot --explain-patterns --force`. The same command works on any local git repository without configuration.

What does activity-weighted risk mean?

Activity-weighted risk combines structural complexity (cyclomatic complexity, nesting depth and fan-out) with how often and how recently a function has been changing, so a function that is both hard to understand and actively changing carries the most live regression risk. Structure still counts on its own, though: in this analysis the top five scores range from 15.51 to 19.3 even though none has been touched in a year, because their structure is so extreme. By contrast, the three actively changing generated enum functions score only 7.44 each because their cyclomatic complexity is 5. The score is meant to direct review effort, not to judge the quality of the code or its maintainers.

I would start with InstallLatest in packer/plugin-getter/plugins.go. It hasn’t been changed in 442 days, and it has a cyclomatic complexity of 95, a nesting depth of 7 and a fan-out of 56. That is a maintenance concern rather than an emergency: nobody is touching it now, but whoever has to modify plugin installation next will inherit all of that complexity at once. Across hashicorp/packer’s 1,330 analyzed functions, 212 are in the critical band and 517 sit in the debt quadrant, so dormant complexity, not active churn, is the dominant risk here.

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
InstallLatestpacker/plugin-getter/plugins.go19.395756
RunContextcommand/build.go17.871560
Runpacker/build.go16.874520
wrappedMainmain.go15.534431
Provisionprovisioner/shell/provisioner.go15.554336
Triage Band Distribution
Fire3Debt517Watch1OK809

1,330 functions analyzed

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

The distribution is lopsided. Only 3 functions fall in the fire quadrant, and they are small generated enum helpers with a cyclomatic complexity of 5 each. They change because they are generated, not because anyone is working on hard logic. The real exposure is the 517 debt-quadrant functions, and all five of my top hotspots come from that group. None has been touched in the last year. Their oldest gaps are 530 days (twice) and 442 days.

All five share the same three antipatterns: exit-heavy, god function and long function. Each one orchestrates a lot, returns from many places, and cannot be covered by a handful of tests. I am not saying any of them is broken. File-level history is quiet, with no bug-linked commits or reverts in any of the five files. These are maintainability problems, and the cost lands on whoever touches them next.

InstallLatest — plugins.go

InstallLatest
packer/plugin-getter/plugins.go
19.3
critical
CC 95
ND 7
FO 56
touches/yr 0
Cyclomatic Complexity 95
threshold: 10

This is the plugin installer’s main entry point for resolving and fetching the newest version that satisfies a requirement. From the code I can see that it loops over getters, fetches and parses a release list, filters releases against version constraints, sorts them highest first, then loops again over candidate versions to fetch checksums and install. That is several distinct jobs in one function, and the nesting depth of 7 follows from loops sitting inside loops sitting inside error checks.

Go’s explicit error handling dominates the branching. Several branches build up a multi-error value, log at trace level, and then either continue to the next getter or return. A fan-out of 56 means the function is coupled to a very wide surface, so a change to checksumming or download behavior can surface here unexpectedly. A comment in the source already marks the inner version loop with a TODO to split it into its own install-version function. The function hasn’t been changed in 442 days and has a single recent author, so institutional knowledge of the details may be thin.

I recommend acting on that TODO. Extract the per-version install loop first, then separate release discovery (get, parse, filter, sort) from installation. Two or three functions with a complexity under 30 each would be far easier to test with a fake getter.

RunContext — build.go

RunContext
command/build.go
17.76
critical
CC 71
ND 5
FO 60
touches/yr 0

This is the body of the packer build command, and it reads like a long setup pipeline: load config, detect and initialize plugins, set up the HCP registry, populate a version, fetch builds, inject enforced provisioners, then run the builds. Each stage has its own early return on a non-zero status, which is where much of the exit-heavy pattern comes from. Its fan-out of 60 is the highest in my top five, so it touches more distinct collaborators than any other function here.

It hasn’t been changed in 179 days, and the file shows no author activity in the last 90 days. Only wrappedMain (132 days) has changed more recently among my top five, and neither has been touched in the last year. The HCP registry and enforced-provisioner steps look like later additions to an older flow, and that kind of accretion is how a command handler ends up with a complexity of 71.

I would extract each pipeline stage into a named step that returns a status or diagnostics, so RunContext becomes a readable sequence. The HCP registry setup and enforcement block is the cleanest first cut: it is self-contained and already has its own error translation.

Run — build.go

Run
packer/build.go
16.84
critical
CC 74
ND 5
FO 20
touches/yr 0

This is the core build’s execution method. It copies hooks, wraps provisioners (with a debug variant when debugging is on), registers provision and cleanup hooks, then runs the builder and the post-processing that follows. It hasn’t been changed in 530 days and is flagged stale-complex, so I would treat it as high blast radius when next touched. A cyclomatic complexity of 74 on a fan-out of only 20 tells me the difficulty here is branching rather than coupling.

The function opens with a panic if prepare was not called first, an ordering contract enforced at runtime. With this many paths, the interaction between debug mode, cleanup provisioners and post-processors is where I would want tests before changing anything. Because Go returns errors explicitly, each of those paths probably carries its own exit.

I recommend extracting hook assembly into its own function first. Building the hook map is mostly independent of running the builder, and moving it out would remove a large share of the branches without changing behavior.

wrappedMain — main.go

wrappedMain
main.go
15.53
critical
CC 34
ND 4
FO 31
touches/yr 0

This is the process entry point behind the panic wrapper. It sets thread counts, configures log filtering, loads the global config, starts the checkpoint reporter in a goroutine, prepares the cache directory, and picks between machine-readable and normal UI. The source carries a long warning comment about how output written here interacts with the wrapper, and the author explicitly advises avoiding output changes in this function. I read that as a sign the function is fragile for reasons the metrics cannot fully capture.

The go statement launching the checkpoint run is the kind of goroutine interaction that structural metrics do not measure, so the real risk may be higher than a cyclomatic complexity of 34 suggests. It is the only function in my top five whose file has a non-zero bug-fix rate (17%, with 6 commits total), though with no bug-linked commits or reverts I would read that as mild context, not evidence of defects. It hasn’t been changed in 132 days.

I would move configuration loading, cache setup and UI selection into separate setup helpers that return errors, keeping the output-stream workarounds in one place. That would make the delicate stderr and stdout handling easier to find and review.

Provision — provisioner.go

Provision
provisioner/shell/provisioner.go
15.51
critical
CC 54
ND 3
FO 36
touches/yr 0

This is the shell provisioner’s execution method. From the code I can see that it handles inline commands by writing a temporary script, optionally writes and uploads an environment-variable file with retries, and then runs the resulting scripts. Nearly every step has its own wrapped error return, which accounts for the exit-heavy pattern. The nesting depth of 3 is modest, so the complexity of 54 comes from many sequential decisions rather than deep structure.

It hasn’t been changed in 530 days and is flagged stale-complex. Shell provisioning is one of the most commonly used provisioners, so I would expect a change here to reach many users’ builds. The upside is that sequential, low-nesting complexity is the easiest kind to break apart safely.

I would extract the three obvious phases into functions: inline script preparation, environment-variable file preparation and upload, and script execution. Each can be tested without the others.

The common thread across these five is size and orchestration, not churn. Splitting each into named stages is the lowest-risk way to reduce the cost of the next change.

Patterns Found

Antipatterns detected across the top functions in this snapshot:

PatternOccurrences
exit_heavy5
god_function5
long_function5
complex_branching4
deeply_nested3
stale_complex3

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/hashicorp/packer
cd packer
git checkout 51ba09f6e41b5c46b9793f228abeb24c9210b03f
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 →

Was this useful? Let me know →

Related Analyses