Problem class: go-const-bump-forgotten-on-migration-add
Wrote the verified solution to ~/solution.md. It's reproduced below.
latestMigration drifts when a new migration is appendedProblem class: go-const-bump-forgotten-on-migration-add
Observed on: coding-hermes-scheduler — V59 + V60 landed (bc1805f7, e1dfe353) while the sibling const stayed at 58; 12 CI tests red until INT-CI-174 bumped it. Same red run also hit errcheck on new CLI code and a hidden staticcheck SA4006 dead store.
CI fails with a message of the form:
--- FAIL: TestMigrationVersion (0.00s)
version = 60, want 58
or the inverse (version = 58, want 60), across every test that asserts MigrationVersion == latestMigration. The migration list is correct; the exported version is stale.
Two independent sources of truth are maintained by hand in the same package:
const latestMigration = 58 // <-- hand-maintained
var migrations = []migration{
{Version: 58, ...},
{Version: 59, ...},
{Version: 60, ...}, // <-- appended, const not touched
}
const MigrationVersion = latestMigration
A migration file added under migrations/ or a new entry appended to the slice has no compiler-enforced link to the constant. The author (and reviewer) must remember to edit a different line in a different file in the same commit. That is a missing-invariant bug: the code compiles, go vet is clean, and only the assertion tests catch it. The INT-CI-174 fix was a one-line bump, i.e. the drift will recur with V61 unless the invariant is made structural.
Delete the hand-maintained numeric constant. Compute the version from the slice. A computed value cannot be a Go const, so export it as a var (or a function). Comparisons in tests/callers are unaffected.
package migrations
import "slices"
type migration struct {
Version int
Name string
}
// Single source of truth. Do not add a sibling "latest" literal.
var migrations = []migration{
{Version: 58, Name: "drop_legacy_columns"},
{Version: 59, Name: "add_tenant_index"},
{Version: 60, Name: "add_schedule_retries"},
}
func maxVersion(ms []migration) int {
return slices.MaxFunc(ms, func(a, b migration) int { return a.Version - b.Version }).Version
}
// Derived: appending V{N} automatically moves the version. Cannot drift.
var latestMigration = maxVersion(migrations)
// Exported value callers/tests compare against.
var MigrationVersion = latestMigration
// Optional: function form for callers that prefer it.
func LatestMigration() int { return maxVersion(migrations) }
If the slice is []int of versions, use slices.Max(versions) (Go 1.21+):
var latestMigration = slices.Max(versions)
const is genuinely required (compile-time use)Keep a const but make drift a build/test failure and, ideally, generate it:
//go:generate go run ./internal/gen-migration-version
const latestMigration = 60 // generated by `go generate ./...`
Commit the generator + a guard test (below). Prefer the derived form unless a constant is truly required.
package migrations
import (
"slices"
"testing"
)
func TestLatestMigrationMatchesSlice(t *testing.T) {
want := slices.MaxFunc(migrations, func(a, b migration) int {
return a.Version - b.Version
}).Version
if latestMigration != want {
t.Fatalf("latestMigration=%d, want max over slice=%d", latestMigration, want)
}
if MigrationVersion != want {
t.Fatalf("MigrationVersion=%d, want max over slice=%d", MigrationVersion, want)
}
}
func TestVersionsStrictlyIncreasing(t *testing.T) {
for i := 1; i < len(migrations); i++ {
if migrations[i].Version <= migrations[i-1].Version {
t.Fatalf("versions not strictly increasing at index %d: %d <= %d",
i, migrations[i].Version, migrations[i-1].Version)
}
}
}
Bump the const in the same commit as the migration and add the guard test:
const latestMigration = 60 // keep in sync with migrations slice
Detection recipe to find every stale site before pushing:
# Highest version literal that appears in migration definitions
grep -rhoE 'Version:[[:space:]]*[0-9]+' --include='*.go' . \
| grep -oE '[0-9]+' | sort -n | tail -1
# The hand-maintained constant(s)
grep -rnE '(latestMigration|MigrationVersion)' --include='*.go' . \
| grep -E '=[[:space:]]*[0-9]+'
Compare the two numbers; any mismatch is the bug. Do this in the same PR as the migration add — do not leave it for CI.
errcheck on new CLI output callsfmt.Fprintf returns (int, error). Assign to the blank identifier explicitly:
// before: errcheck flags the ignored error
fmt.Fprintf(os.Stderr, "usage: %s\n", os.Args[0])
// after
_, _ = fmt.Fprintf(os.Stderr, "usage: %s\n", os.Args[0])
For a deliberate discard where only the error matters, use _ =:
_ = cmd.Help()
Do not blanket-//nolint; discard explicitly so intent is visible.
staticcheck SA4006 dead store hidden behind the package-local fixFixing errcheck only in the changed file can mask an assignment whose value is never read. Example:
// SA4006: err is assigned but never read
err := fmt.Errorf("boom")
return nil
Fix by using it, removing the assignment, or discarding deliberately:
return fmt.Errorf("boom")
// or, if intentionally ignored:
_ = fmt.Errorf("boom")
Run lint repo-wide, not just on the diff, so the second defect surfaces:
golangci-lint run ./...
staticcheck ./...
go vet ./...
The fix was reproduced and validated in a scratch module (Go 1.26). Key runs:
Buggy pattern fails exactly like CI:
$ go test ./...
--- FAIL: TestMirrorsCI (0.00s)
bug_test.go:7: version = 58, want 60
FAIL
Derived fix passes, and appending V61 needs no manual bump:
$ go vet ./... && go test ./...
=== RUN TestLatestMigrationMatchesSlice
--- PASS: TestLatestMigrationMatchesSlice (0.00s)
=== RUN TestVersionsStrictlyIncreasing
--- PASS: TestVersionsStrictlyIncreasing (0.00s)
PASS
ok verify 0.002s
After appending {Version: 61, ...} to the slice with zero edits to any const, go test ./... still reported ok; the old const-based pattern would have required a manual bump and gone red again.
Run this on the real repo to verify the actual fix:
# 1. confirm version is now derived, not a stale literal
grep -rnE 'latestMigration|MigrationVersion' --include='*.go' .
# 2. append a throwaway V{N+1} migration entry and run only the version tests;
# they must still pass with no constant edit. Then revert the throwaway.
# 3. full local gate, repo-wide
go build ./... && go test ./... && go vet ./...
golangci-lint run ./...
staticcheck ./...
Expected: build clean, all MigrationVersion tests green, lint clean, and the version matches the max literal in the migrations slice.
slices.Max / slices.MaxFunc.const is unavoidable, generate it via go:generate and commit a guard test that asserts version == max(slice).TestVersionsStrictlyIncreasing so duplicate/regressed versions fail immediately.golangci-lint run ./..., staticcheck ./...) in the same commit.errcheck: explicitly discard with _, _ = / _ =; never silently drop.Note: the working directory ~ contained no repository, so I reproduced the failure mode and validated the fix in an isolated Go 1.26 scratch module (buggy pattern → version = 58, want 60; derived pattern → green, including a no-edit V61 append). The grep/lint commands above are ready to run against the actual coding-hermes-scheduler checkout.
# Evidence - Problem class: go-const-bump-forgotten-on-migration-add - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-10-02T14:43:29.292Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Repo pattern: adding migration V{N} to a migrations slice while a sibling const latestMigration stays at the old value. Every test asserting MigrationVersion == latestMigration then fails on CI with version = N, want old. Fix: grep the const beside any new migration version and bump in the SAME commit; better, derive latestMigration from max over the slice so it cannot drift. Seen on coding-hermes-scheduler: v59+v60 landed (bc1805f7, e1dfe353) leaving const at 58; 12 CI tests red until INT-CI-174 bumped it. Second defect in same red run: golangci errcheck on new CLI code - fix with _ = fmt.Fprintf(...) discards, and check repo-wide lint (staticcheck SA4006 dead store hid behind the package-local fix).", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-const-bump-forgotten-on-migration-add", "provider": "openrouter", "solved_at": "2026-10-02T14:43:29.293Z", "version": ""}