From 2a2d53cf8cdbc231e6f37f773ff45bdb437bc7c6 Mon Sep 17 00:00:00 2001 From: Calvin Lobo Date: Sat, 29 Jun 2024 21:15:27 -0600 Subject: [PATCH 1/3] Implemented an ignore-file (-i) option to the lint command which takes a YAML file that lists the paths for each rule for which to ignore errors for. --- cmd/lint.go | 63 ++++++++++++++++++- cmd/lint_test.go | 67 +++++++++++++++++++++ cmd/vacuum_report.go | 17 ++++++ cmd/vacuum_report_test.go | 32 ++++++++++ model/rules.go | 2 + model/test_files/burgershop.ignorefile.yaml | 6 ++ utils/lint_file_request.go | 1 + 7 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 model/test_files/burgershop.ignorefile.yaml diff --git a/cmd/lint.go b/cmd/lint.go index 0385a13b..2fc333e1 100644 --- a/cmd/lint.go +++ b/cmd/lint.go @@ -6,6 +6,7 @@ package cmd import ( "errors" "fmt" + "gopkg.in/yaml.v3" "log/slog" "os" "path/filepath" @@ -61,6 +62,7 @@ func GetLintCommand() *cobra.Command { hardModeFlag, _ := cmd.Flags().GetBool("hard-mode") ignoreArrayCircleRef, _ := cmd.Flags().GetBool("ignore-array-circle-ref") ignorePolymorphCircleRef, _ := cmd.Flags().GetBool("ignore-polymorph-circle-ref") + ignoreFile, _ := cmd.Flags().GetString("ignore-file") // disable color and styling, for CI/CD use. // https://github.com/daveshanley/vacuum/issues/234 @@ -175,6 +177,25 @@ func GetLintCommand() *cobra.Command { } } + if len(ignoreFile) > 1 { + if !silent { + pterm.Info.Printf("Using ignore file '%s'", ignoreFile) + pterm.Println() + } + } + + ignoredItems := model.IgnoredItems{} + if ignoreFile != "" { + raw, ferr := os.ReadFile(ignoreFile) + if ferr != nil { + return fmt.Errorf("failed to read ignore file: %w", ferr) + } + ferr = yaml.Unmarshal(raw, &ignoredItems) + if ferr != nil { + return fmt.Errorf("failed to read ignore file: %w", ferr) + } + } + start := time.Now() var filesProcessedSize int64 @@ -214,6 +235,7 @@ func GetLintCommand() *cobra.Command { TimeoutFlag: timeoutFlag, IgnoreArrayCircleRef: ignoreArrayCircleRef, IgnorePolymorphCircleRef: ignorePolymorphCircleRef, + IgnoredResults: ignoredItems, } fs, fp, err := lintFile(lfr) @@ -261,6 +283,7 @@ func GetLintCommand() *cobra.Command { cmd.Flags().StringP("fail-severity", "n", model.SeverityError, "Results of this level or above will trigger a failure exit code") cmd.Flags().Bool("ignore-array-circle-ref", false, "Ignore circular array references") cmd.Flags().Bool("ignore-polymorph-circle-ref", false, "Ignore circular polymorphic references") + cmd.Flags().String("ignore-file", "", "Path to ignore file") // TODO: Add globbed-files flag to other commands as well cmd.Flags().String("globbed-files", "", "Glob pattern of files to lint") @@ -320,7 +343,7 @@ func lintFile(req utils.LintFileRequest) (int64, int, error) { IgnoreCircularPolymorphicRef: req.IgnorePolymorphCircleRef, }) - results := result.Results + result.Results = filterIgnoredResults(result.Results, req.IgnoredResults) if len(result.Errors) > 0 { for _, err := range result.Errors { @@ -330,7 +353,7 @@ func lintFile(req utils.LintFileRequest) (int64, int, error) { return result.FileSize, result.FilesProcessed, fmt.Errorf("linting failed due to %d issues", len(result.Errors)) } - resultSet := model.NewRuleResultSet(results) + resultSet := model.NewRuleResultSet(result.Results) resultSet.SortResultsByLineNumber() warnings := resultSet.GetWarnCount() errs := resultSet.GetErrorCount() @@ -362,6 +385,42 @@ func lintFile(req utils.LintFileRequest) (int64, int, error) { return result.FileSize, result.FilesProcessed, CheckFailureSeverity(req.FailSeverityFlag, errs, warnings, informs) } +// filterIgnoredResultsPtr filters the given results slice, taking out any (RuleID, Path) combos that were listed in the +// ignore file +func filterIgnoredResultsPtr(results []*model.RuleFunctionResult, ignored model.IgnoredItems) []*model.RuleFunctionResult { + var filteredResults []*model.RuleFunctionResult + + for _, r := range results { + + var found bool + for _, i := range ignored[r.Rule.Id] { + if r.Path == i { + found = true + break + } + } + if !found { + filteredResults = append(filteredResults, r) + } + } + + return filteredResults +} + +// filterIgnoredResults does the filtering of ignored results on non-pointer result elements +func filterIgnoredResults(results []model.RuleFunctionResult, ignored model.IgnoredItems) []model.RuleFunctionResult { + resultsPtrs := make([]*model.RuleFunctionResult, 0, len(results)) + for _, r := range results { + r := r // prevent loop memory aliasing + resultsPtrs = append(resultsPtrs, &r) + } + resultsFiltered := make([]model.RuleFunctionResult, 0, len(results)) + for _, r := range filterIgnoredResultsPtr(resultsPtrs, ignored) { + resultsFiltered = append(resultsFiltered, *r) + } + return resultsFiltered +} + func processResults(results []*model.RuleFunctionResult, specData []string, snippets, diff --git a/cmd/lint_test.go b/cmd/lint_test.go index 76ed3d45..aeff3fbd 100644 --- a/cmd/lint_test.go +++ b/cmd/lint_test.go @@ -507,3 +507,70 @@ rules: assert.NoError(t, err) assert.NotNil(t, outBytes) } + +func TestFilterIgnoredResults(t *testing.T) { + + results := []model.RuleFunctionResult{ + {Path: "a/b/c", Rule: &model.Rule{Id: "XXX"}}, + {Path: "a/b", Rule: &model.Rule{Id: "XXX"}}, + {Path: "a", Rule: &model.Rule{Id: "XXX"}}, + {Path: "a/b/c", Rule: &model.Rule{Id: "YYY"}}, + {Path: "a/b", Rule: &model.Rule{Id: "YYY"}}, + {Path: "a", Rule: &model.Rule{Id: "YYY"}}, + {Path: "a/b/c", Rule: &model.Rule{Id: "ZZZ"}}, + {Path: "a/b", Rule: &model.Rule{Id: "ZZZ"}}, + {Path: "a", Rule: &model.Rule{Id: "ZZZ"}}, + } + + igItems := model.IgnoredItems{ + "XXX": []string{"a/b/c"}, + "YYY": []string{"a/b"}, + } + + results = filterIgnoredResults(results, igItems) + + expected := []model.RuleFunctionResult{ + {Path: "a/b", Rule: &model.Rule{Id: "XXX"}}, + {Path: "a", Rule: &model.Rule{Id: "XXX"}}, + {Path: "a/b/c", Rule: &model.Rule{Id: "YYY"}}, + {Path: "a", Rule: &model.Rule{Id: "YYY"}}, + {Path: "a/b/c", Rule: &model.Rule{Id: "ZZZ"}}, + {Path: "a/b", Rule: &model.Rule{Id: "ZZZ"}}, + {Path: "a", Rule: &model.Rule{Id: "ZZZ"}}, + } + assert.Len(t, results, 7) + assert.Equal(t, expected, expected) +} + +func TestGetLintCommand_Details_WithIgnoreFile(t *testing.T) { + + yaml := ` +extends: [[spectral:oas, recommended]] +rules: + url-starts-with-major-version: + description: Major version must be the first URL component + message: All paths must start with a version number, eg /v1, /v2 + given: $.paths + severity: error + then: + function: pattern + functionOptions: + match: "/v[0-9]+/" +` + + tmp, _ := os.CreateTemp("", "") + _, _ = io.WriteString(tmp, yaml) + + cmd := GetLintCommand() + cmd.PersistentFlags().StringP("ruleset", "r", "", "") + cmd.SetArgs([]string{ + "-d", + "--ignore-file", + "../model/test_files/burgershop.ignorefile.yaml", + "-r", + tmp.Name(), + "../model/test_files/burgershop.openapi.yaml", + }) + cmdErr := cmd.Execute() + assert.NoError(t, cmdErr) +} diff --git a/cmd/vacuum_report.go b/cmd/vacuum_report.go index 465e44d8..80ed427d 100644 --- a/cmd/vacuum_report.go +++ b/cmd/vacuum_report.go @@ -16,6 +16,7 @@ import ( vacuum_report "github.com/daveshanley/vacuum/vacuum-report" "github.com/pterm/pterm" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" "os" "time" ) @@ -46,6 +47,7 @@ func GetVacuumReportCommand() *cobra.Command { skipCheckFlag, _ := cmd.Flags().GetBool("skip-check") timeoutFlag, _ := cmd.Flags().GetInt("timeout") hardModeFlag, _ := cmd.Flags().GetBool("hard-mode") + ignoreFile, _ := cmd.Flags().GetString("ignore-file") // disable color and styling, for CI/CD use. // https://github.com/daveshanley/vacuum/issues/234 @@ -102,6 +104,18 @@ func GetVacuumReportCommand() *cobra.Command { return fileError } + ignoredItems := model.IgnoredItems{} + if ignoreFile != "" { + raw, ferr := os.ReadFile(ignoreFile) + if ferr != nil { + return fmt.Errorf("failed to read ignore file: %w", ferr) + } + ferr = yaml.Unmarshal(raw, &ignoredItems) + if ferr != nil { + return fmt.Errorf("failed to read ignore file: %w", ferr) + } + } + // read spec and parse to dashboard. defaultRuleSets := rulesets.BuildDefaultRuleSets() @@ -165,6 +179,8 @@ func GetVacuumReportCommand() *cobra.Command { resultSet := model.NewRuleResultSet(ruleset.Results) resultSet.SortResultsByLineNumber() + resultSet.Results = filterIgnoredResultsPtr(resultSet.Results, ignoredItems) + duration := time.Since(start) // if we want jUnit output, then build the report and be done with it. @@ -262,5 +278,6 @@ func GetVacuumReportCommand() *cobra.Command { cmd.Flags().BoolP("compress", "c", false, "Compress results using gzip") cmd.Flags().BoolP("no-pretty", "n", false, "Render JSON with no formatting") cmd.Flags().BoolP("no-style", "q", false, "Disable styling and color output, just plain text (useful for CI/CD)") + cmd.Flags().String("ignore-file", "", "Path to ignore file") return cmd } diff --git a/cmd/vacuum_report_test.go b/cmd/vacuum_report_test.go index 493c8664..5c041baf 100644 --- a/cmd/vacuum_report_test.go +++ b/cmd/vacuum_report_test.go @@ -190,3 +190,35 @@ func TestGetVacuumReportCommand_BadFile(t *testing.T) { assert.Error(t, cmdErr) } + +func TestGetVacuumReport_WithIgnoreFile(t *testing.T) { + + yaml := ` +extends: [[spectral:oas, recommended]] +rules: + url-starts-with-major-version: + description: Major version must be the first URL component + message: All paths must start with a version number, eg /v1, /v2 + given: $.paths + severity: error + then: + function: pattern + functionOptions: + match: "/v[0-9]+/" +` + + tmp, _ := os.CreateTemp("", "") + _, _ = io.WriteString(tmp, yaml) + + cmd := GetVacuumReportCommand() + cmd.PersistentFlags().StringP("ruleset", "r", "", "") + cmd.SetArgs([]string{ + "--ignore-file", + "../model/test_files/burgershop.ignorefile.yaml", + "-r", + tmp.Name(), + "../model/test_files/burgershop.openapi.yaml", + }) + cmdErr := cmd.Execute() + assert.NoError(t, cmdErr) +} diff --git a/model/rules.go b/model/rules.go index dfe33743..c4cf59f1 100644 --- a/model/rules.go +++ b/model/rules.go @@ -68,6 +68,8 @@ type RuleFunctionResult struct { ModelContext any `json:"-" yaml:"-"` } +type IgnoredItems map[string][]string + // RuleResultSet contains all the results found during a linting run, and all the methods required to // filter, sort and calculate counts. type RuleResultSet struct { diff --git a/model/test_files/burgershop.ignorefile.yaml b/model/test_files/burgershop.ignorefile.yaml new file mode 100644 index 00000000..d29bf7a2 --- /dev/null +++ b/model/test_files/burgershop.ignorefile.yaml @@ -0,0 +1,6 @@ +url-starts-with-major-version: + - $.paths['/burgers'] + - $.paths['/burgers/{burgerId}'] + - $.paths['/burgers/{burgerId}/dressings'] + - $.paths['/dressings/{dressingId}'] + - $.paths['/dressings'] \ No newline at end of file diff --git a/utils/lint_file_request.go b/utils/lint_file_request.go index 3c48866b..b2b79317 100644 --- a/utils/lint_file_request.go +++ b/utils/lint_file_request.go @@ -30,6 +30,7 @@ type LintFileRequest struct { TimeoutFlag int IgnoreArrayCircleRef bool IgnorePolymorphCircleRef bool + IgnoredResults model.IgnoredItems DefaultRuleSets rulesets.RuleSets SelectedRS *rulesets.RuleSet Functions map[string]model.RuleFunction From 91db93fc319def41ba967514b6701ea58a658454 Mon Sep 17 00:00:00 2001 From: Calvin Lobo Date: Thu, 4 Jul 2024 19:46:23 -0600 Subject: [PATCH 2/3] Improved tests to check for success in stdout --- cmd/lint_test.go | 5 +++++ cmd/vacuum_report_test.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/cmd/lint_test.go b/cmd/lint_test.go index aeff3fbd..da8d1ba7 100644 --- a/cmd/lint_test.go +++ b/cmd/lint_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "github.com/daveshanley/vacuum/model" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" "io" "os" @@ -561,6 +562,9 @@ rules: tmp, _ := os.CreateTemp("", "") _, _ = io.WriteString(tmp, yaml) + b := bytes.NewBufferString("") + pterm.SetDefaultOutput(b) + cmd := GetLintCommand() cmd.PersistentFlags().StringP("ruleset", "r", "", "") cmd.SetArgs([]string{ @@ -573,4 +577,5 @@ rules: }) cmdErr := cmd.Execute() assert.NoError(t, cmdErr) + assert.Contains(t, b.String(), "Linting passed") } diff --git a/cmd/vacuum_report_test.go b/cmd/vacuum_report_test.go index 5c041baf..bfa823fd 100644 --- a/cmd/vacuum_report_test.go +++ b/cmd/vacuum_report_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "fmt" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" "io" "os" @@ -210,6 +211,9 @@ rules: tmp, _ := os.CreateTemp("", "") _, _ = io.WriteString(tmp, yaml) + b := bytes.NewBufferString("") + pterm.SetDefaultOutput(b) + cmd := GetVacuumReportCommand() cmd.PersistentFlags().StringP("ruleset", "r", "", "") cmd.SetArgs([]string{ @@ -221,4 +225,5 @@ rules: }) cmdErr := cmd.Execute() assert.NoError(t, cmdErr) + assert.Contains(t, b.String(), "SUCCESS") } From e6af85d4ec9996e02ebf304f05b90fc2ef1e3944 Mon Sep 17 00:00:00 2001 From: Calvin Lobo Date: Mon, 8 Jul 2024 11:02:55 -0600 Subject: [PATCH 3/3] Added README for ignore-file --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 5a022752..a71823b1 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,35 @@ recognizes a compressed report file and will deal with it automatically when rea > When using compression, the file name will be `vacuum-report-MM-DD-YY-HH_MM_SS.json.gz`. vacuum uses gzip internally. +## Ignoring specific linting errors + +You can ignore specific linting errors by providing an `--ignore-file` argument to the `lint` and `report` commands. + +``` +./vacuum lint --ignore-file -d +``` + +``` +./vacuum report --ignore-file -c +``` + +The ignore-file should point to a .yaml file that contains a list of errors to be ignored by vacuum. The structure of the +yaml file is as follows: + +``` +: + - + - +: + - + - + ... +``` + +Ignoring errors is useful for when you want to implement new rules to existing production APIs. In some cases, +correcting the lint errors would result in a breaking change. Having a way to ignore these errors allows you to implement +the new rules for new APIs while maintaining backwards compatibility for existing ones. + --- ## Try out the dashboard