From 092702265ce1695c0e8baad6709527f1c0054f34 Mon Sep 17 00:00:00 2001 From: Dan Richards Date: Wed, 9 Sep 2026 22:14:57 +0100 Subject: [PATCH 1/4] chore: enforce Go modernization Run go fix across the codebase and require future changes to remain modernized through agent instructions and CI. --- .github/workflows/ci.yml | 5 +++++ CLAUDE.md | 2 ++ Makefile | 5 ++++- internal/api/client_test.go | 5 ++--- internal/api/transport/errors.go | 3 +-- internal/api/types.go | 4 ++-- internal/api/upload_test.go | 3 +-- internal/cmd/run/run.go | 4 +--- internal/cmdutil/errors.go | 6 ++---- internal/schema/schema_test.go | 30 +++++++++++++----------------- 10 files changed, 33 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1a3cf3..d623a6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,11 @@ jobs: with: go-version-file: go.mod + - name: Check Go code is modernized + run: | + make fix + git diff --exit-code + - name: Build run: make build-all diff --git a/CLAUDE.md b/CLAUDE.md index ddaf2d3..0078b9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ make build # Build binary for current platform -> ./bin/runware make build-all # Build all platforms -> ./bin make test # Run all tests (go test -race ./...) make lint # golangci-lint +make fix # Modernize Go code with go fix make snapshot # GoReleaser snapshot build make docs # Generate command reference docs make install # go install for current platform @@ -42,6 +43,7 @@ make run ARGS="..." # Run without building - Commit messages follow conventional commits (`feat:`, `fix:`, `docs:`, `test:`) - Tests live alongside source files (`*_test.go`) - Config shorthand keys in CLI map to nested yaml paths (e.g. `model` → `defaults.model`) +- Run `make fix` after every code change and retain its changes; this is a mandatory completion gate - Run `go fmt` on any files you modify before considering work complete - Run `make lint` before considering work complete; fix all lint errors - Error naming follows https://go.dev/wiki/Errors: error types end in `"Error"` (e.g. `type NotTabularError struct`), error variables start with `"Err"` (e.g. `var ErrNotFound = errors.New(...)`) diff --git a/Makefile b/Makefile index f87f51f..1f359a4 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GOFLAGS = -trimpath BINARY=runware -.PHONY: build build-all windows-amd64 windows-arm64 darwin darwin-arm64 darwin-amd64 linux-amd64 linux-arm64 run test lint clean install snapshot docs go-tidy +.PHONY: build build-all windows-amd64 windows-arm64 darwin darwin-arm64 darwin-amd64 linux-amd64 linux-arm64 run test lint fix clean install snapshot docs go-tidy build: go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/${BINARY} ./cmd/runware @@ -47,6 +47,9 @@ test: lint: golangci-lint run +fix: + go fix ./... + clean: rm -rf bin dist diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 15f035c..003589d 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "maps" "net/http" "net/http/httptest" "strings" @@ -165,9 +166,7 @@ func TestSend_NoAPIKey(t *testing.T) { func successItem(t *testing.T, extra map[string]any) json.RawMessage { t.Helper() m := map[string]any{fieldStatus: "success"} - for k, v := range extra { - m[k] = v - } + maps.Copy(m, extra) return rawJSON(t, m) } diff --git a/internal/api/transport/errors.go b/internal/api/transport/errors.go index 0f64a1e..de70d3f 100644 --- a/internal/api/transport/errors.go +++ b/internal/api/transport/errors.go @@ -474,8 +474,7 @@ func IsAuthError(err error) bool { if errors.Is(err, ErrNoAPIKey) { return true } - var re *RunwareError - if errors.As(err, &re) { + if re, ok := errors.AsType[*RunwareError](err); ok { return re.Code == CodeAuth } return false diff --git a/internal/api/types.go b/internal/api/types.go index 7fe59ea..304d79d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -137,7 +137,7 @@ type TeamMember struct { Name string `json:"name"` Email string `json:"email"` Roles []string `json:"roles"` - JoinedAt time.Time `json:"joinedAt,omitempty"` + JoinedAt time.Time `json:"joinedAt"` } // APIKeyInfo describes a single API key on the account. @@ -147,7 +147,7 @@ type APIKeyInfo struct { Description string `json:"description,omitempty"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"createdAt"` - LastUsedAt time.Time `json:"lastUsedAt,omitempty"` + LastUsedAt time.Time `json:"lastUsedAt"` Requests int `json:"requests,omitempty"` } diff --git a/internal/api/upload_test.go b/internal/api/upload_test.go index 9ded1c3..7ba3575 100644 --- a/internal/api/upload_test.go +++ b/internal/api/upload_test.go @@ -275,8 +275,7 @@ func TestModelUpload_StreamErrorPropagated(t *testing.T) { } _, err := NewClient(mock, slog.Default()).ModelUpload(context.Background(), minimalUploadRequest(), ModelUploadOptions{}) - var re *transport.RunwareError - if !errors.As(err, &re) { + if _, ok := errors.AsType[*transport.RunwareError](err); !ok { t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) } } diff --git a/internal/cmd/run/run.go b/internal/cmd/run/run.go index e179099..24666f2 100644 --- a/internal/cmd/run/run.go +++ b/internal/cmd/run/run.go @@ -218,9 +218,7 @@ The model positional argument may be omitted when --preset supplies one.`, // so that --preset runs fail consistently with non-preset runs. func mergePresetParams(presetParams map[string]string, kvArgs []string) (map[string]string, error) { merged := make(map[string]string, len(presetParams)+len(kvArgs)) - for k, v := range presetParams { - merged[k] = v - } + maps.Copy(merged, presetParams) for _, kv := range kvArgs { k, v, ok := strings.Cut(kv, "=") if !ok { diff --git a/internal/cmdutil/errors.go b/internal/cmdutil/errors.go index f43b96b..85c84c2 100644 --- a/internal/cmdutil/errors.go +++ b/internal/cmdutil/errors.go @@ -44,8 +44,7 @@ func PrintErrorTo(logger *log.Logger, w io.Writer, format output.Format, err err return } - var re *transport.RunwareError - if errors.As(err, &re) { + if re, ok := errors.AsType[*transport.RunwareError](err); ok { if isStructuredFormat(format) { writeStructuredError(w, format, structuredErrors(re.APIFields())) return @@ -78,8 +77,7 @@ func PrintErrorMsg(logger *log.Logger, format output.Format, message string, err // PrintErrorMsgTo logs a custom message and writes structured output to w. func PrintErrorMsgTo(logger *log.Logger, w io.Writer, format output.Format, message string, err error) { - var re *transport.RunwareError - if errors.As(err, &re) { + if re, ok := errors.AsType[*transport.RunwareError](err); ok { if isStructuredFormat(format) { writeStructuredError(w, format, map[string]any{ fieldMessage: message, diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index 78109dd..3f68b1e 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -1257,13 +1257,11 @@ const ( testTypeNumber = "number" ) -func f64(v float64) *float64 { return &v } - // boundedWidthNode mirrors a real image model's width: [512, 2048], step 16. func boundedWidthNode() schema.Node { return schema.Node{ Properties: map[string]schema.Node{ - testFieldWidth: {Type: schema.TypeInteger, Minimum: f64(512), Maximum: f64(2048), MultipleOf: f64(16)}, + testFieldWidth: {Type: schema.TypeInteger, Minimum: new(float64(512)), Maximum: new(float64(2048)), MultipleOf: new(float64(16))}, }, } } @@ -1318,7 +1316,7 @@ func TestValidateNumericConstraints_NoConstraint_Passes(t *testing.T) { func TestValidateNumericConstraints_NegativeMinimumAllowed(t *testing.T) { node := schema.Node{ Properties: map[string]schema.Node{ - testFieldWeight: {Type: testTypeNumber, Minimum: f64(-4), Maximum: f64(4), MultipleOf: f64(0.01)}, + testFieldWeight: {Type: testTypeNumber, Minimum: new(float64(-4)), Maximum: new(float64(4)), MultipleOf: new(0.01)}, }, } if err := schema.ValidateConstraints(node, map[string]any{testFieldWeight: -3.5}); err != nil { @@ -1332,7 +1330,7 @@ func TestValidateNumericConstraints_NestedObject(t *testing.T) { testFieldAccel: { Type: schema.TypeObject, Properties: map[string]schema.Node{ - testFieldCachePct: {Type: schema.TypeInteger, Minimum: f64(1), Maximum: f64(100)}, + testFieldCachePct: {Type: schema.TypeInteger, Minimum: new(float64(1)), Maximum: new(float64(100))}, }, }, }, @@ -1355,7 +1353,7 @@ func TestValidateNumericConstraints_ArrayItems(t *testing.T) { Items: &schema.Node{ Type: schema.TypeObject, Properties: map[string]schema.Node{ - testFieldWeight: {Type: testTypeNumber, Minimum: f64(-4), Maximum: f64(4)}, + testFieldWeight: {Type: testTypeNumber, Minimum: new(float64(-4)), Maximum: new(float64(4))}, }, }, }, @@ -1374,7 +1372,7 @@ func TestValidateNumericConstraints_ArrayItems(t *testing.T) { func TestValidateNumericConstraints_FloatMultipleOf(t *testing.T) { node := schema.Node{ Properties: map[string]schema.Node{ - testFieldCFGScale: {Type: testTypeNumber, Minimum: f64(1), Maximum: f64(20), MultipleOf: f64(0.01)}, + testFieldCFGScale: {Type: testTypeNumber, Minimum: new(float64(1)), Maximum: new(float64(20)), MultipleOf: new(0.01)}, }, } if err := schema.ValidateConstraints(node, map[string]any{testFieldCFGScale: 4.5}); err != nil { @@ -1391,8 +1389,6 @@ func TestValidateNumericConstraints_FloatMultipleOf(t *testing.T) { // ---- ValidateConstraints: NaN / Inf / exclusive bounds / string / array ---- -func iptr(v int) *int { return &v } - const ( testFieldImageURL = "imageURL" testFieldTaskUUID = "taskUUID" @@ -1403,7 +1399,7 @@ const ( func TestValidateConstraints_RejectsNaN(t *testing.T) { node := schema.Node{Properties: map[string]schema.Node{ - testFieldCFGScale: {Type: testTypeNumber, Minimum: f64(1), Maximum: f64(20)}, + testFieldCFGScale: {Type: testTypeNumber, Minimum: new(float64(1)), Maximum: new(float64(20))}, }} err := schema.ValidateConstraints(node, map[string]any{testFieldCFGScale: math.NaN()}) if err == nil { @@ -1416,7 +1412,7 @@ func TestValidateConstraints_RejectsNaN(t *testing.T) { func TestValidateConstraints_RejectsInf(t *testing.T) { node := schema.Node{Properties: map[string]schema.Node{ - testFieldCFGScale: {Type: testTypeNumber, Minimum: f64(1), Maximum: f64(20)}, + testFieldCFGScale: {Type: testTypeNumber, Minimum: new(float64(1)), Maximum: new(float64(20))}, }} err := schema.ValidateConstraints(node, map[string]any{testFieldCFGScale: math.Inf(1)}) if err == nil { @@ -1429,7 +1425,7 @@ func TestValidateConstraints_RejectsInf(t *testing.T) { func TestValidateConstraints_ExclusiveMinimum(t *testing.T) { node := schema.Node{Properties: map[string]schema.Node{ - testFieldCFGScale: {Type: testTypeNumber, ExclusiveMinimum: f64(0)}, + testFieldCFGScale: {Type: testTypeNumber, ExclusiveMinimum: new(float64(0))}, }} if err := schema.ValidateConstraints(node, map[string]any{testFieldCFGScale: 0.0}); err == nil { t.Fatal("expected error for value equal to the exclusive minimum") @@ -1441,7 +1437,7 @@ func TestValidateConstraints_ExclusiveMinimum(t *testing.T) { func TestValidateConstraints_ExclusiveMaximum(t *testing.T) { node := schema.Node{Properties: map[string]schema.Node{ - testFieldCFGScale: {Type: testTypeNumber, ExclusiveMaximum: f64(1)}, + testFieldCFGScale: {Type: testTypeNumber, ExclusiveMaximum: new(float64(1))}, }} if err := schema.ValidateConstraints(node, map[string]any{testFieldCFGScale: 1.0}); err == nil { t.Fatal("expected error for value equal to the exclusive maximum") @@ -1450,7 +1446,7 @@ func TestValidateConstraints_ExclusiveMaximum(t *testing.T) { func TestValidateConstraints_MinLength(t *testing.T) { node := schema.Node{Properties: map[string]schema.Node{ - testFieldPositivePrompt: {Type: schema.TypeString, MinLength: iptr(3)}, + testFieldPositivePrompt: {Type: schema.TypeString, MinLength: new(3)}, }} if err := schema.ValidateConstraints(node, map[string]any{testFieldPositivePrompt: "ab"}); err == nil { t.Fatal("expected error for a string below minLength") @@ -1462,7 +1458,7 @@ func TestValidateConstraints_MinLength(t *testing.T) { func TestValidateConstraints_MaxLength(t *testing.T) { node := schema.Node{Properties: map[string]schema.Node{ - testFieldPositivePrompt: {Type: schema.TypeString, MaxLength: iptr(5)}, + testFieldPositivePrompt: {Type: schema.TypeString, MaxLength: new(5)}, }} err := schema.ValidateConstraints(node, map[string]any{testFieldPositivePrompt: "abcdef"}) if err == nil { @@ -1536,7 +1532,7 @@ func TestValidateConstraints_UnknownFormatPasses(t *testing.T) { func TestValidateConstraints_MinItems(t *testing.T) { node := schema.Node{Properties: map[string]schema.Node{ - testFieldTags: {Type: schema.TypeArray, MinItems: iptr(2)}, + testFieldTags: {Type: schema.TypeArray, MinItems: new(2)}, }} err := schema.ValidateConstraints(node, map[string]any{testFieldTags: []any{"a"}}) if err == nil { @@ -1549,7 +1545,7 @@ func TestValidateConstraints_MinItems(t *testing.T) { func TestValidateConstraints_MaxItems(t *testing.T) { node := schema.Node{Properties: map[string]schema.Node{ - testFieldTags: {Type: schema.TypeArray, MaxItems: iptr(2)}, + testFieldTags: {Type: schema.TypeArray, MaxItems: new(2)}, }} if err := schema.ValidateConstraints(node, map[string]any{testFieldTags: []any{"a", "b", "c"}}); err == nil { t.Fatal("expected error for an array above maxItems") From 0604be12e5578f08358a586e6fa8817b4f32ac12 Mon Sep 17 00:00:00 2001 From: Dan Richards Date: Wed, 9 Sep 2026 22:24:55 +0100 Subject: [PATCH 2/4] ci: detect all go fix changes --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d623a6c..7844f48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,11 @@ jobs: - name: Check Go code is modernized run: | make fix - git diff --exit-code + if [ -n "$(git status --porcelain)" ]; then + echo "Go code is not modernized. Run 'make fix' and commit the result." + git status --porcelain + exit 1 + fi - name: Build run: make build-all From 00865692fb2db15684554aac52d7ff0c19cc474d Mon Sep 17 00:00:00 2001 From: Dan Richards Date: Wed, 9 Sep 2026 22:27:31 +0100 Subject: [PATCH 3/4] chore(go): use non-mutating fix gate --- .github/workflows/ci.yml | 8 +------- CLAUDE.md | 3 ++- Makefile | 7 +++++-- README.md | 4 +++- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7844f48..fff3330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,7 @@ jobs: go-version-file: go.mod - name: Check Go code is modernized - run: | - make fix - if [ -n "$(git status --porcelain)" ]; then - echo "Go code is not modernized. Run 'make fix' and commit the result." - git status --porcelain - exit 1 - fi + run: make check-go-fix - name: Build run: make build-all diff --git a/CLAUDE.md b/CLAUDE.md index 0078b9c..03f5c00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ make build-all # Build all platforms -> ./bin make test # Run all tests (go test -race ./...) make lint # golangci-lint make fix # Modernize Go code with go fix +make check-go-fix # Fail when go fix would change Go source make snapshot # GoReleaser snapshot build make docs # Generate command reference docs make install # go install for current platform @@ -43,7 +44,7 @@ make run ARGS="..." # Run without building - Commit messages follow conventional commits (`feat:`, `fix:`, `docs:`, `test:`) - Tests live alongside source files (`*_test.go`) - Config shorthand keys in CLI map to nested yaml paths (e.g. `model` → `defaults.model`) -- Run `make fix` after every code change and retain its changes; this is a mandatory completion gate +- Run `make fix` after every change that touches Go code and retain its modernizations; `make check-go-fix` and CI reject code that `go fix` would change - Run `go fmt` on any files you modify before considering work complete - Run `make lint` before considering work complete; fix all lint errors - Error naming follows https://go.dev/wiki/Errors: error types end in `"Error"` (e.g. `type NotTabularError struct`), error variables start with `"Err"` (e.g. `var ErrNotFound = errors.New(...)`) diff --git a/Makefile b/Makefile index 1f359a4..03975bb 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GOFLAGS = -trimpath BINARY=runware -.PHONY: build build-all windows-amd64 windows-arm64 darwin darwin-arm64 darwin-amd64 linux-amd64 linux-arm64 run test lint fix clean install snapshot docs go-tidy +.PHONY: build build-all windows-amd64 windows-arm64 darwin darwin-arm64 darwin-amd64 linux-amd64 linux-arm64 run test lint fix check-go-fix clean install snapshot docs go-tidy build: go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/${BINARY} ./cmd/runware @@ -44,12 +44,15 @@ run: test: go test -race ./... -lint: +lint: check-go-fix golangci-lint run fix: go fix ./... +check-go-fix: + go fix -diff ./... + clean: rm -rf bin dist diff --git a/README.md b/README.md index f81c0dc..f826fdd 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,9 @@ make build-all # Build all platforms → ./bin/ make install # go install for current platform make run ARGS="..." # Run without building (e.g. make run ARGS="ping") make test # Run all tests -make lint # Run golangci-lint +make fix # Apply go fix modernizations +make check-go-fix # Fail when go fix would change Go source +make lint # Check go fix, then run golangci-lint make docs # Regenerate ./docs/ command reference make snapshot # GoReleaser snapshot build make clean # Remove ./bin and ./dist From 3c7c39e2d40de3e5de3b5cd23e51995d7d0ee844 Mon Sep 17 00:00:00 2001 From: Dan Richards Date: Wed, 9 Sep 2026 22:42:44 +0100 Subject: [PATCH 4/4] fix(account): omit zero timestamps --- internal/api/types.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/types.go b/internal/api/types.go index 304d79d..fd999af 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -137,7 +137,7 @@ type TeamMember struct { Name string `json:"name"` Email string `json:"email"` Roles []string `json:"roles"` - JoinedAt time.Time `json:"joinedAt"` + JoinedAt time.Time `json:"joinedAt,omitzero" yaml:"joinedat,omitempty"` } // APIKeyInfo describes a single API key on the account. @@ -147,7 +147,7 @@ type APIKeyInfo struct { Description string `json:"description,omitempty"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"createdAt"` - LastUsedAt time.Time `json:"lastUsedAt"` + LastUsedAt time.Time `json:"lastUsedAt,omitzero" yaml:"lastusedat,omitempty"` Requests int `json:"requests,omitempty"` }