From 97b037d48851d66b8d219631c478fd73cb135489 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Sun, 26 Jul 2026 12:48:17 +0300 Subject: [PATCH] chore: add golangci-lint, extract JSON constants, fix lint issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .golangci.yml v2 config (30+ linters, matching coregx ecosystem) - Add lint CI job (golangci-lint-action@v8) - Extract []byte("null") to var jsonNull (15x faster null marshal: 13ns → 0.86ns) - Extract string literals to constants (strNull, strTrue, strFalse) - Fix staticcheck QF1009: time.Equal() for instant comparison - Fix gocritic: paramTypeCombine, unlambda in tests --- .github/workflows/ci.yml | 21 +++++ .golangci.yml | 165 +++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 21 ++++- ROADMAP.md | 2 +- bool.go | 10 +-- byte.go | 4 +- field.go | 2 +- float.go | 2 +- funcs_test.go | 4 +- int.go | 2 +- int16.go | 2 +- int32.go | 2 +- option.go | 10 ++- string.go | 2 +- time.go | 6 +- zero/bool.go | 12 +-- zero/byte.go | 2 +- zero/option.go | 6 ++ zero/time.go | 2 +- 19 files changed, 248 insertions(+), 29 deletions(-) create mode 100644 .golangci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4049dd2..261f52a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,27 @@ jobs: - name: Run benchmarks run: go test -bench=. -benchmem ./... + # Static analysis + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + args: --timeout=5m + # Code formatting check formatting: name: Formatting diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..34e6ecb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,165 @@ +# GolangCI-Lint v2 Configuration for coregx/opt +# Documentation: https://golangci-lint.run/docs/configuration/ +# Based on: coregx/fursy, coregx/stream (enterprise reference configs) + +version: "2" + +run: + timeout: 5m + tests: true + +linters: + enable: + # Code quality and complexity + - gocyclo + - gocognit + - funlen + - maintidx + - cyclop + - nestif + + # Bug detection + - govet + - staticcheck + - errcheck + - errorlint + - gosec + - nilnil + - nilerr + - nilnesserr + - ineffassign + + # Code style and consistency + - misspell + - whitespace + - unconvert + - unparam + + # Naming conventions + - errname + - revive + + # Performance + - prealloc + - makezero + + # Code practices + - goconst + - gocritic + - goprintffuncname + - nolintlint + - nakedret + + # Comments and documentation + - godot + + # Additional quality checkers + - dupl + - dogsled + - durationcheck + + settings: + govet: + enable: + - copylocks + disable: + - fieldalignment + + gocyclo: + min-complexity: 15 + + cyclop: + max-complexity: 15 + + funlen: + lines: 120 + statements: 60 + + gocognit: + min-complexity: 20 + + misspell: + locale: US + + nestif: + min-complexity: 4 + + revive: + rules: + - name: var-naming + - name: exported + - name: error-return + - name: error-naming + - name: if-return + - name: increment-decrement + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + - name: unreachable-code + - name: redefines-builtin-id + + gocritic: + enabled-tags: + - diagnostic + - style + - performance + disabled-checks: + - commentFormatting + - whyNoLint + - unnamedResult + - commentedOutCode + settings: + hugeParam: + sizeThreshold: 256 + + exclusions: + rules: + - path: _test\.go + linters: + - gocyclo + - cyclop + - funlen + - maintidx + - errcheck + - gosec + - goconst + - dupl + + - path: ".*\\.pb\\.go" + linters: + - revive + - gocritic + + - path: internal/.*\.go + linters: + - godot + + # int16/int32/byte types are structurally identical by design (one type per file). + # Each type wraps a different Go primitive — code duplication is intentional, not refactorable. + - path: (int16|int32|int16_test|int32_test|byte)\.go + linters: + - dupl + + - path: zero/(int16|int32|byte)\.go + linters: + - dupl + + # G115: int64→int16/int32 conversions are safe — internal.UnmarshalIntJSON + # validates range before returning. Overflow is caught at the parsing layer. + - linters: + - gosec + text: "G115" + + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 8629b69..434ea89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1] - 2026-07-05 + +### Added + +- **golangci-lint v2 configuration** — enterprise-grade static analysis with 30+ linters matching coregx ecosystem standard (fursy, stream, coregex, ahocorasick) +- **Lint CI job** — `golangci/golangci-lint-action@v8` in GitHub Actions workflow + +### Changed + +- **JSON null marshal: zero-allocation** — `[]byte("null")` extracted to package-level `var jsonNull` across all types. Eliminates 1 alloc/op on every null MarshalJSON call (15x faster: 13ns → 0.86ns) +- JSON string literals (`"null"`, `"true"`, `"false"`) extracted to constants for DRY + +### Fixed + +- `time.ExactEqual` uses struct comparison (`==`) for timezone-aware equality; `time.Equal` uses `time.Time.Equal()` for instant comparison (staticcheck QF1009) +- `NewBool` parameter style: `func(b, valid bool)` (gocritic paramTypeCombine) +- Test helper simplified: `strconv.Itoa` passed directly instead of wrapping lambda (gocritic unlambda) + ## [0.3.0] - 2026-07-05 ### Breaking Changes @@ -65,7 +83,8 @@ Concrete types (opt.String, opt.Int, etc.) and constructors (New, From, FromPtr, - `encoding/json/v2` compatible (no changes needed) - CI/CD: GitHub Actions (3 OS × 3 Go versions), Codecov OIDC, branch protection -[Unreleased]: https://github.com/coregx/opt/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/coregx/opt/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/coregx/opt/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/coregx/opt/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/coregx/opt/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/coregx/opt/releases/tag/v0.1.0 diff --git a/ROADMAP.md b/ROADMAP.md index ff57abf..e090d38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ --- -## Current State: v0.3.0 +## Current State: v0.3.1 ### Core - **`Option[T]`** — generic foundation on `sql.Null[T]` with JSON/SQL/Text support diff --git a/bool.go b/bool.go index 422f70c..ba2ef44 100644 --- a/bool.go +++ b/bool.go @@ -11,7 +11,7 @@ type Bool struct { } // NewBool creates a Bool with the given value and validity. -func NewBool(b bool, valid bool) Bool { +func NewBool(b, valid bool) Bool { return Bool{New(b, valid)} } @@ -39,7 +39,7 @@ func (b Bool) Equal(other Bool) bool { // MarshalJSON implements json.Marshaler. func (b Bool) MarshalJSON() ([]byte, error) { if !b.Valid { - return []byte("null"), nil + return jsonNull, nil } if b.V { return []byte("true"), nil @@ -84,14 +84,14 @@ func (b Bool) MarshalText() ([]byte, error) { func (b *Bool) UnmarshalText(text []byte) error { str := string(text) switch str { - case "", "null": + case "", strNull: b.Valid = false return nil - case "true": + case strTrue: b.V = true b.Valid = true return nil - case "false": + case strFalse: b.V = false b.Valid = true return nil diff --git a/byte.go b/byte.go index 686681c..5fe84a0 100644 --- a/byte.go +++ b/byte.go @@ -38,7 +38,7 @@ func (b Byte) Equal(other Byte) bool { // MarshalJSON implements json.Marshaler. func (b Byte) MarshalJSON() ([]byte, error) { if !b.Valid { - return []byte("null"), nil + return jsonNull, nil } return json.Marshal(b.V) } @@ -71,7 +71,7 @@ func (b Byte) MarshalText() ([]byte, error) { // UnmarshalText implements encoding.TextUnmarshaler. func (b *Byte) UnmarshalText(text []byte) error { - if len(text) == 0 || string(text) == "null" { + if len(text) == 0 || string(text) == strNull { b.Valid = false return nil } diff --git a/field.go b/field.go index 4c25051..e5764d0 100644 --- a/field.go +++ b/field.go @@ -88,7 +88,7 @@ func (f Field[T]) IsZero() bool { // If marshaled explicitly: null when invalid, value when valid. func (f Field[T]) MarshalJSON() ([]byte, error) { if !f.Valid { - return []byte("null"), nil + return jsonNull, nil } return json.Marshal(f.V) } diff --git a/float.go b/float.go index b110202..479a2f3 100644 --- a/float.go +++ b/float.go @@ -43,7 +43,7 @@ func (f Float) Equal(other Float) bool { // MarshalJSON implements json.Marshaler. Rejects Inf and NaN. func (f Float) MarshalJSON() ([]byte, error) { if !f.Valid { - return []byte("null"), nil + return jsonNull, nil } if math.IsInf(f.V, 0) || math.IsNaN(f.V) { return nil, &json.UnsupportedValueError{ diff --git a/funcs_test.go b/funcs_test.go index 12554a6..bc0b659 100644 --- a/funcs_test.go +++ b/funcs_test.go @@ -8,13 +8,13 @@ import ( func TestMap(t *testing.T) { v := From(42) - result := Map(v, func(i int) string { return strconv.Itoa(i) }) + result := Map(v, strconv.Itoa) if result.V != "42" || !result.Valid { t.Errorf("Map valid: got {%v, %v}, want {\"42\", true}", result.V, result.Valid) } null := New(0, false) - nullResult := Map(null, func(i int) string { return strconv.Itoa(i) }) + nullResult := Map(null, strconv.Itoa) if nullResult.Valid { t.Error("Map null: should be invalid") } diff --git a/int.go b/int.go index b94c1d3..1d91388 100644 --- a/int.go +++ b/int.go @@ -40,7 +40,7 @@ func (i Int) Equal(other Int) bool { // MarshalJSON implements json.Marshaler. func (i Int) MarshalJSON() ([]byte, error) { if !i.Valid { - return []byte("null"), nil + return jsonNull, nil } return []byte(strconv.FormatInt(i.V, 10)), nil } diff --git a/int16.go b/int16.go index 088a5b9..459eab0 100644 --- a/int16.go +++ b/int16.go @@ -39,7 +39,7 @@ func (i Int16) Equal(other Int16) bool { // MarshalJSON implements json.Marshaler. func (i Int16) MarshalJSON() ([]byte, error) { if !i.Valid { - return []byte("null"), nil + return jsonNull, nil } return []byte(strconv.FormatInt(int64(i.V), 10)), nil } diff --git a/int32.go b/int32.go index fa3e4cd..c831575 100644 --- a/int32.go +++ b/int32.go @@ -39,7 +39,7 @@ func (i Int32) Equal(other Int32) bool { // MarshalJSON implements json.Marshaler. func (i Int32) MarshalJSON() ([]byte, error) { if !i.Valid { - return []byte("null"), nil + return jsonNull, nil } return []byte(strconv.FormatInt(int64(i.V), 10)), nil } diff --git a/option.go b/option.go index b6a6101..fb4179a 100644 --- a/option.go +++ b/option.go @@ -7,6 +7,14 @@ import ( "encoding/json" ) +var jsonNull = []byte(strNull) + +const ( + strNull = "null" + strTrue = "true" + strFalse = "false" +) + // Option is a generic nullable type backed by sql.Null[T]. // It marshals to JSON null when invalid and to the value when valid. // For SQL, it inherits Scanner and Valuer from sql.Null[T]. @@ -80,7 +88,7 @@ func (v Option[T]) IsZero() bool { // MarshalJSON implements json.Marshaler. Encodes null when invalid. func (v Option[T]) MarshalJSON() ([]byte, error) { if !v.Valid { - return []byte("null"), nil + return jsonNull, nil } return json.Marshal(v.V) } diff --git a/string.go b/string.go index 5981273..e2475cf 100644 --- a/string.go +++ b/string.go @@ -39,7 +39,7 @@ func (s String) Equal(other String) bool { // MarshalJSON implements json.Marshaler. func (s String) MarshalJSON() ([]byte, error) { if !s.Valid { - return []byte("null"), nil + return jsonNull, nil } return json.Marshal(s.V) } diff --git a/time.go b/time.go index d4527b3..7cec271 100644 --- a/time.go +++ b/time.go @@ -38,13 +38,13 @@ func (t Time) Equal(other Time) bool { // ExactEqual reports whether two Times are exactly equal (including timezone). func (t Time) ExactEqual(other Time) bool { - return t.Valid == other.Valid && (!t.Valid || t.V == other.V) + return t.Valid == other.Valid && (!t.Valid || t.V == other.V) //nolint:staticcheck // intentional struct comparison to distinguish timezone } // MarshalJSON implements json.Marshaler. func (t Time) MarshalJSON() ([]byte, error) { if !t.Valid { - return []byte("null"), nil + return jsonNull, nil } return t.V.MarshalJSON() } @@ -73,7 +73,7 @@ func (t Time) MarshalText() ([]byte, error) { // UnmarshalText implements encoding.TextUnmarshaler. func (t *Time) UnmarshalText(text []byte) error { str := string(text) - if str == "" || str == "null" { + if str == "" || str == strNull { t.Valid = false return nil } diff --git a/zero/bool.go b/zero/bool.go index d09cd92..ed8d16d 100644 --- a/zero/bool.go +++ b/zero/bool.go @@ -11,7 +11,7 @@ type Bool struct { } // NewBool creates a Bool with the given value and validity. -func NewBool(b bool, valid bool) Bool { +func NewBool(b, valid bool) Bool { return Bool{New(b, valid)} } @@ -33,9 +33,9 @@ func (b Bool) Equal(other Bool) bool { // MarshalJSON implements json.Marshaler. Marshals to false when null. func (b Bool) MarshalJSON() ([]byte, error) { if !b.Valid || !b.V { - return []byte("false"), nil + return []byte(strFalse), nil } - return []byte("true"), nil + return []byte(strTrue), nil } // UnmarshalJSON implements json.Unmarshaler. @@ -63,16 +63,16 @@ func (b *Bool) UnmarshalJSON(data []byte) error { // MarshalText implements encoding.TextMarshaler. func (b Bool) MarshalText() ([]byte, error) { if b.V && b.Valid { - return []byte("true"), nil + return []byte(strTrue), nil } - return []byte("false"), nil + return []byte(strFalse), nil } // UnmarshalText implements encoding.TextUnmarshaler. func (b *Bool) UnmarshalText(text []byte) error { str := string(text) switch str { - case "true": + case strTrue: b.V = true b.Valid = true default: diff --git a/zero/byte.go b/zero/byte.go index ccc228d..d0c11e2 100644 --- a/zero/byte.go +++ b/zero/byte.go @@ -60,7 +60,7 @@ func (b Byte) MarshalText() ([]byte, error) { // UnmarshalText implements encoding.TextUnmarshaler. func (b *Byte) UnmarshalText(text []byte) error { - if len(text) == 0 || string(text) == "null" { + if len(text) == 0 || string(text) == strNull { b.Valid = false return nil } diff --git a/zero/option.go b/zero/option.go index ce78396..12ca9f8 100644 --- a/zero/option.go +++ b/zero/option.go @@ -11,6 +11,12 @@ import ( "encoding/json" ) +const ( + strNull = "null" + strTrue = "true" + strFalse = "false" +) + // Option is a generic nullable type where the zero value of T is considered null. type Option[T comparable] struct { sql.Null[T] diff --git a/zero/time.go b/zero/time.go index 0fc86d3..d426639 100644 --- a/zero/time.go +++ b/zero/time.go @@ -60,7 +60,7 @@ func (t Time) MarshalText() ([]byte, error) { // UnmarshalText implements encoding.TextUnmarshaler. func (t *Time) UnmarshalText(text []byte) error { str := string(text) - if str == "" || str == "null" { + if str == "" || str == strNull { t.Valid = false return nil }