Skip to content

Commit d804ab9

Browse files
converter
1 parent 000051f commit d804ab9

40 files changed

Lines changed: 5482 additions & 0 deletions

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.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package openapi
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
9+
"github.com/speakeasy-api/openapi/openapi/linter/converter"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var convertRulesCmd = &cobra.Command{
14+
Use: "convert-rules <config-file>",
15+
Short: "Convert Spectral/Vacuum/legacy configs to native linter format",
16+
Long: `Convert a Spectral, Vacuum, or legacy Speakeasy lint config into the native
17+
linter format. This generates:
18+
19+
- A lint.yaml config file with mapped rule overrides
20+
- TypeScript rule files for custom rules that don't have native equivalents
21+
22+
Supported input formats:
23+
- Spectral configs (.spectral.yml / .spectral.yaml)
24+
- Vacuum configs (Spectral-compatible format)
25+
- Legacy Speakeasy lint.yaml (with lintVersion/defaultRuleset/rulesets)
26+
27+
Examples:
28+
openapi spec lint convert-rules .spectral.yml
29+
openapi spec lint convert-rules .spectral.yml --output ./converted
30+
openapi spec lint convert-rules lint.yaml --dry-run
31+
openapi spec lint convert-rules .spectral.yml --force`,
32+
Args: cobra.ExactArgs(1),
33+
Run: runConvertRules,
34+
}
35+
36+
var (
37+
convertOutput string
38+
convertRulesDir string
39+
convertForce bool
40+
convertDryRun bool
41+
)
42+
43+
func init() {
44+
convertRulesCmd.Flags().StringVarP(&convertOutput, "output", "o", ".", "Output directory for generated files")
45+
convertRulesCmd.Flags().StringVar(&convertRulesDir, "rules-dir", "./rules", "Subdirectory for generated .ts rule files")
46+
convertRulesCmd.Flags().BoolVarP(&convertForce, "force", "f", false, "Overwrite existing files")
47+
convertRulesCmd.Flags().BoolVar(&convertDryRun, "dry-run", false, "Print summary without writing files")
48+
49+
lintCmd.AddCommand(convertRulesCmd)
50+
}
51+
52+
func runConvertRules(cmd *cobra.Command, args []string) {
53+
configFile := args[0]
54+
55+
// Parse the input config
56+
ir, err := converter.ParseFile(configFile)
57+
if err != nil {
58+
fmt.Fprintf(os.Stderr, "Error parsing config: %v\n", err)
59+
os.Exit(1)
60+
}
61+
62+
// Generate native output
63+
result, err := converter.Generate(ir,
64+
converter.WithRulesDir(convertRulesDir),
65+
)
66+
if err != nil {
67+
fmt.Fprintf(os.Stderr, "Error generating output: %v\n", err)
68+
os.Exit(1)
69+
}
70+
71+
// Print summary
72+
printConvertSummary(result, configFile)
73+
74+
// Print warnings
75+
if len(result.Warnings) > 0 {
76+
fmt.Println("\nWarnings:")
77+
for _, w := range result.Warnings {
78+
prefix := ""
79+
if w.RuleID != "" {
80+
prefix = fmt.Sprintf("[%s] ", w.RuleID)
81+
}
82+
fmt.Printf(" %s(%s) %s\n", prefix, w.Phase, w.Message)
83+
}
84+
}
85+
86+
if convertDryRun {
87+
fmt.Println("\n--dry-run: no files written")
88+
return
89+
}
90+
91+
// Check for existing files unless --force
92+
if !convertForce {
93+
configPath := filepath.Join(convertOutput, "lint.yaml")
94+
if _, err := os.Stat(configPath); err == nil {
95+
fmt.Fprintf(os.Stderr, "Error: %s already exists (use --force to overwrite)\n", configPath)
96+
os.Exit(1)
97+
}
98+
rulesPath := filepath.Join(convertOutput, convertRulesDir)
99+
if _, err := os.Stat(rulesPath); err == nil {
100+
fmt.Fprintf(os.Stderr, "Error: %s already exists (use --force to overwrite)\n", rulesPath)
101+
os.Exit(1)
102+
}
103+
}
104+
105+
// Ensure output directory exists
106+
if err := os.MkdirAll(convertOutput, 0o755); err != nil { //nolint:gosec
107+
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
108+
os.Exit(1)
109+
}
110+
111+
// Write files
112+
if err := result.WriteFiles(convertOutput); err != nil {
113+
fmt.Fprintf(os.Stderr, "Error writing files: %v\n", err)
114+
os.Exit(1)
115+
}
116+
117+
fmt.Printf("\nFiles written to %s\n", convertOutput)
118+
}
119+
120+
func printConvertSummary(result *converter.GenerateResult, inputFile string) {
121+
fmt.Printf("Converting: %s\n\n", inputFile)
122+
123+
// Extends
124+
if len(result.Config.Extends) > 0 {
125+
fmt.Printf("Extends: %v\n", result.Config.Extends)
126+
}
127+
128+
// Rule overrides
129+
overrideCount := 0
130+
for _, entry := range result.Config.Rules {
131+
if entry.Disabled != nil || entry.Severity != nil {
132+
overrideCount++
133+
}
134+
}
135+
if overrideCount > 0 {
136+
fmt.Printf("Rule overrides: %d\n", overrideCount)
137+
}
138+
139+
// Generated rules
140+
if len(result.GeneratedRules) > 0 {
141+
ruleIDs := sortedKeys(result.GeneratedRules)
142+
fmt.Printf("Generated rules: %d\n", len(result.GeneratedRules))
143+
for _, ruleID := range ruleIDs {
144+
fmt.Printf(" - %s.ts\n", ruleID)
145+
}
146+
147+
// Files to be written
148+
fmt.Println("\nFiles:")
149+
fmt.Println(" - lint.yaml")
150+
for _, ruleID := range ruleIDs {
151+
fmt.Printf(" - %s/%s.ts\n", convertRulesDir, ruleID)
152+
}
153+
} else {
154+
fmt.Println("\nFiles:")
155+
fmt.Println(" - lint.yaml")
156+
}
157+
}
158+
159+
func sortedKeys(m map[string]string) []string {
160+
keys := make([]string, 0, len(m))
161+
for k := range m {
162+
keys = append(keys, k)
163+
}
164+
sort.Strings(keys)
165+
return keys
166+
}

