Skip to content

Add checkergen module for reflection-free code generation - #282

Merged
cinar merged 5 commits into
mainfrom
add-checkergen-module
Sep 6, 2026
Merged

Add checkergen module for reflection-free code generation#282
cinar merged 5 commits into
mainfrom
add-checkergen-module

Conversation

@cinar

@cinar cinar commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Summary

Implements #264 per the revised scoping comment: a code generator that turns checkers/validate struct tags into a reflection-free Check<Type>(v *Type) (checker.CheckErrors, bool) function, calling the same plain checker/normalizer functions (IsEmail, MinLen[string](8), IsEqField, ...) directly instead of walking the struct with reflect through CheckStruct.

  • New isolated checkergen/ module (own go.mod, taskfile.yml, replace ../), needs golang.org/x/tools (go/packages) for type info — same pattern as checkerlint
  • Self-contained per-checker call-spec mapping table lives in checkergen itself, not the core module — emitting Go source text is a different concern than reflecting into a Schema, so the core module's job stays unchanged
  • v1 scope: a field is only eligible if its Go type is exactly one of the predeclared scalar types — not a named/defined type, a pointer, or a nested struct/slice/map. A struct with an ineligible field or unmapped checker is skipped with a clear diagnostic, not generated incorrectly, and doesn't block generation for the rest of the package
  • A malformed tag parameter panics the same way the equivalent runtime maker does; Generate recovers this per-struct into a skip, mirroring the cli module's CheckWithConfig panic recovery (documented pattern in CLAUDE.md)

Two deliberate, documented divergences from CheckStruct

  • eq-field's generated call uses == (comparable), matching IsEq/IsGte's style, instead of the reflect path's reflect.DeepEqual — only differs for a slice/map/struct field, which isn't eligible for generation anyway
  • eq/ne/oneof generated calls support any comparable field type via the fully generic IsEq/IsNe/IsOneOf, even though CheckStruct's own reflect-based implementation only supports string fields today (checkEq/checkNe/checkOneOf hardcode reflectString) — a capability superset, not a behavior change for anything CheckStruct already validates correctly

Verification

  • Differential tests (testdata/fixture): for a battery of inputs, generated code and checker.CheckStruct are asserted to report errors under identical field keys and normalize values identically
  • Drift test: regenerates the committed fixture into a scratch copy and byte-compares it against the checked-in version, to catch the fixture falling out of sync with the generator
  • 96.7%/98.2% test coverage (package/overall)

Performance

Benchmarked against CheckStruct on the same struct/input: ~3x faster, 4-8x fewer allocations. Numbers in both checkergen/README.md and the root README's new "Code Generation" section.

Also fixes along the way

  • An unrelated gosec finding (G306) this surfaced: tightened checkergen's generated-file write permission to 0600
  • Added -exclude-dir=checkergen to the root taskfile.yml's gosec run proactively this time, plus a loud warning comment in CLAUDE.md about updating both places — learned from the nethttp/fiber miss that broke main's CI after Add a Fiber adapter module #276

Closes #264

Test plan

  • cd checkergen && go test -cover . — 96.7% coverage
  • cd checkergen && go vet ./..., gosec, staticcheck, revive — all clean
  • Root go test -cover ./... — 100%, unaffected
  • Root gosec run (with the updated exclude list) verified clean from the actual repo root, run multiple times to rule out flakiness
  • Benchmarks run 3x for stability — consistent ~3x speedup, 4-8x fewer allocations

🤖 Generated with Claude Code

https://claude.ai/code/session_01ARLam6G4Bu9afnWHpmJWeo

Adds checkergen/, a new isolated module (own go.mod, taskfile.yml,
replace ../, needs golang.org/x/tools for go/packages type info,
same pattern as checkerlint) that generates a
Check<Type>(v *Type) (checker.CheckErrors, bool) function per
eligible struct from its checkers/validate tags -- calling the same
plain checker/normalizer functions (IsEmail, MinLen[string](8), ...)
directly via checker.Check, instead of walking the struct with
reflect at runtime through CheckStruct.

