From d7b6f4d8d043418e373251677514a53e8cea13fe Mon Sep 17 00:00:00 2001 From: General Kroll Date: Sat, 4 Apr 2026 09:52:22 +1100 Subject: [PATCH 1/4] format-requirements Extended query protocol support with error recovery Summary: - Implement full extended query protocol (Parse, Bind, Describe, Execute, Close, Sync, Flush) with proper wire message parsing and serialization - Add IExtendedQueryBackend interface with opt-in support via type assertion; DefaultExtendedQueryBackend delegates to simple query path for backwards compat - Restructure command loop: ReadyForQuery sent once initially, then only after SimpleQuery completion and Sync (per PostgreSQL protocol spec) - Add error state tracking: after extended query failure, discard messages until Sync, then send ReadyForQuery('E') matching PostgreSQL behavior - Fix ClientClose ('C') to close prepared statements/portals, not the connection; connection close moved to ClientTerminate ('X') where it belongs - Add text format bypass in Column.Write: string/[]byte sources write raw bytes directly, preserving exact backend representation (e.g. sqlite "t"/"f" for bools) - Thread resultFormats from Bind through to RowDescription and data encoding, supporting per-column text/binary format selection per protocol spec - Per-connection PreparedStatement and Portal caches - Enable previously disabled pgx query test - Add comprehensive test coverage: extended query happy path, error recovery (Parse/Bind/Describe/Execute errors), statement/portal lifecycle, multiple Sync recovery, simple query after extended query error, text bypass preservation, resolveResultFormat protocol rules, sqlbackend unit tests --- .github/workflows/build.yaml | 31 ++++ .github/workflows/lint.yaml | 13 +- command.go | 8 +- docs/postgres_emulation.md | 0 docs/psql_wire_changes.md | 131 ++++++++++++++++ extended_query.go | 36 ++++- row.go | 38 ++++- row_test.go | 293 +++++++++++++++++++++++++++++++++++ writer.go | 11 +- 9 files changed, 542 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/build.yaml create mode 100644 docs/postgres_emulation.md create mode 100644 docs/psql_wire_changes.md create mode 100644 row_test.go diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..c24b3c9 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,31 @@ +name: Build and test + +on: + push: + tags: + - 'v*' + - 'build*' + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.17 + + - name: Unit tests + run: | + go mod tidy + go test ./... -v diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 8540a34..f9ff429 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,7 +1,18 @@ -name: CI +name: Linting checks on: push: + tags: + - 'v*' + - 'build*' + - 'lint*' + branches: + - main + - develop + pull_request: + branches: + - main + - develop jobs: diff --git a/command.go b/command.go index 6d43936..0bbb8fa 100644 --- a/command.go +++ b/command.go @@ -244,7 +244,7 @@ func (srv *Server) handleSimpleQuery(ctx context.Context, cn SQLConnection) erro } if !headersWritten { headersWritten = true - srv.writeSQLResultHeader(ctx, res, dw) + srv.writeSQLResultHeader(ctx, res, dw, nil) } srv.writeSQLResultRows(ctx, res, dw) // TODO: add debug messages, configurably @@ -283,16 +283,16 @@ func (srv *Server) writeSQLResultRows(ctx context.Context, res sqldata.ISQLResul return nil } -func (srv *Server) writeSQLResultHeader(ctx context.Context, res sqldata.ISQLResult, writer DataWriter) error { +func (srv *Server) writeSQLResultHeader(ctx context.Context, res sqldata.ISQLResult, writer DataWriter, resultFormats []int16) error { var colz Columns - for _, c := range res.GetColumns() { + for i, c := range res.GetColumns() { colz = append(colz, Column{ Table: c.GetTableId(), Name: c.GetName(), Oid: oid.Oid(c.GetObjectID()), Width: c.GetWidth(), - Format: TextFormat, + Format: resolveResultFormat(resultFormats, i), }, ) } diff --git a/docs/postgres_emulation.md b/docs/postgres_emulation.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/psql_wire_changes.md b/docs/psql_wire_changes.md new file mode 100644 index 0000000..e17f972 --- /dev/null +++ b/docs/psql_wire_changes.md @@ -0,0 +1,131 @@ +# Required psql-wire library changes for extended query fidelity + +These changes are needed in `github.com/stackql/psql-wire` to complete postgres-fidelity extended query support in stackql. + +## 1. Text format encoding must not alter value representation + +**File:** `row.go` — `Column.Write()` method (line ~82) + +**Problem:** When a column has a non-text OID (e.g. `T_int8`, `T_bool`), the current path does: +```go +typed.Value.Set(src) // pgtype.Int8.Set("100000001") — works +encoder := fc.Encoder(typed) // TextEncoder +bb, _ := encoder(ci, nil) // outputs "100000001" (no quotes, different width) +``` + +For `T_bool`, `pgtype.Bool` text-encodes as `"true"/"false"` instead of sqlite's `"t"/"f"`. For `T_int8`, formatting may differ from the string that came in. + +**Fix:** For `TextFormat`, bypass `pgtype.Set()` + encoder when the source value is already a string or `[]byte`. Write the raw bytes directly with a length prefix: + +```go +func (column Column) Write(ctx context.Context, writer buffer.Writer, src interface{}) error { + if column.Format == TextFormat { + if b, ok := asTextBytes(src); ok { + if b == nil { + writer.AddInt32(-1) // NULL + return nil + } + writer.AddInt32(int32(len(b))) + writer.AddBytes(b) + return nil + } + } + // existing pgtype path for binary format or non-string sources + ... +} + +func asTextBytes(src interface{}) ([]byte, bool) { + switch v := src.(type) { + case string: + if strings.ToLower(v) == "null" { + return nil, true + } + return []byte(v), true + case []byte: + return v, true + default: + return nil, false + } +} +``` + +This preserves the exact string representation from the RDBMS while still sending the correct OID in `RowDescription`. Clients see `T_int8` in the column metadata but receive text-encoded values — which is valid postgres behaviour when `FormatCode=0` (text). + +## 2. Respect `resultFormats` from Bind in RowDescription + +**File:** `extended_query.go` — `writeRowDescriptionFromSQLColumns()` (line ~419) + +**Problem:** Format is hardcoded to `TextFormat`: +```go +colz = append(colz, Column{ + ... + Format: TextFormat, // always text +}) +``` + +**Fix:** Accept `resultFormats []int16` (from the portal's Bind message) and apply per the postgres protocol rules: + +```go +func writeRowDescriptionFromSQLColumns( + ctx context.Context, writer buffer.Writer, + columns []sqldata.ISQLColumn, resultFormats []int16, +) error { + var colz Columns + for i, c := range columns { + colz = append(colz, Column{ + Table: c.GetTableId(), + Name: c.GetName(), + AttrNo: c.GetAttrNum(), + Oid: oid.Oid(c.GetObjectID()), + Width: c.GetWidth(), + Format: resolveResultFormat(resultFormats, i), + }) + } + return colz.Define(ctx, writer) +} + +func resolveResultFormat(formats []int16, i int) FormatCode { + if len(formats) == 0 { + return TextFormat + } + if len(formats) == 1 { + return FormatCode(formats[0]) + } + if i < len(formats) { + return FormatCode(formats[i]) + } + return TextFormat +} +``` + +This requires threading `resultFormats` from the portal through to the row description writer. The portal already stores `ResultFormats` — it just needs to be passed through `handleExecute` → `writeSQLResultHeader`. + +## 3. Thread resultFormats through handleExecute + +**File:** `extended_query.go` — `handleExecute()` (line ~248) + +The portal's `ResultFormats` need to reach the data writer so `Column.Write` uses the correct encoder (text vs binary). Currently the `dataWriter` and `writeSQLResultHeader` don't receive format information. + +**Fix:** Add `resultFormats []int16` to the `dataWriter` struct or pass it through the header writing path: + +```go +dw := &dataWriter{ + ctx: ctx, + client: conn, + resultFormats: portal.ResultFormats, +} +``` + +Then in `writeSQLResultHeader`, use `dw.resultFormats` when calling `writeRowDescriptionFromSQLColumns`. + +## Summary + +| Change | File | Risk | Effect | +|--------|------|------|--------| +| Text bypass for string values | `row.go` | Low — only affects text format path, preserves exact string bytes | Enables OID fidelity without changing output format | +| resultFormats in RowDescription | `extended_query.go` | Low — defaults to TextFormat when formats not specified | Clients can request binary results | +| Thread formats through execute | `extended_query.go` | Low — adds field to existing struct | Connects Bind formats to result encoding | + +Change 1 is the critical blocker. Changes 2-3 are enhancements for binary result support (most clients default to text results anyway). + +Once change 1 lands, stackql can enable finer OID mapping (`integer`→`T_int8`, `boolean`→`T_bool`, etc.) without breaking any existing tests. diff --git a/extended_query.go b/extended_query.go index 52457f7..9dd74c7 100644 --- a/extended_query.go +++ b/extended_query.go @@ -214,8 +214,9 @@ func (srv *Server) handleDescribeStatement(ctx context.Context, conn SQLConnecti } // Send RowDescription or NoData + // Describe on a statement has no result formats yet (Bind hasn't happened) if columns != nil { - return writeRowDescriptionFromSQLColumns(ctx, conn, columns) + return writeRowDescriptionFromSQLColumns(ctx, conn, columns, nil) } return writeNoData(conn) } @@ -238,7 +239,7 @@ func (srv *Server) handleDescribePortal(ctx context.Context, conn SQLConnection, } if columns != nil { - return writeRowDescriptionFromSQLColumns(ctx, conn, columns) + return writeRowDescriptionFromSQLColumns(ctx, conn, columns, portal.ResultFormats) } return writeNoData(conn) } @@ -291,8 +292,9 @@ func (srv *Server) handleExecute(ctx context.Context, conn SQLConnection) error } dw := &dataWriter{ - ctx: ctx, - client: conn, + ctx: ctx, + client: conn, + resultFormats: portal.ResultFormats, } var headersWritten bool @@ -307,7 +309,7 @@ func (srv *Server) handleExecute(ctx context.Context, conn SQLConnection) error } if !headersWritten { headersWritten = true - srv.writeSQLResultHeader(ctx, res, dw) + srv.writeSQLResultHeader(ctx, res, dw, portal.ResultFormats) } srv.writeSQLResultRows(ctx, res, dw) dw.Complete(notices, "OK") @@ -416,17 +418,35 @@ func writeParameterDescription(writer buffer.Writer, paramOIDs []uint32) error { return writer.End() } -func writeRowDescriptionFromSQLColumns(ctx context.Context, writer buffer.Writer, columns []sqldata.ISQLColumn) error { +func writeRowDescriptionFromSQLColumns(ctx context.Context, writer buffer.Writer, columns []sqldata.ISQLColumn, resultFormats []int16) error { var colz Columns - for _, c := range columns { + for i, c := range columns { colz = append(colz, Column{ Table: c.GetTableId(), Name: c.GetName(), AttrNo: c.GetAttrNum(), Oid: oid.Oid(c.GetObjectID()), Width: c.GetWidth(), - Format: TextFormat, + Format: resolveResultFormat(resultFormats, i), }) } return colz.Define(ctx, writer) } + +// resolveResultFormat determines the format code for column i based on the +// result format codes from the Bind message, per the PostgreSQL protocol: +// - 0 format codes: all columns use text +// - 1 format code: all columns use that format +// - N format codes: each column uses its corresponding format +func resolveResultFormat(formats []int16, i int) FormatCode { + if len(formats) == 0 { + return TextFormat + } + if len(formats) == 1 { + return FormatCode(formats[0]) + } + if i < len(formats) { + return FormatCode(formats[i]) + } + return TextFormat +} diff --git a/row.go b/row.go index de852dc..28eb0c1 100644 --- a/row.go +++ b/row.go @@ -78,12 +78,32 @@ func (column Column) Define(ctx context.Context, writer buffer.Writer) { // Write encodes the given source value using the column type definition and connection // info. The encoded byte buffer is added to the given write buffer. This method -// Is used to encode values and return them inside a DataRow message. +// is used to encode values and return them inside a DataRow message. +// +// For text format (FormatCode=0), if the source is already a string or []byte, +// the raw bytes are written directly without going through pgtype encoding. +// This preserves the exact representation from the backend (e.g. sqlite's "t"/"f" +// for booleans) while still advertising the correct OID in RowDescription. func (column Column) Write(ctx context.Context, writer buffer.Writer, src interface{}) (err error) { if ctx.Err() != nil { return ctx.Err() } + // For text format, bypass pgtype encoding when the source is already + // a string or []byte. This avoids representation changes (e.g. pgtype.Bool + // encoding "true"/"false" instead of the original "t"/"f"). + if column.Format == TextFormat { + if b, ok := asTextBytes(src); ok { + if b == nil { + writer.AddInt32(-1) // NULL + return nil + } + writer.AddInt32(int32(len(b))) + writer.AddBytes(b) + return nil + } + } + ci := TypeInfo(ctx) if ci == nil { return errors.New("postgres connection info has not been defined inside the given context") @@ -120,3 +140,19 @@ func (column Column) Write(ctx context.Context, writer buffer.Writer, src interf return nil } + +// asTextBytes extracts raw bytes from string or []byte sources for text-format +// passthrough. Returns (nil, true) for NULL-representing strings. +func asTextBytes(src interface{}) ([]byte, bool) { + switch v := src.(type) { + case string: + if strings.ToLower(v) == "null" { + return nil, true + } + return []byte(v), true + case []byte: + return v, true + default: + return nil, false + } +} diff --git a/row_test.go b/row_test.go new file mode 100644 index 0000000..1250866 --- /dev/null +++ b/row_test.go @@ -0,0 +1,293 @@ +package wire + +import ( + "bytes" + "context" + "encoding/binary" + "testing" + + "github.com/lib/pq/oid" + "github.com/stackql/psql-wire/internal/buffer" +) + +// readDataRowValues reads DataRow column values from raw bytes written by Columns.Write. +// Returns the raw byte slices for each column (nil for NULL). +func readDataRowValues(t *testing.T, data []byte) [][]byte { + t.Helper() + r := bytes.NewReader(data) + + // ServerDataRow type byte + msgType := make([]byte, 1) + if _, err := r.Read(msgType); err != nil { + t.Fatalf("read type byte: %v", err) + } + + // message length (int32) + var msgLen int32 + if err := binary.Read(r, binary.BigEndian, &msgLen); err != nil { + t.Fatalf("read msg length: %v", err) + } + + // column count (int16) + var numCols int16 + if err := binary.Read(r, binary.BigEndian, &numCols); err != nil { + t.Fatalf("read column count: %v", err) + } + + values := make([][]byte, numCols) + for i := 0; i < int(numCols); i++ { + var length int32 + if err := binary.Read(r, binary.BigEndian, &length); err != nil { + t.Fatalf("read value length for col %d: %v", i, err) + } + if length == -1 { + values[i] = nil + } else { + val := make([]byte, length) + if _, err := r.Read(val); err != nil { + t.Fatalf("read value for col %d: %v", i, err) + } + values[i] = val + } + } + return values +} + +func TestAsTextBytes(t *testing.T) { + tests := []struct { + name string + src interface{} + wantData []byte + wantOK bool + }{ + { + name: "string value", + src: "hello", + wantData: []byte("hello"), + wantOK: true, + }, + { + name: "byte slice", + src: []byte("world"), + wantData: []byte("world"), + wantOK: true, + }, + { + name: "null string", + src: "null", + wantData: nil, + wantOK: true, + }, + { + name: "NULL string uppercase", + src: "NULL", + wantData: nil, + wantOK: true, + }, + { + name: "integer not matched", + src: 42, + wantData: nil, + wantOK: false, + }, + { + name: "bool not matched", + src: true, + wantData: nil, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, ok := asTextBytes(tt.src) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !bytes.Equal(data, tt.wantData) { + t.Fatalf("data = %q, want %q", data, tt.wantData) + } + }) + } +} + +func TestColumnWrite_TextBypass_PreservesExactString(t *testing.T) { + ctx := setTypeInfo(context.Background()) + + tests := []struct { + name string + colOid oid.Oid + src interface{} + wantText string + }{ + { + name: "bool column with sqlite t/f preserved", + colOid: oid.T_bool, + src: "t", + wantText: "t", + }, + { + name: "bool column with 1/0 preserved", + colOid: oid.T_bool, + src: "1", + wantText: "1", + }, + { + name: "int8 column with string preserved exactly", + colOid: oid.T_int8, + src: "100000001", + wantText: "100000001", + }, + { + name: "text column with plain string", + colOid: oid.T_text, + src: "hello world", + wantText: "hello world", + }, + { + name: "int4 column with byte slice", + colOid: oid.T_int4, + src: []byte("42"), + wantText: "42", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + col := Column{ + Name: "test", + Oid: tt.colOid, + Width: -1, + Format: TextFormat, + } + + var buf bytes.Buffer + writer := buffer.NewWriter(&buf) + + // Wrap in a DataRow message to get valid framing + cols := Columns{col} + err := cols.Write(ctx, writer, []interface{}{tt.src}) + if err != nil { + t.Fatalf("Write error: %v", err) + } + + values := readDataRowValues(t, buf.Bytes()) + if len(values) != 1 { + t.Fatalf("got %d values, want 1", len(values)) + } + + got := string(values[0]) + if got != tt.wantText { + t.Errorf("got %q, want %q (OID %d)", got, tt.wantText, tt.colOid) + } + }) + } +} + +func TestColumnWrite_TextBypass_NullHandling(t *testing.T) { + ctx := setTypeInfo(context.Background()) + + col := Column{ + Name: "test", + Oid: oid.T_text, + Width: -1, + Format: TextFormat, + } + + var buf bytes.Buffer + writer := buffer.NewWriter(&buf) + + cols := Columns{col} + err := cols.Write(ctx, writer, []interface{}{"null"}) + if err != nil { + t.Fatalf("Write error: %v", err) + } + + values := readDataRowValues(t, buf.Bytes()) + if values[0] != nil { + t.Errorf("expected NULL (nil), got %q", values[0]) + } +} + +func TestColumnWrite_NonString_UsesStandardEncoder(t *testing.T) { + ctx := setTypeInfo(context.Background()) + + // When source is a native Go type (not string/[]byte), + // the standard pgtype encoder should be used. + col := Column{ + Name: "test", + Oid: oid.T_int4, + Width: 4, + Format: TextFormat, + } + + var buf bytes.Buffer + writer := buffer.NewWriter(&buf) + + cols := Columns{col} + err := cols.Write(ctx, writer, []interface{}{int32(42)}) + if err != nil { + t.Fatalf("Write error: %v", err) + } + + values := readDataRowValues(t, buf.Bytes()) + got := string(values[0]) + if got != "42" { + t.Errorf("got %q, want %q", got, "42") + } +} + +func TestResolveResultFormat(t *testing.T) { + tests := []struct { + name string + formats []int16 + index int + want FormatCode + }{ + { + name: "empty formats defaults to text", + formats: nil, + index: 0, + want: TextFormat, + }, + { + name: "single format applies to all columns", + formats: []int16{1}, + index: 0, + want: BinaryFormat, + }, + { + name: "single format applies to column 5", + formats: []int16{1}, + index: 5, + want: BinaryFormat, + }, + { + name: "per-column format code 0", + formats: []int16{0, 1, 0}, + index: 0, + want: TextFormat, + }, + { + name: "per-column format code 1", + formats: []int16{0, 1, 0}, + index: 1, + want: BinaryFormat, + }, + { + name: "index beyond formats defaults to text", + formats: []int16{1, 0}, + index: 5, + want: TextFormat, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveResultFormat(tt.formats, tt.index) + if got != tt.want { + t.Errorf("resolveResultFormat(%v, %d) = %d, want %d", tt.formats, tt.index, got, tt.want) + } + }) + } +} diff --git a/writer.go b/writer.go index 197bd00..a065dc0 100644 --- a/writer.go +++ b/writer.go @@ -49,11 +49,12 @@ var ErrClosedWriter = errors.New("closed writer") // dataWriter is a implementation of the DataWriter interface. type dataWriter struct { - columns Columns - ctx context.Context - client buffer.Writer - closed bool - written uint64 + columns Columns + ctx context.Context + client buffer.Writer + closed bool + written uint64 + resultFormats []int16 // from Bind message; nil means all text } func (writer *dataWriter) Define(columns Columns) error { From 90ab8a57c7dd7edc99381781b865bd7267335f52 Mon Sep 17 00:00:00 2001 From: General Kroll Date: Sat, 4 Apr 2026 11:43:54 +1100 Subject: [PATCH 2/4] improve-ci --- .github/workflows/build.yaml | 3 ++- .github/workflows/lint.yaml | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c24b3c9..ae4faac 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -15,7 +15,8 @@ on: jobs: - lint: + test: + name: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index f9ff429..2a97421 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -17,6 +17,7 @@ on: jobs: lint: + name: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -27,7 +28,7 @@ jobs: go-version: 1.17 - name: Restore bin - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ./bin key: ${{ runner.os }}-bin-${{ hashFiles('**/go.sum') }} @@ -36,7 +37,7 @@ jobs: run: make lint - name: Cache bin - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ./bin key: ${{ runner.os }}-bin-${{ hashFiles('**/go.sum') }} From 8b7212f074ffb47971846e2fc05151edcb9ff11d Mon Sep 17 00:00:00 2001 From: General Kroll Date: Sat, 4 Apr 2026 12:18:41 +1100 Subject: [PATCH 3/4] - Soft linting. --- .github/workflows/lint.yaml | 13 ++- .golangci.yml | 178 ++++++++++++++++++++++++++++++++++++ Makefile | 4 +- docs/developer-guide.md | 8 ++ 4 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 .golangci.yml create mode 100644 docs/developer-guide.md diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2a97421..8709421 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -14,6 +14,10 @@ on: - main - develop +env: + GOLANGCI_LINT_VERSION: ${{ vars.GOLANGCI_LINT_VERSION == '' && 'v2.5.0' || vars.GOLANGCI_LINT_VERSION }} + DEFAULT_STEP_TIMEOUT: ${{ vars.DEFAULT_STEP_TIMEOUT_MIN == '' && '20' || vars.DEFAULT_STEP_TIMEOUT_MIN }} + jobs: lint: @@ -34,7 +38,14 @@ jobs: key: ${{ runner.os }}-bin-${{ hashFiles('**/go.sum') }} - name: Linting - run: make lint + run: | + make lint GOLANGCI_LINT_VERSION=${GOLANGCI_LINT_VERSION} | tee lint.log 2>&1 || true + + - name: Upload linting results + uses: actions/upload-artifact@v2 + with: + name: lint-results + path: lint.log - name: Cache bin uses: actions/cache@v4 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3456286 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,178 @@ +version: "2" +output: + formats: + text: + path: stdout +linters: + default: none + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - cyclop + - dupl + - durationcheck + - errcheck + - errname + - errorlint + - exhaustive + - forbidigo + - funlen + - gocheckcompilerdirectives + - gochecknoglobals + - gochecknoinits + - gocognit + # - goconst + - gocritic + - gocyclo + - godot + - gomodguard + - goprintffuncname + - gosec + - govet + - ineffassign + - lll + - loggercheck + - makezero + - mnd + - musttag + - nakedret + - nestif + - nilerr + - nilnil + # - noctx + # - nolintlint + - nonamedreturns + - nosprintfhostport + - predeclared + - promlinter + - reassign + - revive + - rowserrcheck + - sqlclosecheck + # - staticcheck + - testableexamples + - testpackage + - tparallel + - unconvert + - unparam + - unused + - usestdlibvars + - wastedassign + - whitespace + settings: + cyclop: + max-complexity: 30 + package-average: 10 + errcheck: + check-type-assertions: true + exhaustive: + check: + - switch + - map + exhaustruct: + exclude: + - ^net/http.Client$ + - ^net/http.Cookie$ + - ^net/http.Request$ + - ^net/http.Response$ + - ^net/http.Server$ + - ^net/http.Transport$ + - ^net/url.URL$ + - ^os/exec.Cmd$ + - ^reflect.StructField$ + - ^github.com/Shopify/sarama.Config$ + - ^github.com/Shopify/sarama.ProducerMessage$ + - ^github.com/mitchellh/mapstructure.DecoderConfig$ + - ^github.com/prometheus/client_golang/.+Opts$ + - ^github.com/spf13/cobra.Command$ + - ^github.com/spf13/cobra.CompletionOptions$ + - ^github.com/stretchr/testify/mock.Mock$ + - ^github.com/testcontainers/testcontainers-go.+Request$ + - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ + - ^golang.org/x/tools/go/analysis.Analyzer$ + - ^google.golang.org/protobuf/.+Options$ + - ^gopkg.in/yaml.v3.Node$ + funlen: + lines: 100 + statements: 50 + gocognit: + min-complexity: 20 + gocritic: + settings: + captLocal: + paramsOnly: false + underef: + skipRecvDeref: false + gomodguard: + blocked: + modules: + - github.com/golang/protobuf: + recommendations: + - google.golang.org/protobuf + reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - github.com/satori/go.uuid: + recommendations: + - github.com/google/uuid + reason: satori's package is not maintained + - github.com/gofrs/uuid: + recommendations: + - github.com/google/uuid + reason: gofrs' package is not go module + govet: + disable: + - fieldalignment + enable-all: true + settings: + shadow: + strict: true + mnd: + ignored-functions: + - os.Chmod + - os.Mkdir + - os.MkdirAll + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets + - prometheus.ExponentialBucketsRange + - prometheus.LinearBuckets + nakedret: + max-func-lines: 0 + nolintlint: + require-explanation: true + require-specific: true + allow-no-explanation: + - funlen + - gocognit + - lll + rowserrcheck: + packages: + - github.com/jmoiron/sqlx + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - stylecheck + path: command\.go + paths: + - third_party$ + - '.*_test\.go$' + - builtin$ + - examples$ +issues: + max-same-issues: 50 +formatters: + enable: + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/Makefile b/Makefile index a3bb998..2bd7081 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,8 @@ GOPATH = $(HOME)/go GOBIN = $(GOPATH)/bin GO ?= GOGC=off $(shell which go) +GOLANGCI_LINT_VERSION = v2.5.0 + # Printing V = 0 Q = $(if $(filter 1,$V),,@) @@ -23,7 +25,7 @@ $(BIN)/%: | $(BIN) ; $(info $(M) building $(@F)…) GOLANGCI_LINT = $(BIN)/golangci-lint $(BIN)/golangci-lint: | $(BIN) ; - curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.42.1 + curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/$(GOLANGCI_LINT_VERSION)/install.sh| sh -s $(GOLANGCI_LINT_VERSION) STRINGER = $(BIN)/stringer GOIMPORTS = $(BIN)/goimports diff --git a/docs/developer-guide.md b/docs/developer-guide.md new file mode 100644 index 0000000..9f789d5 --- /dev/null +++ b/docs/developer-guide.md @@ -0,0 +1,8 @@ + +# Developer Guide + +## Running linter locally + +```bash +make lint +``` From aa8c8ce9d9501442dbaa398dfb1585cb37ac51c1 Mon Sep 17 00:00:00 2001 From: General Kroll Date: Sat, 4 Apr 2026 12:20:09 +1100 Subject: [PATCH 4/4] - Soft linting. --- .github/workflows/lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 8709421..9a9b2f5 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -42,7 +42,7 @@ jobs: make lint GOLANGCI_LINT_VERSION=${GOLANGCI_LINT_VERSION} | tee lint.log 2>&1 || true - name: Upload linting results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: lint-results path: lint.log