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/lint-ignore.yaml b/lint-ignore.yaml new file mode 100644 index 00000000..5cc5b4dc --- /dev/null +++ b/lint-ignore.yaml @@ -0,0 +1,2 @@ +url-starts-with-major-version: + - $.paths['/api/hootsuite-analytics/resolved-conversation/table'] \ No newline at end of file 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