From 120e1cd545603ece8917444d0371756d487c8195 Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:48:40 +0400 Subject: [PATCH] feat(scaffold): strengthen generated Go lint checks Add cyclop with a complexity limit of 10 and lower the gocognit threshold to 15, preserving existing linters and argument/result limits. Require Go doc for exported production symbols and private test helpers, excluding standard test entrypoints. Update scaffold seed comments, golden fixtures, the Orders API example, and documentation. --- docs/README.md | 2 +- docs/product/README.md | 2 +- docs/product/scaffold-and-ownership.md | 26 +++++ examples/orders-api/.golangci.yml | 31 +++++- .../orders-api/internal/deps/application.go | 1 + .../internal/orders/handler_test.go | 3 + examples/orders-api/internal/orders/orders.go | 12 +++ internal/service/scaffold/golangci_test.go | 100 ++++++++++++++++++ .../scaffold/templates/application.go.gotmpl | 2 + .../service/scaffold/templates/golangci.yml | 31 +++++- .../templates/kafka_handler.go.gotmpl | 2 + .../scaffold/testdata/full/.golangci.yml | 31 +++++- .../full/internal/deps/application.go | 5 +- .../transport/consumerkafka/audit/handler.go | 2 + .../consumerkafka/invoice/handler.go | 2 + .../scaffold/testdata/minimal/.golangci.yml | 31 +++++- 16 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 internal/service/scaffold/golangci_test.go diff --git a/docs/README.md b/docs/README.md index a887465..c718a0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ the documentation at its matching Git tag. - [Contracts and generation](product/contracts-and-generation.md): Sources, Snapshots, synchronization, linting, and generated outputs. - [Scaffold and ownership](product/scaffold-and-ownership.md): refresh rules, - user-owned inputs, and partial publication. + Go quality checks, user-owned inputs, and partial publication. - [Runtime configuration](product/runtime-configuration.md): environment keys, defaults, secrets, and Start Policy. - [Generated application](product/generated-application.md): composition, diff --git a/docs/product/README.md b/docs/product/README.md index 72e8701..6fd7f3b 100644 --- a/docs/product/README.md +++ b/docs/product/README.md @@ -11,7 +11,7 @@ their observable behavior, and operational constraints. - [Contracts and generation](contracts-and-generation.md): bounded Sources, committed Snapshots, linting, generators, and publication. - [Scaffold and ownership](scaffold-and-ownership.md): Managed Outputs, Seeds, - custom configurations, and refresh behavior. + Go quality checks, custom configurations, and refresh behavior. - [Runtime configuration](runtime-configuration.md): environment policy, generated config, Start Policy, and migration separation. - [Generated application](generated-application.md): explicit composition, diff --git a/docs/product/scaffold-and-ownership.md b/docs/product/scaffold-and-ownership.md index dcc1766..8059b61 100644 --- a/docs/product/scaffold-and-ownership.md +++ b/docs/product/scaffold-and-ownership.md @@ -23,6 +23,32 @@ workflow. Handwritten files inside those trees can be removed by replacement. The distinction and partial-publication policy are recorded in [PDR 0003](decisions/0003-managed-outputs-and-scaffold-seeds.md). +## Go quality checks + +Scaffold provides a pinned golangci-lint toolchain, `gofmt` formatting, and Go +lint tasks. The lint configuration checks handwritten production and test code +with cyclomatic complexity limited to 10, cognitive complexity limited to 15, +at most four function parameters, and at most three results. Existing checks +for errors, resources, context usage, and test practices remain enabled. + +The Project's Mise tasks expose Go linting as `lint:go` and Contract linting +as `lint:contracts`; `lint` runs both. The `devctl lint` command checks +Contracts. The `fmt` task formats Go code, and `fmt:check` checks formatting. + +In importable production packages, exported types, functions, and methods +require Go doc, including exported methods on private receivers and public +interface methods. The production Go doc check skips `package main`. +Production constants and variables do not require documentation, and name +stuttering is not checked. In test files, package-level declarations and methods +require Go doc, including private helpers. Standard `Test`, `TestMain`, +`Benchmark`, `Fuzz`, and `Example` entrypoints do not. Interface method inputs +must be named, including methods with a single input. + +Generated Go files with the standard generated-file marker and the `gen/` tree +are excluded. Refresh replaces the lint configuration but preserves existing +Scaffold Seeds, so existing application code may need documentation when the +quality checks are strengthened. + ## Native generator configs Canonical HTTP config paths are `tools/oapi/server.yaml` and diff --git a/examples/orders-api/.golangci.yml b/examples/orders-api/.golangci.yml index a01c843..865891e 100644 --- a/examples/orders-api/.golangci.yml +++ b/examples/orders-api/.golangci.yml @@ -11,6 +11,7 @@ linters: - bodyclose - containedctx - contextcheck + - cyclop - durationcheck - errcheck - errchkjson @@ -22,6 +23,7 @@ linters: - gocheckcompilerdirectives - gochecknoinits - gocognit + - godoclint - govet - inamedparam - ineffassign @@ -50,8 +52,18 @@ linters: - wastedassign - wrapcheck settings: + cyclop: + max-complexity: 10 gocognit: - min-complexity: 20 + min-complexity: 15 + godoclint: + default: none + enable: [require-doc] + options: + require-doc: + ignore-unexported: false + inamedparam: + skip-single-param: false nolintlint: allow-unused: false require-explanation: true @@ -66,9 +78,26 @@ linters: arguments: [4] - name: function-result-limit arguments: [3] + - name: exported + arguments: + - check-private-receivers + - check-public-interface + - disable-stuttering-check + - disable-checks-on-constants + - disable-checks-on-variables exclusions: generated: strict paths: ["^gen/"] + rules: + # Revive owns production documentation; godoclint also checks test helpers. + - path-except: _test\.go$ + linters: [godoclint] + # Test entry points describe their scenarios through names and assertions. + # Keep documentation checks for helpers and methods in the same files. + - path: _test\.go$ + linters: [godoclint] + text: '^symbol should have a godoc' + source: '^func ((Test|Benchmark|Fuzz)([A-Z0-9_][A-Za-z0-9_]*)?\([^)]* \*testing\.(T|B|F|M)|Example([A-Z0-9_][A-Za-z0-9_]*)?\()\)[[:space:]]+\{' formatters: enable: [gofmt] exclusions: diff --git a/examples/orders-api/internal/deps/application.go b/examples/orders-api/internal/deps/application.go index 7d28db1..4afbeba 100644 --- a/examples/orders-api/internal/deps/application.go +++ b/examples/orders-api/internal/deps/application.go @@ -16,6 +16,7 @@ type application struct { orders *orders.Handler } +// RegisterHTTP registers the Orders API handlers on the server. func (a *application) RegisterHTTP(server *echo.Echo) { serverhttp.RegisterHandlers(server, serverhttp.NewStrictHandler(a.orders, nil)) } diff --git a/examples/orders-api/internal/orders/handler_test.go b/examples/orders-api/internal/orders/handler_test.go index 01e9ddd..4c0b319 100644 --- a/examples/orders-api/internal/orders/handler_test.go +++ b/examples/orders-api/internal/orders/handler_test.go @@ -9,16 +9,19 @@ import ( "github.com/stretchr/testify/require" ) +// stubStore returns fixed orders and errors for handler scenarios. type stubStore struct { created Order got Order err error } +// Create returns the configured creation result. func (s *stubStore) Create(context.Context, string, int64) (Order, error) { return s.created, s.err } +// Get returns the configured lookup result. func (s *stubStore) Get(context.Context, int64) (Order, error) { return s.got, s.err } diff --git a/examples/orders-api/internal/orders/orders.go b/examples/orders-api/internal/orders/orders.go index 94f6607..3153fe1 100644 --- a/examples/orders-api/internal/orders/orders.go +++ b/examples/orders-api/internal/orders/orders.go @@ -13,6 +13,7 @@ import ( var ErrNotFound = errors.New("order not found") +// Order is a persisted customer order with its total expressed in cents. type Order struct { ID int64 CustomerName string @@ -20,20 +21,26 @@ type Order struct { CreatedAt time.Time } +// Store persists and retrieves orders. type Store interface { + // Create persists an order and returns its assigned ID and creation time. Create(ctx context.Context, customerName string, totalCents int64) (Order, error) + // Get returns the order with the given ID, or ErrNotFound when it does not exist. Get(ctx context.Context, id int64) (Order, error) } +// PostgresStore reads and writes orders through PostgreSQL endpoints. type PostgresStore struct { reader *postgresdb.Endpoint writer *postgresdb.Endpoint } +// NewPostgresStore creates a store using separate reader and writer endpoints. func NewPostgresStore(reader, writer *postgresdb.Endpoint) *PostgresStore { return &PostgresStore{reader: reader, writer: writer} } +// Create inserts an order and returns its assigned ID and creation time. func (s *PostgresStore) Create(ctx context.Context, customerName string, totalCents int64) (Order, error) { const query = ` INSERT INTO orders (customer_name, total_cents) @@ -53,6 +60,7 @@ func (s *PostgresStore) Create(ctx context.Context, customerName string, totalCe return order, nil } +// Get retrieves an order by ID, returning ErrNotFound when it does not exist. func (s *PostgresStore) Get(ctx context.Context, id int64) (Order, error) { const query = ` SELECT id, customer_name, total_cents, created_at @@ -75,14 +83,17 @@ func (s *PostgresStore) Get(ctx context.Context, id int64) (Order, error) { return order, nil } +// Handler serves the Orders API using a Store. type Handler struct { store Store } +// NewHandler creates an Orders API handler backed by store. func NewHandler(store Store) *Handler { return &Handler{store: store} } +// CreateOrder validates the request body and persists a new order. func (h *Handler) CreateOrder(ctx context.Context, request serverhttp.CreateOrderRequestObject) (serverhttp.CreateOrderResponseObject, error) { if request.Body == nil { return nil, errors.New("create order body is required") @@ -94,6 +105,7 @@ func (h *Handler) CreateOrder(ctx context.Context, request serverhttp.CreateOrde return serverhttp.CreateOrder201JSONResponse(toAPIOrder(order)), nil } +// GetOrder retrieves an order, returning a not-found response when it does not exist. func (h *Handler) GetOrder(ctx context.Context, request serverhttp.GetOrderRequestObject) (serverhttp.GetOrderResponseObject, error) { order, err := h.store.Get(ctx, request.Id) if errors.Is(err, ErrNotFound) { diff --git a/internal/service/scaffold/golangci_test.go b/internal/service/scaffold/golangci_test.go new file mode 100644 index 0000000..22a53e6 --- /dev/null +++ b/internal/service/scaffold/golangci_test.go @@ -0,0 +1,100 @@ +package scaffold + +import ( + "go/ast" + "go/doc" + "go/parser" + "go/token" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestPlanConfiguresGoQualityChecks(t *testing.T) { + t.Parallel() + + artifacts := plannedArtifacts(t, minimalScaffoldManifest()) + var config struct { + Run struct { + Tests bool `yaml:"tests"` + } `yaml:"run"` + Linters struct { + Default string `yaml:"default"` + Enable []string `yaml:"enable"` + Settings map[string]map[string]any `yaml:"settings"` + Exclusions struct { + Generated string `yaml:"generated"` + Paths []string `yaml:"paths"` + Rules []map[string]any `yaml:"rules"` + } `yaml:"exclusions"` + } `yaml:"linters"` + Formatters struct { + Enable []string `yaml:"enable"` + } `yaml:"formatters"` + } + require.NoError(t, yaml.Unmarshal(artifacts[".golangci.yml"].Content, &config)) + require.True(t, config.Run.Tests) + require.Equal(t, "none", config.Linters.Default) + require.Subset(t, config.Linters.Enable, []string{"cyclop", "gocognit", "godoclint", "inamedparam", "revive", "bodyclose", "contextcheck", "staticcheck"}) + require.Equal(t, 10, config.Linters.Settings["cyclop"]["max-complexity"]) + require.Equal(t, 15, config.Linters.Settings["gocognit"]["min-complexity"]) + require.Equal(t, map[string]any{ + "default": "none", "enable": []any{"require-doc"}, + "options": map[string]any{"require-doc": map[string]any{"ignore-unexported": false}}, + }, config.Linters.Settings["godoclint"]) + require.Equal(t, false, config.Linters.Settings["inamedparam"]["skip-single-param"]) + require.Equal(t, []any{ + map[string]any{"name": "argument-limit", "arguments": []any{4}}, + map[string]any{"name": "function-result-limit", "arguments": []any{3}}, + map[string]any{"name": "exported", "arguments": []any{ + "check-private-receivers", "check-public-interface", "disable-stuttering-check", + "disable-checks-on-constants", "disable-checks-on-variables", + }}, + }, config.Linters.Settings["revive"]["rules"]) + require.Equal(t, "strict", config.Linters.Exclusions.Generated) + require.Equal(t, []string{"^gen/"}, config.Linters.Exclusions.Paths) + require.Len(t, config.Linters.Exclusions.Rules, 2) + for _, rule := range config.Linters.Exclusions.Rules { + require.Equal(t, []any{"godoclint"}, rule["linters"], "exclusions must preserve complexity and revive checks in tests") + } + entrypoints := config.Linters.Exclusions.Rules[1] + for _, source := range []string{ + "func TestQuality(t *testing.T) { t.Helper() }", + "func TestMain(m *testing.M) { m.Run() }", + "func BenchmarkQuality(b *testing.B) { b.Helper() }", + "func FuzzQuality(f *testing.F) { f.Add(1) }", + "func Example() {}", + } { + require.Regexp(t, entrypoints["source"], source) + } + require.NotRegexp(t, entrypoints["source"], "func (helper) TestQuality(t *testing.T) { t.Helper() }") + require.Equal(t, []string{"gofmt"}, config.Formatters.Enable) + require.False(t, artifacts[".golangci.yml"].CreateOnly) +} + +func TestPlanDocumentsApplicationAndKafkaSeeds(t *testing.T) { + t.Parallel() + + artifacts := plannedArtifacts(t, fullCharacterizationManifest()) + for _, path := range []string{ + "internal/deps/application.go", + "internal/transport/consumerkafka/audit/handler.go", + } { + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, path, artifacts[path].Content, parser.ParseComments) + require.NoError(t, err) + documentation, err := doc.NewFromFiles(fileSet, []*ast.File{file}, "", doc.AllDecls) + require.NoError(t, err) + require.NotEmpty(t, documentation.Types, path) + for _, declaration := range documentation.Types { + require.NotEmpty(t, declaration.Doc, "%s: %s needs Go doc", path, declaration.Name) + for _, method := range declaration.Methods { + require.NotEmpty(t, method.Doc, "%s: %s needs Go doc", path, method.Name) + } + for _, constructor := range declaration.Funcs { + require.NotEmpty(t, constructor.Doc, "%s: %s needs Go doc", path, constructor.Name) + } + } + } +} diff --git a/internal/service/scaffold/templates/application.go.gotmpl b/internal/service/scaffold/templates/application.go.gotmpl index 4f6d5f1..bd79dc4 100644 --- a/internal/service/scaffold/templates/application.go.gotmpl +++ b/internal/service/scaffold/templates/application.go.gotmpl @@ -19,9 +19,11 @@ import ( type application struct{} {{- if .HTTP }} +// RegisterHTTP registers application HTTP handlers on the server. func (*application) RegisterHTTP(*echo.Echo) {} {{- end }} {{- if .GRPC }} +// RegisterGRPC registers application gRPC services on the server. func (*application) RegisterGRPC(*grpc.Server) {} {{- end }} diff --git a/internal/service/scaffold/templates/golangci.yml b/internal/service/scaffold/templates/golangci.yml index a01c843..865891e 100644 --- a/internal/service/scaffold/templates/golangci.yml +++ b/internal/service/scaffold/templates/golangci.yml @@ -11,6 +11,7 @@ linters: - bodyclose - containedctx - contextcheck + - cyclop - durationcheck - errcheck - errchkjson @@ -22,6 +23,7 @@ linters: - gocheckcompilerdirectives - gochecknoinits - gocognit + - godoclint - govet - inamedparam - ineffassign @@ -50,8 +52,18 @@ linters: - wastedassign - wrapcheck settings: + cyclop: + max-complexity: 10 gocognit: - min-complexity: 20 + min-complexity: 15 + godoclint: + default: none + enable: [require-doc] + options: + require-doc: + ignore-unexported: false + inamedparam: + skip-single-param: false nolintlint: allow-unused: false require-explanation: true @@ -66,9 +78,26 @@ linters: arguments: [4] - name: function-result-limit arguments: [3] + - name: exported + arguments: + - check-private-receivers + - check-public-interface + - disable-stuttering-check + - disable-checks-on-constants + - disable-checks-on-variables exclusions: generated: strict paths: ["^gen/"] + rules: + # Revive owns production documentation; godoclint also checks test helpers. + - path-except: _test\.go$ + linters: [godoclint] + # Test entry points describe their scenarios through names and assertions. + # Keep documentation checks for helpers and methods in the same files. + - path: _test\.go$ + linters: [godoclint] + text: '^symbol should have a godoc' + source: '^func ((Test|Benchmark|Fuzz)([A-Z0-9_][A-Za-z0-9_]*)?\([^)]* \*testing\.(T|B|F|M)|Example([A-Z0-9_][A-Za-z0-9_]*)?\()\)[[:space:]]+\{' formatters: enable: [gofmt] exclusions: diff --git a/internal/service/scaffold/templates/kafka_handler.go.gotmpl b/internal/service/scaffold/templates/kafka_handler.go.gotmpl index f4ce9e3..325032c 100644 --- a/internal/service/scaffold/templates/kafka_handler.go.gotmpl +++ b/internal/service/scaffold/templates/kafka_handler.go.gotmpl @@ -10,8 +10,10 @@ import ( var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") +// Handler processes batches for this Kafka consumer. type Handler struct{} +// NewHandler creates a handler whose batch processing must be implemented by the application. func NewHandler() *Handler { return &Handler{} } // Handle processes the batch synchronously. It must not retain batch data after returning. diff --git a/internal/service/scaffold/testdata/full/.golangci.yml b/internal/service/scaffold/testdata/full/.golangci.yml index a01c843..865891e 100644 --- a/internal/service/scaffold/testdata/full/.golangci.yml +++ b/internal/service/scaffold/testdata/full/.golangci.yml @@ -11,6 +11,7 @@ linters: - bodyclose - containedctx - contextcheck + - cyclop - durationcheck - errcheck - errchkjson @@ -22,6 +23,7 @@ linters: - gocheckcompilerdirectives - gochecknoinits - gocognit + - godoclint - govet - inamedparam - ineffassign @@ -50,8 +52,18 @@ linters: - wastedassign - wrapcheck settings: + cyclop: + max-complexity: 10 gocognit: - min-complexity: 20 + min-complexity: 15 + godoclint: + default: none + enable: [require-doc] + options: + require-doc: + ignore-unexported: false + inamedparam: + skip-single-param: false nolintlint: allow-unused: false require-explanation: true @@ -66,9 +78,26 @@ linters: arguments: [4] - name: function-result-limit arguments: [3] + - name: exported + arguments: + - check-private-receivers + - check-public-interface + - disable-stuttering-check + - disable-checks-on-constants + - disable-checks-on-variables exclusions: generated: strict paths: ["^gen/"] + rules: + # Revive owns production documentation; godoclint also checks test helpers. + - path-except: _test\.go$ + linters: [godoclint] + # Test entry points describe their scenarios through names and assertions. + # Keep documentation checks for helpers and methods in the same files. + - path: _test\.go$ + linters: [godoclint] + text: '^symbol should have a godoc' + source: '^func ((Test|Benchmark|Fuzz)([A-Z0-9_][A-Za-z0-9_]*)?\([^)]* \*testing\.(T|B|F|M)|Example([A-Z0-9_][A-Za-z0-9_]*)?\()\)[[:space:]]+\{' formatters: enable: [gofmt] exclusions: diff --git a/internal/service/scaffold/testdata/full/internal/deps/application.go b/internal/service/scaffold/testdata/full/internal/deps/application.go index e6a88bb..c44f1de 100644 --- a/internal/service/scaffold/testdata/full/internal/deps/application.go +++ b/internal/service/scaffold/testdata/full/internal/deps/application.go @@ -12,7 +12,10 @@ import ( // application is the user-owned composition root. Add application dependencies here. type application struct{} -func (*application) RegisterHTTP(*echo.Echo) {} +// RegisterHTTP registers application HTTP handlers on the server. +func (*application) RegisterHTTP(*echo.Echo) {} + +// RegisterGRPC registers application gRPC services on the server. func (*application) RegisterGRPC(*grpc.Server) {} // provideApplication is created once. Add newly scaffolded provider calls manually. diff --git a/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go index e994717..50880a8 100644 --- a/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go +++ b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go @@ -10,8 +10,10 @@ import ( var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") +// Handler processes batches for this Kafka consumer. type Handler struct{} +// NewHandler creates a handler whose batch processing must be implemented by the application. func NewHandler() *Handler { return &Handler{} } // Handle processes the batch synchronously. It must not retain batch data after returning. diff --git a/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go index 5465c9b..dc5af6e 100644 --- a/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go +++ b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go @@ -10,8 +10,10 @@ import ( var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") +// Handler processes batches for this Kafka consumer. type Handler struct{} +// NewHandler creates a handler whose batch processing must be implemented by the application. func NewHandler() *Handler { return &Handler{} } // Handle processes the batch synchronously. It must not retain batch data after returning. diff --git a/internal/service/scaffold/testdata/minimal/.golangci.yml b/internal/service/scaffold/testdata/minimal/.golangci.yml index a01c843..865891e 100644 --- a/internal/service/scaffold/testdata/minimal/.golangci.yml +++ b/internal/service/scaffold/testdata/minimal/.golangci.yml @@ -11,6 +11,7 @@ linters: - bodyclose - containedctx - contextcheck + - cyclop - durationcheck - errcheck - errchkjson @@ -22,6 +23,7 @@ linters: - gocheckcompilerdirectives - gochecknoinits - gocognit + - godoclint - govet - inamedparam - ineffassign @@ -50,8 +52,18 @@ linters: - wastedassign - wrapcheck settings: + cyclop: + max-complexity: 10 gocognit: - min-complexity: 20 + min-complexity: 15 + godoclint: + default: none + enable: [require-doc] + options: + require-doc: + ignore-unexported: false + inamedparam: + skip-single-param: false nolintlint: allow-unused: false require-explanation: true @@ -66,9 +78,26 @@ linters: arguments: [4] - name: function-result-limit arguments: [3] + - name: exported + arguments: + - check-private-receivers + - check-public-interface + - disable-stuttering-check + - disable-checks-on-constants + - disable-checks-on-variables exclusions: generated: strict paths: ["^gen/"] + rules: + # Revive owns production documentation; godoclint also checks test helpers. + - path-except: _test\.go$ + linters: [godoclint] + # Test entry points describe their scenarios through names and assertions. + # Keep documentation checks for helpers and methods in the same files. + - path: _test\.go$ + linters: [godoclint] + text: '^symbol should have a godoc' + source: '^func ((Test|Benchmark|Fuzz)([A-Z0-9_][A-Za-z0-9_]*)?\([^)]* \*testing\.(T|B|F|M)|Example([A-Z0-9_][A-Za-z0-9_]*)?\()\)[[:space:]]+\{' formatters: enable: [gofmt] exclusions: