Skip to content

Add golangci-lint and fix the two real bugs it found - #2

Merged
korya merged 4 commits into
mainfrom
dmitri-chore-golangci-lint
Aug 6, 2026
Merged

Add golangci-lint and fix the two real bugs it found#2
korya merged 4 commits into
mainfrom
dmitri-chore-golangci-lint

Conversation

@korya

@korya korya commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Problem

Exporting a project could hand someone a zip that opens fine and is quietly missing pages. zip.Writer.Close writes 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.Close had 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 check and just 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.

Linter Why this repo
rowserrcheck, sqlclosecheck Raw SQL throughout. A result set whose iteration stopped early is indistinguishable from a complete one unless rows.Err() is checked.
errorlint Control flow leans on errors.Is (ErrStaleLease, ErrNotWaiting, ErrBudgetExceeded). A stray == or %v instead of %w would silently unfence a run.
bodyclose HTTP clients in the CLI, the model adapters, and the e2e harness.
misspell User-facing strings are a product surface here (R-AGT-2), so a typo is a product bug.
nolintlint Any suppression must state a reason and name a linter.

Style linters are deliberately absent. One that mostly fires on taste gets suppressed within a week, and that teaches people to reach for //nolint reflexively — which is how a linter becomes worse than none.

The 51 findings split three ways, and the split is the point:

  • Real bugs (the two above) — fixed.
  • Silent holes — now logged. 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 — 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 fifty times.

No visual change — this is Go-side tooling and error handling only.

Other Changes

  • Installation: brew bundle on macOS, golangci-lint-action in CI with install-only: true. That flag matters — the action installs and caches the binary but does not run it, so just check-ci remains what actually lints. CI has to exercise the same recipe a developer does, or it stops predicting local results.
  • A Brewfile for the rest of the toolchain (Go, just, Node 24, gh). It 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 omits vite-plus and vitest deliberately: 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: CI pins golangci-lint v2.12.2 while Homebrew tracks latest, so a new release can fail CI on code that linted clean locally. The Brewfile says how to match the pin when that happens.
  • Formatting deliberately stays with gofmt in the justfile rather than moving into golangci-lint's formatters section — two tools rewriting the same files is a way to conflict with yourself.

Related:

🤖 Generated with Claude Code

korya and others added 4 commits August 6, 2026 12:48
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
@korya
korya marked this pull request as ready for review August 6, 2026 18:09
@korya
korya merged commit edb39e7 into main Aug 6, 2026
1 check passed
@korya
korya deleted the dmitri-chore-golangci-lint branch August 6, 2026 23:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant