Skip to content

Commit 792c2b5

Browse files
Merge pull request #133 from speakeasy-api/linter
feat: add OpenAPI linter framework with 63 rules, custom rules runtime, and config converter
2 parents 62e3a60 + 195bf09 commit 792c2b5

File tree

384 files changed

+49945
-1311
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

384 files changed

+49945
-1311
lines changed

.github/workflows/release.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
permissions:
99
contents: write
1010
packages: write
11+
id-token: write
1112

1213
jobs:
1314
goreleaser:
@@ -52,3 +53,31 @@ jobs:
5253
dist/
5354
!dist/*.txt
5455
retention-days: 30
56+
57+
npm-publish:
58+
runs-on: ubuntu-latest
59+
steps:
60+
- name: Checkout
61+
uses: actions/checkout@v6
62+
63+
- name: Setup Node.js
64+
uses: actions/setup-node@v4
65+
with:
66+
node-version: 24
67+
registry-url: https://registry.npmjs.org
68+
69+
- name: Install dependencies
70+
working-directory: openapi/linter/customrules/types
71+
run: npm ci
72+
73+
- name: Set version from tag
74+
working-directory: openapi/linter/customrules/types
75+
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version
76+
77+
- name: Build
78+
working-directory: openapi/linter/customrules/types
79+
run: npm run build
80+
81+
- name: Publish
82+
working-directory: openapi/linter/customrules/types
83+
run: npm publish --provenance --access public

.github/workflows/update-cmd-dependency.yaml

Lines changed: 0 additions & 89 deletions
This file was deleted.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
name: Update Submodule Dependencies
2+
3+
on:
4+
push:
5+
branches: [main]
6+
# Only run if changes affect the root module (not submodules themselves)
7+
paths-ignore:
8+
- "cmd/openapi/**"
9+
- "openapi/linter/customrules/**"
10+
- ".github/workflows/update-submodule-dependencies.yaml"
11+
12+
permissions:
13+
contents: write
14+
pull-requests: write
15+
16+
jobs:
17+
update-dependencies:
18+
name: Update submodule dependencies
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v6
22+
23+
- name: Setup Go
24+
uses: actions/setup-go@v6
25+
with:
26+
go-version-file: "go.mod"
27+
cache: false # Disable caching to ensure fresh dependency resolution
28+
29+
- name: Update openapi/linter/customrules go.mod
30+
run: |
31+
cd openapi/linter/customrules
32+
33+
# Update to latest main commit
34+
go get github.com/speakeasy-api/openapi@main
35+
go mod tidy
36+
37+
- name: Update cmd/openapi go.mod
38+
run: |
39+
cd cmd/openapi
40+
41+
# Update to latest main commit (both main module and customrules)
42+
go get github.com/speakeasy-api/openapi@main
43+
go get github.com/speakeasy-api/openapi/openapi/linter/customrules@main
44+
go mod tidy
45+
46+
- name: Check for changes
47+
id: changes
48+
run: |
49+
CHANGED_FILES=""
50+
51+
# Check customrules module
52+
if ! git diff --quiet openapi/linter/customrules/go.mod openapi/linter/customrules/go.sum 2>/dev/null; then
53+
CHANGED_FILES="${CHANGED_FILES}customrules "
54+
fi
55+
56+
# Check cmd/openapi module
57+
if ! git diff --quiet cmd/openapi/go.mod cmd/openapi/go.sum 2>/dev/null; then
58+
CHANGED_FILES="${CHANGED_FILES}cmd "
59+
fi
60+
61+
if [ -z "$CHANGED_FILES" ]; then
62+
echo "changed=false" >> $GITHUB_OUTPUT
63+
echo "No changes detected"
64+
else
65+
echo "changed=true" >> $GITHUB_OUTPUT
66+
echo "modules=${CHANGED_FILES}" >> $GITHUB_OUTPUT
67+
echo "Changes detected in: ${CHANGED_FILES}"
68+
69+
# Get the new version for the PR description
70+
NEW_VERSION=$(grep 'github.com/speakeasy-api/openapi v' cmd/openapi/go.mod | head -1 | awk '{print $2}')
71+
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
72+
echo "Updated to version: ${NEW_VERSION}"
73+
fi
74+
75+
- name: Create Pull Request
76+
if: steps.changes.outputs.changed == 'true'
77+
uses: peter-evans/create-pull-request@v8
78+
with:
79+
token: ${{ secrets.GITHUB_TOKEN }}
80+
commit-message: |
81+
chore: update submodule dependencies to latest main
82+
83+
Updates go.mod files in submodules to use the latest commit from main.
84+
Version: ${{ steps.changes.outputs.version }}
85+
Updated modules: ${{ steps.changes.outputs.modules }}
86+
branch: bot/update-submodule-dependencies
87+
delete-branch: true
88+
title: "chore: update submodule dependencies to latest main"
89+
body: |
90+
## Updates submodule dependencies
91+
92+
This PR updates the `go.mod` files in submodules to reference the latest commit from main.
93+
94+
**Updated to:** `${{ steps.changes.outputs.version }}`
95+
**Updated modules:** ${{ steps.changes.outputs.modules }}
96+
97+
**Changes:**
98+
- Updated `github.com/speakeasy-api/openapi` dependency in submodule go.mod files
99+
- Ran `go mod tidy` to update dependencies
100+
101+
---
102+
*This PR was automatically created by the [update-submodule-dependencies workflow](.github/workflows/update-submodule-dependencies.yaml)*
103+
labels: |
104+
dependencies
105+
automated
106+
107+
- name: Summary
108+
run: |
109+
if [ "${{ steps.changes.outputs.changed }}" == "true" ]; then
110+
echo "✅ Pull request created to update submodule dependencies"
111+
echo "Version: ${{ steps.changes.outputs.version }}"
112+
echo "Modules: ${{ steps.changes.outputs.modules }}"
113+
else
114+
echo "ℹ️ No changes needed - submodule dependencies already up to date"
115+
fi

AGENTS.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,66 @@ git commit -m "feat: implement prefixEncoding and itemEncoding for OpenAPI 3.2
106106
3. **Searchability**: Easier to search and filter commits
107107
4. **Tool Compatibility**: Works better with automated tools and scripts
108108

109+
## Linter Rules
110+
111+
This project uses `golangci-lint` with strict rules. Run `mise lint` to check. The most common violations are listed below. **When you encounter a new common lint pattern not documented here, add it to this section so future sessions avoid the same mistakes.**
112+
113+
### perfsprint — Avoid `fmt.Sprintf` for Simple String Operations
114+
115+
The `perfsprint` linter flags unnecessary `fmt.Sprintf` calls. Use string concatenation or `strconv` instead.
116+
117+
#### ❌ Bad
118+
119+
```go
120+
// Single %s — just use concatenation
121+
msg := fmt.Sprintf("prefix: %s", value)
122+
123+
// Single %d — use strconv
124+
msg := fmt.Sprintf("%d", count)
125+
126+
// Writing formatted string to a writer
127+
b.WriteString(fmt.Sprintf("hello %s world %d", name, n))
128+
```
129+
130+
#### ✅ Good
131+
132+
```go
133+
// String concatenation
134+
msg := "prefix: " + value
135+
136+
// strconv for numbers
137+
msg := strconv.Itoa(count)
138+
139+
// fmt.Fprintf writes directly to the writer
140+
fmt.Fprintf(b, "hello %s world %d", name, n)
141+
142+
// For string-only format with multiple args, concatenation is fine
143+
b.WriteString(indent + "const x = " + varName + ";\n")
144+
```
145+
146+
**Rule of thumb:** If `fmt.Sprintf` has a single `%s` or `%d` verb and nothing else complex, replace it with concatenation or `strconv`. If writing to an `io.Writer`/`strings.Builder`, use `fmt.Fprintf` directly instead of `WriteString(fmt.Sprintf(...))`.
147+
148+
### staticcheck — Common Issues
149+
150+
- **QF1012**: Use `fmt.Fprintf(w, ...)` instead of `w.WriteString(fmt.Sprintf(...))` — writes directly to the writer without an intermediate string allocation.
151+
- **QF1003**: Use tagged `switch` instead of `if-else` chains on the same variable.
152+
- **S1016**: Use type conversion `TargetType(value)` instead of struct literal when types have identical fields.
153+
154+
### predeclared — Don't Shadow Built-in Identifiers
155+
156+
Avoid using `min`, `max`, `new`, `len`, `cap`, `copy`, `delete`, `error`, `any` as variable names. Use descriptive alternatives like `minVal`, `maxVal`.
157+
158+
### testifylint — Test Assertion Best Practices
159+
160+
- Use `assert.Empty(t, val)` instead of `assert.Equal(t, "", val)`
161+
- Use `assert.True(t, val)` / `assert.False(t, val)` instead of `assert.Equal(t, true/false, val)`
162+
- Use `require.Error(t, err)` instead of `assert.Error(t, err)` for error checks
163+
- Use `assert.Len(t, slice, n)` instead of `assert.Equal(t, n, len(slice))`
164+
165+
### gocritic — Code Style
166+
167+
- Convert `if-else if` chains to `switch` statements when comparing the same variable.
168+
109169
## Testing
110170

111171
Follow these testing conventions when writing Go tests in this project. Run newly added or modified test immediately after changes to make sure they work as expected before continuing with more work.

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ The `arazzo` package provides an API for working with Arazzo documents including
7272

7373
### [openapi](./openapi)
7474

75-
The `openapi` package provides an API for working with OpenAPI documents including reading, creating, mutating, walking, validating and upgrading them. Supports OpenAPI 3.0.x, 3.1.x, and 3.2.x specifications.
75+
The `openapi` package provides an API for working with OpenAPI documents including reading, creating, mutating, walking, validating, upgrading, and linting them. Supports OpenAPI 3.0.x, 3.1.x, and 3.2.x specifications.
76+
77+
The [`openapi/linter`](./openapi/linter) subpackage provides a configurable linter with 60+ built-in rules covering style, security (OWASP), and semantic validation. Custom rules can be written in TypeScript/JavaScript using the [`@speakeasy-api/openapi-linter-types`](https://www.npmjs.com/package/@speakeasy-api/openapi-linter-types) package.
7678

7779
### [swagger](./swagger)
7880

@@ -125,6 +127,7 @@ The CLI provides four main command groups:
125127
- `explore` - Interactively explore an OpenAPI specification in the terminal
126128
- `inline` - Inline all references in an OpenAPI specification
127129
- `join` - Join multiple OpenAPI documents into a single document
130+
- `lint` - Lint an OpenAPI specification for style, security, and best practices
128131
- `localize` - Localize an OpenAPI specification by copying external references to a target directory
129132
- `optimize` - Optimize an OpenAPI specification by deduplicating inline schemas
130133
- `sanitize` - Remove unwanted elements from an OpenAPI specification
@@ -150,6 +153,12 @@ The CLI provides four main command groups:
150153
# Validate an OpenAPI specification
151154
openapi spec validate ./spec.yaml
152155

156+
# Lint for style, security, and best practices
157+
openapi spec lint ./spec.yaml
158+
159+
# Lint with custom configuration
160+
openapi spec lint --config lint.yaml ./spec.yaml
161+
153162
# Bundle external references into components section
154163
openapi spec bundle ./spec.yaml ./bundled-spec.yaml
155164

arazzo/arazzo.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,11 @@ func (a *Arazzo) Validate(ctx context.Context, opts ...validation.Option) []erro
109109

110110
arazzoVersion, err := version.Parse(a.Arazzo)
111111
if err != nil {
112-
errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.version is invalid %s: %s", a.Arazzo, err.Error()), core, core.Arazzo))
112+
errs = append(errs, validation.NewValueError(validation.SeverityError, validation.RuleValidationInvalidFormat, fmt.Errorf("arazzo.version is invalid `%s`: %w", a.Arazzo, err), core, core.Arazzo))
113113
}
114114
if arazzoVersion != nil {
115115
if arazzoVersion.GreaterThan(*MaximumSupportedVersion) {
116-
errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.version only Arazzo versions between %s and %s are supported", MinimumSupportedVersion, MaximumSupportedVersion), core, core.Arazzo))
116+
errs = append(errs, validation.NewValueError(validation.SeverityError, validation.RuleValidationSupportedVersion, fmt.Errorf("arazzo.version only Arazzo versions between `%s` and `%s` are supported", MinimumSupportedVersion, MaximumSupportedVersion), core, core.Arazzo))
117117
}
118118
}
119119

@@ -125,7 +125,7 @@ func (a *Arazzo) Validate(ctx context.Context, opts ...validation.Option) []erro
125125
errs = append(errs, sourceDescription.Validate(ctx, opts...)...)
126126

127127
if _, ok := sourceDescriptionNames[sourceDescription.Name]; ok {
128-
errs = append(errs, validation.NewSliceError(validation.NewValueValidationError("sourceDescription.name %s is not unique", sourceDescription.Name), core, core.SourceDescriptions, i))
128+
errs = append(errs, validation.NewSliceError(validation.SeverityError, validation.RuleValidationDuplicateKey, fmt.Errorf("sourceDescription.name `%s` is not unique", sourceDescription.Name), core, core.SourceDescriptions, i))
129129
}
130130

131131
sourceDescriptionNames[sourceDescription.Name] = true
@@ -137,7 +137,7 @@ func (a *Arazzo) Validate(ctx context.Context, opts ...validation.Option) []erro
137137
errs = append(errs, workflow.Validate(ctx, opts...)...)
138138

139139
if _, ok := workflowIds[workflow.WorkflowID]; ok {
140-
errs = append(errs, validation.NewSliceError(validation.NewValueValidationError("workflow.workflowId %s is not unique", workflow.WorkflowID), core, core.Workflows, i))
140+
errs = append(errs, validation.NewSliceError(validation.SeverityError, validation.RuleValidationDuplicateKey, fmt.Errorf("workflow.workflowId `%s` is not unique", workflow.WorkflowID), core, core.Workflows, i))
141141
}
142142

143143
workflowIds[workflow.WorkflowID] = true

arazzo/arazzo_examples_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,6 @@ func Example_validating() {
190190
fmt.Printf("%s\n", err.Error())
191191
}
192192
// Output:
193-
// [3:3] info.version is missing
194-
// [13:9] step at least one of operationId, operationPath or workflowId fields must be set
193+
// [3:3] error validation-required-field `info.version` is required
194+
// [13:9] error validation-required-field step at least one of operationId, operationPath or workflowId fields must be set
195195
}

0 commit comments

Comments
 (0)