Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/product/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions docs/product/scaffold-and-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion examples/orders-api/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ linters:
- bodyclose
- containedctx
- contextcheck
- cyclop
- durationcheck
- errcheck
- errchkjson
Expand All @@ -22,6 +23,7 @@ linters:
- gocheckcompilerdirectives
- gochecknoinits
- gocognit
- godoclint
- govet
- inamedparam
- ineffassign
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions examples/orders-api/internal/deps/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
3 changes: 3 additions & 0 deletions examples/orders-api/internal/orders/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions examples/orders-api/internal/orders/orders.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,34 @@ 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
TotalCents int64
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)
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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) {
Expand Down
100 changes: 100 additions & 0 deletions internal/service/scaffold/golangci_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
2 changes: 2 additions & 0 deletions internal/service/scaffold/templates/application.go.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
31 changes: 30 additions & 1 deletion internal/service/scaffold/templates/golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ linters:
- bodyclose
- containedctx
- contextcheck
- cyclop
- durationcheck
- errcheck
- errchkjson
Expand All @@ -22,6 +23,7 @@ linters:
- gocheckcompilerdirectives
- gochecknoinits
- gocognit
- godoclint
- govet
- inamedparam
- ineffassign
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions internal/service/scaffold/templates/kafka_handler.go.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading