chore(tooling): golangci-lint + govulncheck + defensive guards

Phase 6 of the 2026-05-09 review remediation plan. Bundle of small
hygiene fixes — none individually urgent but better folded together
than scattered across follow-ups.

- .golangci.yml: enable errcheck/govet/gosec/staticcheck/unused/
  ineffassign/gocyclo/misspell/revive. Tuned to the codebase style
  (no universal exported-doc requirement, gocyclo cap at 20 to
  accommodate handler dispatch). 0 issues across the tree.
- ci.yml: add golangci-lint job + govulncheck (informational).
- Defensive guards:
  - registry.go: Module.Name mismatch now errors at Build instead of
    silently overwriting (TestBuild_RejectsFactoryNameMismatch).
  - cmd/server/main.go: PORT env validated numerically + 0..65535.
  - firestore_provider.go: For() re-validates module name; invalid
    names return an invalidStore whose every op errors with
    ErrInvalidModuleName.
- Dead code removal:
  - wordle: gameTTLSeconds const + pickDaily/hashDJB2/todayUTC
    helpers + their tests deleted (pickDaily was unused;
    daily.go renamed pick_random.go).
- Dependency: golang.org/x/net v0.52.0 -> v0.54.0 (resolves
  GO-2026-4918 HTTP/2 infinite-loop CVE).
- Deferred from the original phase plan: Docker digest pinning
  (Dependabot handles), per-handler file splits (largest file 279 LOC;
  splits would churn for marginal gain).

go test -race -count=1 ./... clean (15 packages); golangci-lint run
clean (0 issues).
This commit is contained in:
tiennm99 committed 2026-05-09 16:33:21 +07:00
1 parent 765cce549d
commit bdbcc43f85
19 files changed
+279 -136

No files matched your search

+3 -3
View File
@@ -3,7 +3,7 @@ package loldle
import "testing"
func TestAttemptFlavor(t *testing.T) {
const max = 8
const maxAttempts = 8
cases := map[int]string{
1: "First try!",
2: "Sharp!",
@@ -16,8 +16,8 @@ func TestAttemptFlavor(t *testing.T) {
9: "Phew — last one!", // attempt > max — defensive; matches JS >=
}
for attempt, want := range cases {
if got := attemptFlavor(attempt, max); got != want {
t.Errorf("attemptFlavor(%d, %d) = %q, want %q", attempt, max, got, want)
if got := attemptFlavor(attempt, maxAttempts); got != want {
t.Errorf("attemptFlavor(%d, %d) = %q, want %q", attempt, maxAttempts, got, want)
}
}
}
+8 -1
View File
@@ -116,7 +116,14 @@ func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider
Registry: reg,
}
mod := factory(moduleDeps)
mod.Name = name // enforce: module name is its registry key, not whatever the factory chose
// A factory that hardcodes its own Name is a bug: the registry key is
// the source of truth and a mismatch means the catalog and module
// disagree about identity. Surface the conflict rather than silently
// overwriting it.
if mod.Name != "" && mod.Name != name {
return nil, fmt.Errorf("module %q: factory returned mismatched Name=%q", name, mod.Name)
}
mod.Name = name
for _, cmd := range mod.Commands {
if err := validateCommand(cmd); err != nil {
+34
View File
@@ -223,6 +223,40 @@ func TestBuild_RejectsInvalidModuleName(t *testing.T) {
}
}
func TestBuild_RejectsFactoryNameMismatch(t *testing.T) {
// A factory that hardcodes its own name disagreeing with the registry key
// is a programming bug — surface it instead of silently overwriting.
factories := map[string]Factory{
"alpha": func(_ Deps) Module {
return Module{Name: "imposter", Commands: []Command{noopCmd("a1")}}
},
}
_, err := Build([]string{"alpha"}, factories, newProvider(), nil)
if err == nil {
t.Fatal("expected error for factory Name mismatch")
}
if !strings.Contains(err.Error(), "imposter") {
t.Errorf("error should mention mismatched name: %v", err)
}
}
func TestBuild_AllowsFactoryWithBlankName(t *testing.T) {
// Factory leaves Name blank; registry fills it from the key. Common,
// non-buggy pattern.
factories := map[string]Factory{
"alpha": func(_ Deps) Module {
return Module{Commands: []Command{noopCmd("a1")}}
},
}
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil)
if err != nil {
t.Fatalf("Build: %v", err)
}
if reg.Modules[0].Name != "alpha" {
t.Errorf("blank-name factory: registered Name = %q, want 'alpha'", reg.Modules[0].Name)
}
}
func TestBuild_AcceptsHyphenatedModuleName(t *testing.T) {
factories := map[string]Factory{
"loldle-emoji": factory("loldle-emoji", []Command{noopCmd("emoji_cmd")}, nil),
-59
View File
@@ -1,59 +0,0 @@
package wordle
import (
"errors"
"math/rand"
"time"
)
// errEmptyWordList is returned by pickers when the dictionary is empty —
// callers (Init) should fail fast rather than spin a game with no answers.
var errEmptyWordList = errors.New("wordle: word list is empty")
// todayUTC returns the current date as YYYY-MM-DD in UTC. Mirrors JS
// `new Date().toISOString().slice(0, 10)`.
func todayUTC(now time.Time) string {
return now.UTC().Format("2006-01-02")
}
// hashDJB2 is the same djb2 variant the JS source uses, with an explicit
// 32-bit mask at the end.
func hashDJB2(s string) uint32 {
h := uint32(5381)
for i := 0; i < len(s); i++ {
h = (h * 33) ^ uint32(s[i])
}
return h
}
// pickDaily returns a deterministic pick keyed by a string seed (defaulting
// to today's UTC date). Same word for everyone on the same UTC day.
//
// Currently unused by handlers (which use pickRandom for variety per round)
// but kept for parity with the JS source — Phase 5c may switch to a daily.
func pickDaily(words []string, seed string) (string, error) {
if len(words) == 0 {
return "", errEmptyWordList
}
if seed == "" {
seed = todayUTC(time.Now())
}
idx := int(hashDJB2(seed) % uint32(len(words)))
return words[idx], nil
}
// pickRandom is the picker handlers actually use today. Uniform random pick.
// rng allows tests to inject a deterministic source. When rng is nil we fall
// through to math/rand's package-level Intn, which IS goroutine-safe via an
// internal mutex on the global Source — important because the bot dispatcher
// runs each Telegram update in its own goroutine and concurrent /wordle_new
// calls would otherwise race on a shared *rand.Rand.
func pickRandom(words []string, rng *rand.Rand) (string, error) {
if len(words) == 0 {
return "", errEmptyWordList
}
if rng != nil {
return words[rng.Intn(len(words))], nil
}
return words[rand.Intn(len(words))], nil
}
+26
View File
@@ -0,0 +1,26 @@
package wordle
import (
"errors"
"math/rand"
)
// errEmptyWordList is returned by pickers when the dictionary is empty —
// callers (Init) should fail fast rather than spin a game with no answers.
var errEmptyWordList = errors.New("wordle: word list is empty")
// pickRandom is the picker handlers actually use today. Uniform random pick.
// rng allows tests to inject a deterministic source. When rng is nil we fall
// through to math/rand's package-level Intn, which IS goroutine-safe via an
// internal mutex on the global Source — important because the bot dispatcher
// runs each Telegram update in its own goroutine and concurrent /wordle_new
// calls would otherwise race on a shared *rand.Rand.
func pickRandom(words []string, rng *rand.Rand) (string, error) {
if len(words) == 0 {
return "", errEmptyWordList
}
if rng != nil {
return words[rng.Intn(len(words))], nil
}
return words[rand.Intn(len(words))], nil
}
@@ -4,51 +4,8 @@ import (
"math/rand"
"sync"
"testing"
"time"
)
func TestTodayUTC_FormatStable(t *testing.T) {
now := time.Date(2026, 5, 9, 9, 30, 0, 0, time.UTC)
if got := todayUTC(now); got != "2026-05-09" {
t.Errorf("todayUTC = %s, want 2026-05-09", got)
}
}
func TestPickDaily_DeterministicForSameSeed(t *testing.T) {
words := []string{"alpha", "bravo", "delta", "gamma"}
a, err := pickDaily(words, "2026-05-09")
if err != nil {
t.Fatal(err)
}
b, err := pickDaily(words, "2026-05-09")
if err != nil {
t.Fatal(err)
}
if a != b {
t.Errorf("daily picks differ: %s vs %s", a, b)
}
}
func TestPickDaily_DifferentSeedsDiffer(t *testing.T) {
// Not strictly required by spec — but a useful smoke that the hash isn't
// degenerate. Use a long word list so collisions are unlikely.
words := []string{
"alpha", "bravo", "delta", "gamma", "echo", "fox",
"hotel", "india", "juliet", "kilo", "lima", "mike",
}
a, _ := pickDaily(words, "2026-05-09")
b, _ := pickDaily(words, "2026-05-10")
if a == b {
t.Logf("warn: same daily for different seeds (acceptable but rare): %s", a)
}
}
func TestPickDaily_EmptyErrors(t *testing.T) {
if _, err := pickDaily(nil, "x"); err == nil {
t.Error("expected error for empty list")
}
}
func TestPickRandom_UsesInjectedRNG(t *testing.T) {
rng := rand.New(rand.NewSource(1))
words := []string{"a", "b", "c", "d", "e"}
+4 -7
View File
@@ -11,11 +11,9 @@ import (
// MaxGuesses is the standard wordle round length.
const MaxGuesses = 6
// gameTTLSeconds matches the JS source's KV TTL — informational only. Cloud
// Firestore has no native per-document TTL equivalent to Cloudflare KV; old
// games linger until manually cleaned. Document the deviation rather than
// scaffold a TTL cron we don't need today.
const gameTTLSeconds = 60 * 60 * 24 * 7
// Cloud Firestore has no native per-document TTL equivalent to Cloudflare KV
// — saved games linger until manually cleaned. Out of scope today; tracked
// in port plan as a future cron.
// GuessRecord is one entry in a game's history. JSON shape locks JS parity:
//
@@ -66,8 +64,7 @@ func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*GameSta
}
}
// saveGame writes the round. TTL is not honored on Firestore (see comment on
// gameTTLSeconds) — kept here as documentation of intent.
// saveGame writes the round.
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameState) error {
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
return fmt.Errorf("wordle saveGame: %w", err)