Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,17 @@ mise test -count=1 ./...
- **Race Detection**: Automatically enables race detection to catch concurrency issues
- **Submodule Awareness**: Checks for and warns about uninitialized test submodules

## Pre-Commit CI Check

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

```bash
mise ci
```

## Git Commit Conventions

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

### Commit Message Format

Expand Down
188 changes: 182 additions & 6 deletions cmd/openapi/commands/openapi/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import (
"path/filepath"
"time"

"sync"

"github.com/speakeasy-api/openapi/linter"
"github.com/speakeasy-api/openapi/linter/fix"
"github.com/speakeasy-api/openapi/openapi"
openapiLinter "github.com/speakeasy-api/openapi/openapi/linter"
"github.com/speakeasy-api/openapi/validation"
"github.com/spf13/cobra"

// Enable custom rules support
Expand Down Expand Up @@ -57,24 +61,49 @@ in your rules directory:

Then configure the paths in your lint.yaml under custom_rules.paths.

AUTOFIXING:

Use --fix to automatically apply non-interactive fixes. Use --fix-interactive to
also be prompted for fixes that require user input (choosing values, entering text).
Use --dry-run with either flag to preview what would be changed without modifying the file.

See the full documentation at:
https://github.com/speakeasy-api/openapi/blob/main/cmd/openapi/commands/openapi/README.md#lint`,
Args: cobra.ExactArgs(1),
Run: runLint,
Args: cobra.ExactArgs(1),
PreRunE: validateLintFlags,
Run: runLint,
}

var (
lintOutputFormat string
lintRuleset string
lintConfigFile string
lintDisableRules []string
lintOutputFormat string
lintRuleset string
lintConfigFile string
lintDisableRules []string
lintSummary bool
lintFix bool
lintFixInteractive bool
lintDryRun bool
)

func init() {
lintCmd.Flags().StringVarP(&lintOutputFormat, "format", "f", "text", "Output format: text or json")
lintCmd.Flags().StringVarP(&lintRuleset, "ruleset", "r", "all", "Ruleset to use (default loads from config)")
lintCmd.Flags().StringVarP(&lintConfigFile, "config", "c", "", "Path to lint config file (default: ~/.openapi/lint.yaml)")
lintCmd.Flags().StringSliceVarP(&lintDisableRules, "disable", "d", nil, "Rule IDs to disable (can be repeated)")
lintCmd.Flags().BoolVar(&lintSummary, "summary", false, "Print a per-rule summary table of findings")
lintCmd.Flags().BoolVar(&lintFix, "fix", false, "Automatically apply non-interactive fixes and write back")
lintCmd.Flags().BoolVar(&lintFixInteractive, "fix-interactive", false, "Apply all fixes, prompting for interactive ones")
lintCmd.Flags().BoolVar(&lintDryRun, "dry-run", false, "Show what fixes would be applied without changing the file (requires --fix or --fix-interactive)")
}

func validateLintFlags(_ *cobra.Command, _ []string) error {
if lintFix && lintFixInteractive {
return fmt.Errorf("--fix and --fix-interactive are mutually exclusive")
}
if lintDryRun && !lintFix && !lintFixInteractive {
return fmt.Errorf("--dry-run requires --fix or --fix-interactive")
}
return nil
}

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

// Determine fix mode
fixOpts := fix.Options{Mode: fix.ModeNone, DryRun: lintDryRun}
switch {
case lintFixInteractive:
fixOpts.Mode = fix.ModeInteractive
case lintFix:
fixOpts.Mode = fix.ModeAuto
}

if fixOpts.Mode != fix.ModeNone {
if err := applyFixes(ctx, fixOpts, doc, output, cleanFile); err != nil {
return err
}

// Re-lint after applying fixes (unless dry-run) to get accurate remaining count
if !lintDryRun {
// Reload and re-lint the fixed document
reloadedF, err := os.Open(cleanFile)
if err != nil {
return fmt.Errorf("failed to reopen file after fix: %w", err)
}
defer reloadedF.Close()

reloadedDoc, reloadedValErrs, err := openapi.Unmarshal(ctx, reloadedF)
if err != nil {
return fmt.Errorf("failed to unmarshal fixed file: %w", err)
}

reloadedDocInfo := linter.NewDocumentInfo(reloadedDoc, absPath)
output, err = lint.Lint(ctx, reloadedDocInfo, reloadedValErrs, nil)
if err != nil {
return fmt.Errorf("re-linting failed: %w", err)
}
}
}

// Format and print output
switch lintOutputFormat {
case "json":
Expand All @@ -140,6 +205,11 @@ func lintOpenAPI(ctx context.Context, file string) error {
fmt.Println(output.FormatText())
}

// Print per-rule summary if requested
if lintSummary {
fmt.Println(output.FormatSummary())
}

// Exit with error code if there are errors
if output.HasErrors() {
return fmt.Errorf("linting found %d errors", output.ErrorCount())
Expand All @@ -148,6 +218,112 @@ func lintOpenAPI(ctx context.Context, file string) error {
return nil
}

func applyFixes(ctx context.Context, fixOpts fix.Options, doc *openapi.OpenAPI, output *linter.Output, cleanFile string) error {
// Create prompter lazily for interactive mode — only initialized when
// an interactive fix is actually encountered, avoiding unnecessary setup
// when all fixes are non-interactive.
var prompter validation.Prompter
if fixOpts.Mode == fix.ModeInteractive {
prompter = &lazyPrompter{}
}

engine := fix.NewEngine(fixOpts, prompter, fix.NewFixRegistry())
result, err := engine.ProcessErrors(ctx, doc, output.Results)
if err != nil {
return fmt.Errorf("fix processing failed: %w", err)
}

// Report fix results to stderr
reportFixResults(result, fixOpts.DryRun)

// Write modified document back if any fixes were applied (and not dry-run)
if len(result.Applied) > 0 && !fixOpts.DryRun {
processor, err := NewOpenAPIProcessor(cleanFile, "", true)
if err != nil {
return fmt.Errorf("failed to create processor: %w", err)
}
if err := processor.WriteDocument(ctx, doc); err != nil {
return fmt.Errorf("failed to write fixed document: %w", err)
}
fmt.Fprintf(os.Stderr, "Applied %d fix(es) to %s\n", len(result.Applied), cleanFile)
}

return nil
}

func reportFixResults(result *fix.Result, dryRun bool) {
prefix := ""
if dryRun {
prefix = "[dry-run] "
}

if len(result.Applied) > 0 {
fmt.Fprintf(os.Stderr, "\n%sFixed:\n", prefix)
for _, af := range result.Applied {
fmt.Fprintf(os.Stderr, " [%d:%d] %s - %s\n",
af.Error.GetLineNumber(), af.Error.GetColumnNumber(),
af.Error.Rule, af.Fix.Description())
if af.Before != "" || af.After != "" {
fmt.Fprintf(os.Stderr, " %s -> %s\n", af.Before, af.After)
}
}
}

if len(result.Skipped) > 0 {
fmt.Fprintf(os.Stderr, "\n%sSkipped:\n", prefix)
for _, sf := range result.Skipped {
fmt.Fprintf(os.Stderr, " [%d:%d] %s - %s (%s)\n",
sf.Error.GetLineNumber(), sf.Error.GetColumnNumber(),
sf.Error.Rule, sf.Fix.Description(), skipReasonString(sf.Reason))
}
}

if len(result.Failed) > 0 {
fmt.Fprintf(os.Stderr, "\n%sFailed:\n", prefix)
for _, ff := range result.Failed {
fmt.Fprintf(os.Stderr, " [%d:%d] %s - %s: %v\n",
ff.Error.GetLineNumber(), ff.Error.GetColumnNumber(),
ff.Error.Rule, ff.Fix.Description(), ff.FixError)
}
}
}

func skipReasonString(reason fix.SkipReason) string {
switch reason {
case fix.SkipInteractive:
return "requires interactive input"
case fix.SkipConflict:
return "conflict with previous fix"
case fix.SkipUser:
return "skipped by user"
default:
return "unknown"
}
}

// lazyPrompter defers TerminalPrompter creation until an interactive fix is
// actually encountered, avoiding unnecessary setup when all fixes are non-interactive.
type lazyPrompter struct {
once sync.Once
prompter *fix.TerminalPrompter
}

func (l *lazyPrompter) init() {
l.once.Do(func() {
l.prompter = fix.NewTerminalPrompter(os.Stdin, os.Stderr)
})
}

func (l *lazyPrompter) PromptFix(finding *validation.Error, f validation.Fix) ([]string, error) {
l.init()
return l.prompter.PromptFix(finding, f)
}

func (l *lazyPrompter) Confirm(message string) (bool, error) {
l.init()
return l.prompter.Confirm(message)
}

func buildLintConfig() *linter.Config {
config := linter.NewConfig()

Expand Down
Loading
Loading