Repository navigation
ci: lint job is root-only — 1291 golangci-lint findings across five unlinted workspace modules #174
Description
Activity
- addedtriagedItem has been triagedItem has been triagedpriority/p2Backlog-worthyBacklog-worthyseverity/mediumModerate harmModerate harmurgency/this-sprintWithin the current sprintWithin the current sprintimpact/internalTeam-internal onlyTeam-internal onlyeffort/lWeeksWeekstype/choreMaintenance / non-user-visibleMaintenance / non-user-visible
on Aug 8, 2026 Triage of the 49 production findings
Requested: classify by reachability, size it, and answer specifically — which
errcheck/errorlintsites are on live provider call paths, and does any swallow an error on a money operation?Measured against
bce0c2364(main + the LeanerCloud/cloud-commitments-cli#1751 workflow change; no Go source differences). Nothing fixed.Direct answer
No finding swallows an error on a money operation. One is a genuine control-flow defect on the purchase path and is worth fixing on its own; the other 48 are deliberate, already-documented, or fail-loud.
The headline number is misleading in the same way the
bodycloseone was. All 18errcheckfindings are explicit blank discards (v, _ :=or_ = f()), not unchecked calls —.golangci.ymlsetserrcheck.check-blank: trueandcheck-type-assertions: true, so deliberate discards are reported by design. "18 unchecked errors" is not 18 latent bugs.
Worth fixing: 1
providers/azure/services/internal/reservations/purchase.go:475— indoPurchase, on the Azure reservation purchase path.body, _ := io.ReadAll(resp.Body) ... return fmt.Errorf("reservation purchase failed with status %d: %s", resp.StatusCode, string(body))
That error string is then consumed by:
func IsSessionTimeout(err error) bool { return strings.Contains(err.Error(), sessionTimeoutFragment) }
and the retry loop branches on it:
if IsSessionTimeout(purchaseErr) && attempt < purchaseMaxAttempts { ... continue } return "", purchaseErr
So the discarded
io.ReadAllerror decides retry versus abort. If the read fails or truncates before the session-timeout fragment on a 400,IsSessionTimeoutreturns false and the purchase is abandoned rather than retried — a condition the code is explicitly designed to retry.Direction: fails closed. It abandons a purchase, it does not double-buy. But it converts a designed-retryable failure into a permanent one silently, and the operator sees
status 400:with an empty body. This is the only site among the 49 where a discarded error feeds control flow rather than a message.Fix: check the read error and surface it, or classify session timeout from a source that does not depend on a successful body read.
effort/xs.Documented and correct: 17 of the 18
errchecksite(s) why it is not a defect azure/recommendations.go:220,gcp/recommendations.go:161,322—_ = g.Wait()Deliberate, with a comment: every goroutine returns nil, per-service errors are captured separately, and ctx.Err()is checked on the very next line so cancellation is not swallowed. Verified in all three.pkg/concurrency/concurrency.go:61SharedSemaphoredocuments "or nil if none";Acquire/Releasenil-guard explicitly. The, _is the contract.aws/recommendations/converters.go:40,82,110convertPaymentOptionis already marked Deprecated with the silent-empty behaviour spelled out, fails closed at Cost Explorer (which rejects""), retained only for a legacy path, tracked to LeanerCloud/cloud-commitments-cli#865/#1075.pkg/config/load.go:345,349,353,357cobra flags.GetBool/GetStringerror only when the flag is undefined — a build-time mistake, not runtime.--yesdefaulting to false is the safe direction.azure/internal/pricing/retail_prices.go:110,azure/services/compute/client.go:372,406body feeds a JSON decode that fails on truncation, or an error message. Fail-closed. azure/mocks/azure_mocks.go:134test-support package; every importer of providers/azure/mocksis a_test.gofile (verified).purchase.go:287(doCalculatePrice),:390(fetchReservationOrdersPage)Truncation makes json.Unmarshalfail, so both return errors.:390backs the idempotency lookup, and its callerDoIdempotentPurchaseTwoStepexplicitly refuses to purchase on a lookup error to avoid a double-buy — there is no fall-through.errorlint: 8, none reachable as a defect- 6×
err == iterator.Done(computeengine×3,cloudsql,cloudstorage,memorystore) plusgcp/provider.go:430. Google's iterator contract returnsiterator.Doneunwrapped. Were it ever wrapped, the followingif err != nilreturns an error — the loop fails loudly rather than truncating silently, which is the direction that matters. gcp/recommendations.go:431isGoogleAPIError— errorlint seeserr.(*googleapi.Error)but the function already hand-rolls recursive unwrapping below it, so the stated premise is false forUnwrap() errorchains. One narrow real gap: it does not handleUnwrap() []error(errors.Join), whicherrors.Aswould. It feedsisPermissionError, which decides Warn-vs-ERROR logging (fix(frontend): the approval modal falls back to a no-amount body and still offers Approve #247) — observability, not money.
staticcheck10,gosec1,unused12SA4004×3 (elasticache,memorydb,rdsclients) — I checked rather than assuming, because "loop unconditionally terminated" can mean pagination that only ever reads page 1 on an offering-selection path. It does not:scanRDSOfferingPageintentionally takes the first offering from an already-payment-option-filtered page, and returns an error if that first one does not match the requested type. Style finding on deliberate take-first logic that fails closed.SA1019 config.Profile is deprecated×3 (one per provider) — the repo's own deprecation, with typed replacements named in the message. A real migration, self-tracking.gosec G117onec2/client.go:982ClientToken— matches a secret-name pattern.ClientTokenis the EC2 idempotency key, not a credential.unused12 — deadreal*Service/real*BucketHandletypes ingcp/cloudstorageandgcp/memorystore(10 of the 12), plus an unused const and field. Straight deletions, but they are the production implementations behind test doubles, so confirm nothing wires them up dynamically before removing.
Sizing
bucket count effort purchase.go:475 retry-decision fix 1 xserrorlint→errors.Is/errors.As(mechanical, low risk, mild robustness gain)8 xsunuseddeletions12 sSA1019 config.Profilemigration to typed fields3 s, needs careDeliberate discards — //nolint:errcheckwith the existing rationale, or leave and exclude17 xs–sSA4004,ST1005,QF1008,G1177 xsRecommendation: fix
purchase.go:475on its own, now. It is the only one with a behavioural consequence, it isxs, and it is on the purchase path. Everything else can ride with the lint-gate work and does not need to precede it.The 17 deliberate discards deserve a decision rather than edits: they are correct code being reported because the config is strict. Annotating each with
//nolint:errcheckplus the reason already in the surrounding comment makes that visible; the alternative is relaxingcheck-blank, which would also hide genuinely unchecked calls and should not be done.Method notes
- Prod-vs-test split by file path;
providers/azure/mocksadditionally verified to have no non-test importer. - Every claim above was read in source, not inferred from the linter name — the correction earlier in this issue came from doing exactly that inference.
- Any before/after comparison when this is fixed must be a
commset diff on normalized findings, not totals; andgolangci-lintmust show exit 0 and a non-empty findings block before being called clean.
- 6×
The
lintjob in.github/workflows/ci.ymlrunsgolangci-lintandgo vetfrom the repository root. Undergo.workthat covers the root module only, so the other five workspace modules are unlinted — and they are red.This is the same defect as LeanerCloud/cloud-commitments-cli#1751 (test jobs root-only), in the same file, one job over. LeanerCloud/cloud-commitments-cli#1751 fixes the test steps; this issue is deliberately kept separate so a lint-debt excavation does not bury a CI-gating fix, and vice versa.
Measured
golangci-lint runat the CI-pinned v2.10.1, per module, counts from golangci's own summary:.(root, the only one CI lints)pkgproviders/awsproviders/azureproviders/gcptests/e2etests/e2eexits 5 withcontext loading failed: no go files to analyze— every file in it is behind//go:build e2e, so it needs--build-tags=e2eto be linted at all. Same root cause as thego testhalf of LeanerCloud/cloud-commitments-cli#1751.Not all of it is cosmetic — but less of it is serious than first stated
Split by production versus test code, which is the split that decides urgency:
errcheckunusedstaticcheckerrorlintgosecbodycloseSo the set actually worth triaging is 49 production findings —
errcheck18 (unchecked errors, spread across all four modules),unused12,staticcheck10,errorlint8 (providers/gcp),gosec1 (providers/aws). Everything else isgodot(502),misspell(201),gocritic(80) and the test-onlybodycloseblock.Suggested approach
Mirror the per-module loop already used by
govulncheck(ci.yml:312), the gosec SARIF scan (:333), and — once LeanerCloud/cloud-commitments-cli#1751 lands — the test steps. But do not turn the gate on and fix 1291 findings in one PR. Suggested split:errcheck,unused,staticcheck,errorlint,gosec) — these are the ones that can represent real defects, and the set is small enough to review individually.bodyclosefindings separately — LeanerCloud/cloud-commitments-cli#1759. Volume, not risk.godot,misspell,gocritic) separately, ideally with--fix, in its own PR. Note the standing caution that autofix must not rewrite SQL identifiers or test-asserted strings.Reversing that order means either a long-red
mainor a suppressed gate, and suppressing is not on the table.Verification
Same shape as LeanerCloud/cloud-commitments-cli#1751: after the loop is added, break a lint rule deliberately in
providers/azureand confirm CI goes red. Before the change it will not.Related
providers/aws, a module CI neither tested nor linted.providers/awsfindings — 272 with and without it, identical per-linter breakdown. All 272 predate it.