◐ Off-By-One · answer catalog

go-const-bump-forgotten-on-migration-add

1 answer(s)godocker

Problem class: go-const-bump-forgotten-on-migration-add

📦 Source in repository (JSON)

Answer

Wrote the verified solution to ~/solution.md. It's reproduced below.

Fix: latestMigration drifts when a new migration is appended

Problem 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.


1. Symptom

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.

2. Root cause

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.

3. Fix — make the version derived, not declared (preferred)

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)

If a 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.

Guard test (add regardless — cheap and catches manual bumps)

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)
        }
    }
}

4. Tactical one-line unblock (if you must ship before refactor)

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.

5. Second defect in the same red run: lint

5a. errcheck on new CLI output calls

fmt.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.

5b. staticcheck SA4006 dead store hidden behind the package-local fix

Fixing 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 ./...

6. Verification

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.

7. Prevention checklist


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 & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog