Add golangci-lint and fix the two real bugs it found - #2
Merged
Conversation
Enables the standard set (errcheck, govet, ineffassign, staticcheck, unused) plus five chosen because they catch classes of bug this codebase can actually produce: rowserrcheck and sqlclosecheck because it runs raw SQL and a partially-iterated result set looks identical to a complete one; errorlint because control flow leans on errors.Is and a stray `==` would silently unfence a run; bodyclose for the HTTP clients; misspell because user-facing strings are a product surface here. Style linters are deliberately absent. A linter that mostly fires on taste gets suppressed within a week, and that teaches people to reach for //nolint reflexively — so nolintlint is on and demands a reason. The version is pinned and CI installs the same one via `just install-lint`, so a lint failure on a PR always reproduces locally. Formatting stays with gofmt in the justfile rather than moving into golangci-lint's formatters: two tools rewriting the same files is a way to conflict with yourself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Two real bugs the new linter surfaced. Export finalised the zip from a deferred Close and ignored both that error and every read error mid-stream. Because zip.Writer.Close writes the central directory, an abandoned export still produced a *valid* file — just missing pages. A user would open a plausible, incomplete copy of their site and never learn otherwise, which is precisely the failure AC-15 exists to prevent. The response is already committed by then, so this cannot become an HTTP error; it now logs and leaves the stream torn rather than tidily wrong. DB.Close discarded the read pool's error while returning the writer's, so a failed close reported success. Both are joined now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Errcheck found 51 unchecked returns. Three kinds, handled differently. Silent holes now log: a failed session revoke leaves someone signed in who believes they signed out; a failing recovery scan quietly stops rescuing abandoned runs; a failed Complete leaves a run stuck until its lease expires; a cut-short file serve means the content store is damaged and a visitor got half a page. Genuinely unactionable ones are now `_ =` at the call site with a reason where it isn't obvious — so "I meant to ignore this" is visible in review instead of indistinguishable from an oversight. Conventional no-ops (deferred Close, fmt.Fprint to a terminal, fs.Parse under ExitOnError) are excluded in config rather than annotated 50 times. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Replaces the hand-rolled `just install-lint` recipe. Locally, macOS gets the toolchain from `brew bundle`; in CI, golangci-lint-action installs and caches the pinned binary. install-only is the key flag: the action installs but does not run, so `just check-ci` remains what actually lints. CI has to exercise the same recipe a developer does, or the two drift and CI stops predicting local results. The Brewfile pins node@24 rather than tracking `node`, which is now 26 — building the client on a different major than CI is a divergence you find out about from a red build. It deliberately omits vite-plus and vitest: those are pinned in web/package.json, and pinning a dependency in two places is a way to have two answers to the same question. One honest gap noted in both files: Homebrew tracks the latest golangci-lint while CI pins v2.12.2, so a new release can fail CI on code that linted clean locally. The Brewfile says how to match the pin when that happens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Exporting a project could hand someone a zip that opens fine and is quietly missing pages.
zip.Writer.Closewrites the central directory, so an export abandoned midway still produced a valid archive — just an incomplete copy of their site, with nothing to indicate it. That is the exact failure AC-15 exists to prevent: the export is the anti-lock-in guarantee, and one that silently drops files is worse than one that fails.DB.Closehad a smaller version of the same shape, returning the writer pool's error while discarding the reader's — so a failed close reported success. Neither was going to be found by reading the code; both were found in the first minute of running a linter the repo didn't have.Solution
Add golangci-lint to
just checkandjust check-ci, then fix everything it found. Linter selection is the interesting part: the standard set plus five chosen because each catches a class of bug this codebase can produce.rowserrcheck,sqlclosecheckrows.Err()is checked.errorlinterrors.Is(ErrStaleLease,ErrNotWaiting,ErrBudgetExceeded). A stray==or%vinstead of%wwould silently unfence a run.bodyclosemisspellnolintlintStyle linters are deliberately absent. One that mostly fires on taste gets suppressed within a week, and that teaches people to reach for
//nolintreflexively — which is how a linter becomes worse than none.The 51 findings split three ways, and the split is the point:
Completeleaves a run stuck until its lease expires. A cut-short file serve means the content store is damaged and a visitor got half a page._ =at the call site with a reason where it isn't obvious, so "I meant to ignore this" is visible in review instead of indistinguishable from an oversight.Conventional no-ops (deferred
Close,fmt.Fprintto a terminal,fs.ParseunderExitOnError) are excluded in config rather than annotated fifty times.No visual change — this is Go-side tooling and error handling only.
Other Changes
brew bundleon macOS,golangci-lint-actionin CI withinstall-only: true. That flag matters — the action installs and caches the binary but does not run it, sojust check-ciremains what actually lints. CI has to exercise the same recipe a developer does, or it stops predicting local results.just, Node 24,gh). It pinsnode@24rather than trackingnode, which is now 26 — building the client on a different major than CI is a divergence you find out about from a red build. It omits vite-plus and vitest deliberately: those are pinned inweb/package.json, and pinning a dependency in two places is a way to have two answers to the same question.gofmtin the justfile rather than moving into golangci-lint'sformatterssection — two tools rewriting the same files is a way to conflict with yourself.Related:
🤖 Generated with Claude Code