Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2974690
feat: add comprehensive OpenAPI linter framework with 63 rules
TristanSpeakEasy Jan 22, 2026
a1afea7
Update openapi/linter/rules/contact_properties.go
TristanSpeakEasy Jan 22, 2026
9639416
fix
TristanSpeakEasy Jan 22, 2026
b12b514
fix
TristanSpeakEasy Jan 22, 2026
2dc0185
fix
TristanSpeakEasy Jan 22, 2026
908ef20
fix
TristanSpeakEasy Jan 22, 2026
967970d
fix
TristanSpeakEasy Jan 23, 2026
c511932
fix
TristanSpeakEasy Jan 23, 2026
d026835
Merge branch 'main' into linter
TristanSpeakEasy Jan 24, 2026
1e45865
fix
TristanSpeakEasy Jan 28, 2026
ab95606
Merge branch 'main' into linter
TristanSpeakEasy Jan 28, 2026
0f6e2fd
fix: add URL format validation for openIdConnectUrl in security schemes
TristanSpeakEasy Jan 28, 2026
4691b1d
fix
TristanSpeakEasy Jan 28, 2026
88dadee
fix
TristanSpeakEasy Jan 28, 2026
fd0b93e
fix
TristanSpeakEasy Jan 29, 2026
2317008
fix
TristanSpeakEasy Jan 29, 2026
202cbb8
fix
TristanSpeakEasy Jan 30, 2026
a025e9a
fix
TristanSpeakEasy Jan 30, 2026
fdfade9
fix
TristanSpeakEasy Jan 30, 2026
4c1a374
fix
TristanSpeakEasy Feb 2, 2026
9370541
fix
TristanSpeakEasy Feb 2, 2026
21898ea
fix
TristanSpeakEasy Feb 2, 2026
9a220ff
fix
TristanSpeakEasy Feb 3, 2026
c174a3c
fix
TristanSpeakEasy Feb 4, 2026
332182a
fix
TristanSpeakEasy Feb 4, 2026
07cf8e7
fix
TristanSpeakEasy Feb 4, 2026
54a315b
custom rules support
TristanSpeakEasy Feb 5, 2026
0cd2db1
fix
TristanSpeakEasy Feb 5, 2026
d3fbe70
fix
TristanSpeakEasy Feb 5, 2026
611fd2f
feat: add custom naming strategy for Localize
TristanSpeakEasy Feb 6, 2026
2483fb8
fix: formating of dynamic values in errors
TristanSpeakEasy Feb 6, 2026
6481932
ci: add release action for types
TristanSpeakEasy Feb 6, 2026
000051f
fix
TristanSpeakEasy Feb 6, 2026
d804ab9
converter
TristanSpeakEasy Feb 6, 2026
5216d92
fix
TristanSpeakEasy Feb 6, 2026
36386df
fix
TristanSpeakEasy Feb 6, 2026
195bf09
fix
TristanSpeakEasy Feb 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
29 changes: 29 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
permissions:
contents: write
packages: write
id-token: write