Design, per the scoping discussion on #264:
- A self-contained per-checker call-spec mapping table lives in
  checkergen itself (callspec.go), not the core module -- emitting Go
  source text is a different kind of concern than reflecting into a
  Schema, so this keeps the core module's job unchanged.
- v1 scope: a field is only eligible if its Go type is exactly one of
  the predeclared scalar types (string, bool, int/uint/float kinds) --
  not a named/defined type, a pointer, or a nested struct/slice/map.
  A struct with an ineligible field or unmapped checker is skipped
  with a clear diagnostic, not generated incorrectly, and doesn't
  block generation for the rest of the package.
- A malformed tag parameter (e.g. after-field missing its ":field"
  half) panics the same way the equivalent runtime maker does;
  Generate recovers this per-struct into a skip, mirroring the cli
  module's CheckWithConfig panic recovery (see CLAUDE.md).
- eq-field's generated call uses == (comparable), matching
  IsEq/IsGte's style, instead of the reflect-based eq-field tag path's
  reflect.DeepEqual -- documented as a deliberate, practically
  inconsequential divergence (only differs for a slice/map/struct
  field, which isn't eligible for generation anyway).
- eq/ne/oneof generated calls support any comparable field type via
  the fully generic IsEq/IsNe/IsOneOf, even though CheckStruct's own
  reflect-based eq/ne/oneof implementation only supports string
  fields today (checkEq/checkNe/checkOneof hardcode reflectString) --
  a capability superset, not a behavior change for anything CheckStruct
  already validates.

Verified with a differential test suite (testdata/fixture): for a
battery of inputs, the generated code and checker.CheckStruct are
asserted to report errors under identical field keys and normalize
values identically. A separate drift test regenerates the committed
fixture into a scratch copy and byte-compares it against the checked
-in version, to catch the fixture falling out of sync with the
generator itself.

Benchmarked (checkergen/benchmark_test.go) against CheckStruct on the
same struct/input: ~3x faster with 4-8x fewer allocations. Numbers
included in both this module's README and the root README's new
"Code Generation" section.

Also fixes an unrelated gosec finding this surfaced (G306, WriteFile
permissions) by tightening checkergen's own generated-file write to
0600, and adds a proactive -exclude-dir=checkergen entry to the root
taskfile.yml's gosec run -- learned from the nethttp/fiber miss that
broke main's CI after #276.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLam6G4Bu9afnWHpmJWeo
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (c127c4b) to head (7fd2654).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #282   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           86        86           
  Lines         1807      1807           
=========================================
  Hits          1807      1807           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

cinar and others added 4 commits September 6, 2026 15:42
checkergen/README.md: new "What Building This Found" section calling
out the three real issues the differential-test harness surfaced
while building the generator -- a wrong field name in eq-field's
generated error data, an unrecovered panic that could take down
generation for a whole package on one malformed tag, and the
pre-existing eq/ne/oneof string-only limitation in the core module's
reflect-based checkers (not a checkergen bug, but found via it).

articles/reflection-free-go-validation-with-checkergen.md: a
dev.to-formatted draft (published: false, matching the existing
draft convention in this directory) walking through the same three
findings in narrative form, plus the benchmark numbers and usage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLam6G4Bu9afnWHpmJWeo
Drops the "I built this, it found bugs in my own code" framing.
Leads with the reflection cost CheckStruct pays, the benchmark
numbers, and a worked example swapping checker.CheckStruct for
generated code inside a Gin handler, with the same swap spelled out
for Echo, Fiber, and net/http -- tying checkergen directly to the
existing adapter modules instead of presenting it in isolation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLam6G4Bu9afnWHpmJWeo
@cinar
cinar merged commit ae20ebb into main Sep 6, 2026
14 checks passed
@cinar
cinar deleted the add-checkergen-module branch September 6, 2026 16:13
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.

Code Generation Alternative (go generate)

1 participant