Skip to content

Commit 91523e7

Browse files
feat: add autofixer system with fixes for 32 lint rules (#141)
1 parent 117816f commit 91523e7

Some content is hidden

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

68 files changed

+6840
-304
lines changed

AGENTS.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,17 @@ mise test -count=1 ./...
5858
- **Race Detection**: Automatically enables race detection to catch concurrency issues
5959
- **Submodule Awareness**: Checks for and warns about uninitialized test submodules
6060

61+
## Pre-Commit CI Check
62+
63+
**Always run `mise ci` before committing changes.** This runs the full CI pipeline locally (format, lint, test, build) and ensures your changes won't break CI.
64+
65+
```bash
66+
mise ci
67+
```
68+
6169
## Git Commit Conventions
6270

63-
**Always use single-line conventional commits.** Do not create multi-line commit messages.
71+
**Always use single-line conventional commits.** Do not create multi-line commit messages. Do not add `Co-Authored-By` trailers.
6472

6573
### Commit Message Format
6674

cmd/openapi/commands/openapi/lint.go

Lines changed: 182 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import (
77
"path/filepath"
88
"time"
99

10+
"sync"
11+
1012
"github.com/speakeasy-api/openapi/linter"
13+
"github.com/speakeasy-api/openapi/linter/fix"
1114
"github.com/speakeasy-api/openapi/openapi"
1215
openapiLinter "github.com/speakeasy-api/openapi/openapi/linter"
16+
"github.com/speakeasy-api/openapi/validation"
1317
"github.com/spf13/cobra"
1418

1519
// Enable custom rules support
@@ -57,24 +61,49 @@ in your rules directory:
5761
5862
Then configure the paths in your lint.yaml under custom_rules.paths.
5963
64+
AUTOFIXING:
65+
66+
Use --fix to automatically apply non-interactive fixes. Use --fix-interactive to
67+
also be prompted for fixes that require user input (choosing values, entering text).
68+
Use --dry-run with either flag to preview what would be changed without modifying the file.
69+
6070
See the full documentation at:
6171
https://github.com/speakeasy-api/openapi/blob/main/cmd/openapi/commands/openapi/README.md#lint`,
62-
Args: cobra.ExactArgs(1),
63-
Run: runLint,
72+
Args: cobra.ExactArgs(1),
73+
PreRunE: validateLintFlags,
74+
Run: runLint,
6475
}
6576

6677
var (
67-
lintOutputFormat string
68-
lintRuleset string
69-
lintConfigFile string
70-
lintDisableRules []string
78+
lintOutputFormat string
79+
lintRuleset string
80+
lintConfigFile string
81+
lintDisableRules []string
82+
lintSummary bool
83+
lintFix bool
84+
lintFixInteractive bool
85+
lintDryRun bool
7186
)
7287

7388
func init() {
7489
lintCmd.Flags().StringVarP(&lintOutputFormat, "format", "f", "text", "Output format: text or json")
7590
lintCmd.Flags().StringVarP(&lintRuleset, "ruleset", "r", "all", "Ruleset to use (default loads from config)")
7691
lintCmd.Flags().StringVarP(&lintConfigFile, "config", "c", "", "Path to lint config file (default: ~/.openapi/lint.yaml)")
7792
lintCmd.Flags().StringSliceVarP(&lintDisableRules, "disable", "d", nil, "Rule IDs to disable (can be repeated)")
93+
lintCmd.Flags().BoolVar(&lintSummary, "summary", false, "Print a per-rule summary table of findings")
94+
lintCmd.Flags().BoolVar(&lintFix, "fix", false, "Automatically apply non-interactive fixes and write back")
95+
lintCmd.Flags().BoolVar(&lintFixInteractive, "fix-interactive", false, "Apply all fixes, prompting for interactive ones")
96+
lintCmd.Flags().BoolVar(&lintDryRun, "dry-run", false, "Show what fixes would be applied without changing the file (requires --fix or --fix-interactive)")
97+
}
98+
99+
func validateLintFlags(_ *cobra.Command, _ []string) error {
100+
if lintFix && lintFixInteractive {
101+
return fmt.Errorf("--fix and --fix-interactive are mutually exclusive")
102+
}
103+
if lintDryRun && !lintFix && !lintFixInteractive {
104+
return fmt.Errorf("--dry-run requires --fix or --fix-interactive")
105+
}
106+
return nil
78107
}
79108

80109
func runLint(cmd *cobra.Command, args []string) {
@@ -131,6 +160,42 @@ func lintOpenAPI(ctx context.Context, file string) error {
131160
return fmt.Errorf("linting failed: %w", err)
132161
}
133162

163+
// Determine fix mode
164+
fixOpts := fix.Options{Mode: fix.ModeNone, DryRun: lintDryRun}
165+
switch {
166+
case lintFixInteractive:
167+
fixOpts.Mode = fix.ModeInteractive
168+
case lintFix:
169+
fixOpts.Mode = fix.ModeAuto
170+
}
171+
172+
if fixOpts.Mode != fix.ModeNone {
173+
if err := applyFixes(ctx, fixOpts, doc, output, cleanFile); err != nil {
174+
return err
175+
}
176+
177+
// Re-lint after applying fixes (unless dry-run) to get accurate remaining count
178+
if !lintDryRun {
179+
// Reload and re-lint the fixed document
180+
reloadedF, err := os.Open(cleanFile)
181+
if err != nil {
182+
return fmt.Errorf("failed to reopen file after fix: %w", err)
183+
}
184+
defer reloadedF.Close()
185+
186+
reloadedDoc, reloadedValErrs, err := openapi.Unmarshal(ctx, reloadedF)
187+
if err != nil {
188+
return fmt.Errorf("failed to unmarshal fixed file: %w", err)
189+
}
190+
191+
reloadedDocInfo := linter.NewDocumentInfo(reloadedDoc, absPath)
192+
output, err = lint.Lint(ctx, reloadedDocInfo, reloadedValErrs, nil)
193+
if err != nil {
194+
return fmt.Errorf("re-linting failed: %w", err)
195+
}
196+
}
197+
}
198+
134199
// Format and print output
135200
switch lintOutputFormat {
136201
case "json":
@@ -140,6 +205,11 @@ func lintOpenAPI(ctx context.Context, file string) error {
140205
fmt.Println(output.FormatText())
141206
}
142207

208+
// Print per-rule summary if requested
209+
if lintSummary {
210+
fmt.Println(output.FormatSummary())
211+
}
212+
143213
// Exit with error code if there are errors
144214
if output.HasErrors() {
145215
return fmt.Errorf("linting found %d errors", output.ErrorCount())
@@ -148,6 +218,112 @@ func lintOpenAPI(ctx context.Context, file string) error {
148218
return nil
149219
}
150220

221+
func applyFixes(ctx context.Context, fixOpts fix.Options, doc *openapi.OpenAPI, output *linter.Output, cleanFile string) error {
222+
// Create prompter lazily for interactive mode — only initialized when
223+
// an interactive fix is actually encountered, avoiding unnecessary setup
224+
// when all fixes are non-interactive.
225+
var prompter validation.Prompter
226+
if fixOpts.Mode == fix.ModeInteractive {
227+
prompter = &lazyPrompter{}
228+
}
229+
230+
engine := fix.NewEngine(fixOpts, prompter, fix.NewFixRegistry())
231+
result, err := engine.ProcessErrors(ctx, doc, output.Results)
232+
if err != nil {
233+
return fmt.Errorf("fix processing failed: %w", err)
234+
}
235+
236+
// Report fix results to stderr
237+
reportFixResults(result, fixOpts.DryRun)
238+
239+
// Write modified document back if any fixes were applied (and not dry-run)
240+
if len(result.Applied) > 0 && !fixOpts.DryRun {
241+
processor, err := NewOpenAPIProcessor(cleanFile, "", true)
242+
if err != nil {
243+
return fmt.Errorf("failed to create processor: %w", err)
244+
}
245+
if err := processor.WriteDocument(ctx, doc); err != nil {
246+
return fmt.Errorf("failed to write fixed document: %w", err)
247+
}
248+
fmt.Fprintf(os.Stderr, "Applied %d fix(es) to %s\n", len(result.Applied), cleanFile)
249+
}
250+
251+
return nil
252+
}
253+
254+
func reportFixResults(result *fix.Result, dryRun bool) {
255+
prefix := ""
256+
if dryRun {
257+
prefix = "[dry-run] "
258+
}
259+
260+
if len(result.Applied) > 0 {
261+
fmt.Fprintf(os.Stderr, "\n%sFixed:\n", prefix)
262+
for _, af := range result.Applied {
263+
fmt.Fprintf(os.Stderr, " [%d:%d] %s - %s\n",
264+
af.Error.GetLineNumber(), af.Error.GetColumnNumber(),
265+
af.Error.Rule, af.Fix.Description())
266+
if af.Before != "" || af.After != "" {
267+
fmt.Fprintf(os.Stderr, " %s -> %s\n", af.Before, af.After)
268+
}
269+
}
270+
}
271+
272+
if len(result.Skipped) > 0 {
273+
fmt.Fprintf(os.Stderr, "\n%sSkipped:\n", prefix)
274+
for _, sf := range result.Skipped {
275+
fmt.Fprintf(os.Stderr, " [%d:%d] %s - %s (%s)\n",
276+
sf.Error.GetLineNumber(), sf.Error.GetColumnNumber(),
277+
sf.Error.Rule, sf.Fix.Description(), skipReasonString(sf.Reason))
278+
}
279+
}
280+
281+
if len(result.Failed) > 0 {
282+
fmt.Fprintf(os.Stderr, "\n%sFailed:\n", prefix)
283+
for _, ff := range result.Failed {
284+
fmt.Fprintf(os.Stderr, " [%d:%d] %s - %s: %v\n",
285+
ff.Error.GetLineNumber(), ff.Error.GetColumnNumber(),
286+
ff.Error.Rule, ff.Fix.Description(), ff.FixError)
287+
}
288+
}
289+
}
290+
291+
func skipReasonString(reason fix.SkipReason) string {
292+
switch reason {
293+
case fix.SkipInteractive:
294+
return "requires interactive input"
295+
case fix.SkipConflict:
296+
return "conflict with previous fix"
297+
case fix.SkipUser:
298+
return "skipped by user"
299+
default:
300+
return "unknown"
301+
}
302+
}
303+
304+
// lazyPrompter defers TerminalPrompter creation until an interactive fix is
305+
// actually encountered, avoiding unnecessary setup when all fixes are non-interactive.
306+
type lazyPrompter struct {
307+
once sync.Once
308+
prompter *fix.TerminalPrompter
309+
}
310+
311+
func (l *lazyPrompter) init() {
312+
l.once.Do(func() {
313+
l.prompter = fix.NewTerminalPrompter(os.Stdin, os.Stderr)
314+
})
315+
}
316+
317+
func (l *lazyPrompter) PromptFix(finding *validation.Error, f validation.Fix) ([]string, error) {
318+
l.init()
319+
return l.prompter.PromptFix(finding, f)
320+
}
321+
322+
func (l *lazyPrompter) Confirm(message string) (bool, error) {
323+
l.init()
324+
return l.prompter.Confirm(message)
325+
}
326+
151327
func buildLintConfig() *linter.Config {
152328
config := linter.NewConfig()
153329

0 commit comments

Comments
 (0)