jobs:
goreleaser:
Expand Down Expand Up @@ -52,3 +53,31 @@ jobs:
dist/
!dist/*.txt
retention-days: 30

npm-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org

- name: Install dependencies
working-directory: openapi/linter/customrules/types
run: npm ci

- name: Set version from tag
working-directory: openapi/linter/customrules/types
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version

- name: Build
working-directory: openapi/linter/customrules/types
run: npm run build

- name: Publish
working-directory: openapi/linter/customrules/types
run: npm publish --provenance --access public
89 changes: 0 additions & 89 deletions .github/workflows/update-cmd-dependency.yaml

This file was deleted.

115 changes: 115 additions & 0 deletions .github/workflows/update-submodule-dependencies.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Update Submodule Dependencies

on:
push:
branches: [main]
# Only run if changes affect the root module (not submodules themselves)
paths-ignore:
- "cmd/openapi/**"
- "openapi/linter/customrules/**"
- ".github/workflows/update-submodule-dependencies.yaml"

permissions:
contents: write
pull-requests: write

jobs:
update-dependencies:
name: Update submodule dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache: false # Disable caching to ensure fresh dependency resolution

- name: Update openapi/linter/customrules go.mod
run: |
cd openapi/linter/customrules

# Update to latest main commit
go get github.com/speakeasy-api/openapi@main
go mod tidy

- name: Update cmd/openapi go.mod
run: |
cd cmd/openapi

# Update to latest main commit (both main module and customrules)
go get github.com/speakeasy-api/openapi@main
go get github.com/speakeasy-api/openapi/openapi/linter/customrules@main
go mod tidy

- name: Check for changes
id: changes
run: |
CHANGED_FILES=""

# Check customrules module
if ! git diff --quiet openapi/linter/customrules/go.mod openapi/linter/customrules/go.sum 2>/dev/null; then
CHANGED_FILES="${CHANGED_FILES}customrules "
fi

# Check cmd/openapi module
if ! git diff --quiet cmd/openapi/go.mod cmd/openapi/go.sum 2>/dev/null; then
CHANGED_FILES="${CHANGED_FILES}cmd "
fi

if [ -z "$CHANGED_FILES" ]; then
echo "changed=false" >> $GITHUB_OUTPUT
echo "No changes detected"
else
echo "changed=true" >> $GITHUB_OUTPUT
echo "modules=${CHANGED_FILES}" >> $GITHUB_OUTPUT
echo "Changes detected in: ${CHANGED_FILES}"

# Get the new version for the PR description
NEW_VERSION=$(grep 'github.com/speakeasy-api/openapi v' cmd/openapi/go.mod | head -1 | awk '{print $2}')
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "Updated to version: ${NEW_VERSION}"
fi

- name: Create Pull Request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: |
chore: update submodule dependencies to latest main

Updates go.mod files in submodules to use the latest commit from main.
Version: ${{ steps.changes.outputs.version }}
Updated modules: ${{ steps.changes.outputs.modules }}
branch: bot/update-submodule-dependencies
delete-branch: true
title: "chore: update submodule dependencies to latest main"
body: |
## Updates submodule dependencies

This PR updates the `go.mod` files in submodules to reference the latest commit from main.

**Updated to:** `${{ steps.changes.outputs.version }}`
**Updated modules:** ${{ steps.changes.outputs.modules }}

**Changes:**
- Updated `github.com/speakeasy-api/openapi` dependency in submodule go.mod files
- Ran `go mod tidy` to update dependencies

---
*This PR was automatically created by the [update-submodule-dependencies workflow](.github/workflows/update-submodule-dependencies.yaml)*
labels: |
dependencies
automated

- name: Summary
run: |
if [ "${{ steps.changes.outputs.changed }}" == "true" ]; then
echo "✅ Pull request created to update submodule dependencies"
echo "Version: ${{ steps.changes.outputs.version }}"
echo "Modules: ${{ steps.changes.outputs.modules }}"
else
echo "ℹ️ No changes needed - submodule dependencies already up to date"
fi
60 changes: 60 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,66 @@ git commit -m "feat: implement prefixEncoding and itemEncoding for OpenAPI 3.2
3. **Searchability**: Easier to search and filter commits
4. **Tool Compatibility**: Works better with automated tools and scripts

## Linter Rules

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.**

### perfsprint — Avoid `fmt.Sprintf` for Simple String Operations

The `perfsprint` linter flags unnecessary `fmt.Sprintf` calls. Use string concatenation or `strconv` instead.

#### ❌ Bad

```go
// Single %s — just use concatenation
msg := fmt.Sprintf("prefix: %s", value)

// Single %d — use strconv
msg := fmt.Sprintf("%d", count)

// Writing formatted string to a writer
b.WriteString(fmt.Sprintf("hello %s world %d", name, n))
```

#### ✅ Good

```go
// String concatenation
msg := "prefix: " + value

// strconv for numbers
msg := strconv.Itoa(count)

// fmt.Fprintf writes directly to the writer
fmt.Fprintf(b, "hello %s world %d", name, n)

// For string-only format with multiple args, concatenation is fine
b.WriteString(indent + "const x = " + varName + ";\n")
```

**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(...))`.

### staticcheck — Common Issues

- **QF1012**: Use `fmt.Fprintf(w, ...)` instead of `w.WriteString(fmt.Sprintf(...))` — writes directly to the writer without an intermediate string allocation.
- **QF1003**: Use tagged `switch` instead of `if-else` chains on the same variable.
- **S1016**: Use type conversion `TargetType(value)` instead of struct literal when types have identical fields.

### predeclared — Don't Shadow Built-in Identifiers

Avoid using `min`, `max`, `new`, `len`, `cap`, `copy`, `delete`, `error`, `any` as variable names. Use descriptive alternatives like `minVal`, `maxVal`.

### testifylint — Test Assertion Best Practices

- Use `assert.Empty(t, val)` instead of `assert.Equal(t, "", val)`
- Use `assert.True(t, val)` / `assert.False(t, val)` instead of `assert.Equal(t, true/false, val)`
- Use `require.Error(t, err)` instead of `assert.Error(t, err)` for error checks
- Use `assert.Len(t, slice, n)` instead of `assert.Equal(t, n, len(slice))`

### gocritic — Code Style

- Convert `if-else if` chains to `switch` statements when comparing the same variable.

## Testing

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.
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ The `arazzo` package provides an API for working with Arazzo documents including

### [openapi](./openapi)

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.
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.

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.

### [swagger](./swagger)

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

# Lint for style, security, and best practices
openapi spec lint ./spec.yaml

# Lint with custom configuration
openapi spec lint --config lint.yaml ./spec.yaml

# Bundle external references into components section
openapi spec bundle ./spec.yaml ./bundled-spec.yaml

Expand Down
8 changes: 4 additions & 4 deletions arazzo/arazzo.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,11 @@ func (a *Arazzo) Validate(ctx context.Context, opts ...validation.Option) []erro

arazzoVersion, err := version.Parse(a.Arazzo)
if err != nil {
errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.version is invalid %s: %s", a.Arazzo, err.Error()), core, core.Arazzo))
errs = append(errs, validation.NewValueError(validation.SeverityError, validation.RuleValidationInvalidFormat, fmt.Errorf("arazzo.version is invalid `%s`: %w", a.Arazzo, err), core, core.Arazzo))
}
if arazzoVersion != nil {
if arazzoVersion.GreaterThan(*MaximumSupportedVersion) {
errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.version only Arazzo versions between %s and %s are supported", MinimumSupportedVersion, MaximumSupportedVersion), core, core.Arazzo))
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))
}
}

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

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

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

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

workflowIds[workflow.WorkflowID] = true
Expand Down
4 changes: 2 additions & 2 deletions arazzo/arazzo_examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,6 @@ func Example_validating() {
fmt.Printf("%s\n", err.Error())
}
// Output:
// [3:3] info.version is missing
// [13:9] step at least one of operationId, operationPath or workflowId fields must be set
// [3:3] error validation-required-field `info.version` is required
// [13:9] error validation-required-field step at least one of operationId, operationPath or workflowId fields must be set
}
Loading
Loading