@@ -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
5862Then 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+
6070See the full documentation at:
6171https://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
6677var (
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
7388func 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
80109func 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+
151327func buildLintConfig () * linter.Config {
152328 config := linter .NewConfig ()
153329
0 commit comments