go.work

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ use (
44
.
55
./cmd/openapi
66
./jsonschema/oas3/tests
7+
./openapi/linter/converter/tests
78
./openapi/linter/customrules
89
)

linter/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func (c *Config) UnmarshalYAML(value *yaml.Node) error {
4848
Rules []RuleEntry `yaml:"rules,omitempty"`
4949
Categories map[string]CategoryConfig `yaml:"categories,omitempty"`
5050
OutputFormat OutputFormat `yaml:"output_format,omitempty"`
51+
CustomRules *CustomRulesConfig `yaml:"custom_rules,omitempty"`
5152
}
5253
if err := value.Decode(&raw); err != nil {
5354
return err
@@ -78,6 +79,7 @@ func (c *Config) UnmarshalYAML(value *yaml.Node) error {
7879
c.Rules = raw.Rules
7980
c.Categories = raw.Categories
8081
c.OutputFormat = raw.OutputFormat
82+
c.CustomRules = raw.CustomRules
8183
return nil
8284
}
8385

linter/config_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ func TestLoadConfig_MatchRegex(t *testing.T) {
8989
assert.Equal(t, regexp.MustCompile(".*title.*").String(), config.Rules[0].Match.String())
9090
}
9191

92+
func TestLoadConfig_CustomRulesRoundTrip(t *testing.T) {
93+
t.Parallel()
94+
95+
configYAML := `extends: all
96+
custom_rules:
97+
paths:
98+
- "./rules/*.ts"
99+
- "./extra/*.ts"`
100+
config, err := linter.LoadConfig(strings.NewReader(configYAML))
101+
require.NoError(t, err, "should load config with custom_rules")
102+
require.NotNil(t, config.CustomRules, "custom_rules should survive UnmarshalYAML round-trip")
103+
assert.Equal(t, []string{"./rules/*.ts", "./extra/*.ts"}, config.CustomRules.Paths, "custom_rules.paths should be preserved")
104+
}
105+
92106
func TestConfig_ValidateMissingRuleID(t *testing.T) {
93107
t.Parallel()
94108

mise-tasks/test

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ else
1919

2020
echo "🧪 Running tests in separate modules..."
2121
(cd jsonschema/oas3/tests && GOWORK=off gotestsum --format testname -- -race ./...)
22+
(cd openapi/linter/customrules && GOWORK=off gotestsum --format testname -- -race ./...)
23+
(cd openapi/linter/converter/tests && GOWORK=off gotestsum --format testname -- -race ./...)
2224
fi
2325

2426
echo "✅ All tests passed!"

0 commit comments

Comments
 (0)