Skip to content
Open
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
21 changes: 6 additions & 15 deletions cmd/validate-schemas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
// go run ./cmd/validate-schemas # blocking violations only
// go run ./cmd/validate-schemas --warn # include advisory warnings
// go run ./cmd/validate-schemas --warn --no-baseline # full advisory backlog
// go run ./cmd/validate-schemas --warn --no-baseline --style-debt # include legacy style debt
// go run ./cmd/validate-schemas --strict-consistency --style-debt --contract-debt # fail on all debt
// go run ./cmd/validate-schemas --strict-consistency # fail on all issues
package main

import (
Expand All @@ -23,13 +22,7 @@ import (
func main() {
warn := flag.Bool("warn", false, "Include advisory warnings in output (exit 0)")
noBaseline := flag.Bool("no-baseline", false, "Ignore advisory baseline file")
styleDebt := flag.Bool("style-debt", false, "Include legacy style debt")
contractDebt := flag.Bool("contract-debt", false, "Include legacy contract debt")
strict := flag.Bool("strict-consistency", false, "Fail on all style/design/contract debt")

// Accept legacy flag aliases — bound to the same variables as their canonical counterparts.
flag.BoolVar(styleDebt, "legacy-style", false, "Alias for --style-debt")
flag.BoolVar(contractDebt, "compat-debt", false, "Alias for --contract-debt")
strict := flag.Bool("strict-consistency", false, "Fail on all advisory issues")
flag.BoolVar(strict, "strict-debt", false, "Alias for --strict-consistency")

flag.Parse()
Expand All @@ -44,12 +37,10 @@ func main() {
}

opts := validation.AuditOptions{
RootDir: rootDir,
Strict: *strict,
Warn: *warn,
NoBaseline: *noBaseline,
StyleDebt: *styleDebt,
ContractDebt: *contractDebt,
RootDir: rootDir,
Strict: *strict,
Warn: *warn,
NoBaseline: *noBaseline,
}

result := validation.Audit(opts)
Expand Down
2 changes: 1 addition & 1 deletion validation/rules_codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ func checkRule27(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
if _, ok := tags["yaml"]; ok {
out = append(out, Violation{File: filePath,
Message: fmt.Sprintf("Schema %q — property %q has a manual `yaml:` tag in x-oapi-codegen-extra-tags. YAML struct tags are automatically added by the Go generator — remove this to avoid conflicts.", name, propName),
Severity: classifyDesignIssue(opts), RuleNumber: 27})
Severity: classifyIssue(opts), RuleNumber: 27})
}
}
}
Expand Down
8 changes: 2 additions & 6 deletions validation/rules_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,7 @@ func extractConstructName(filePath string) string {
// reportDuplicateSchemas checks the accumulated fingerprints and returns
// violations for schemas that appear in multiple constructs.
func reportDuplicateSchemas(fingerprints map[string][]schemaLocation, opts AuditOptions) []Violation {
sev := classifyContractIssue(opts)
if sev == nil {
return nil
}

sev := classifyIssue(opts)
var violations []Violation

for _, entries := range fingerprints {
Expand Down Expand Up @@ -160,7 +156,7 @@ func reportDuplicateSchemas(fingerprints map[string][]schemaLocation, opts Audit
"Duplicate schema structure detected across constructs: %s. "+
"Consider using a cross-construct `$ref` to a single canonical definition "+
"to avoid type drift.", locations),
Severity: *sev,
Severity: sev,
RuleNumber: 29,
})
}
Expand Down
31 changes: 14 additions & 17 deletions validation/rules_design.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,16 +255,16 @@ func checkRule23(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
isPublic := isExplicitlyPublic(op, doc)

if !isPublic && !codes["401"] {
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — missing `401` (Unauthorized) response.", label), Severity: classifyDesignIssue(opts), RuleNumber: 23})
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — missing `401` (Unauthorized) response.", label), Severity: classifyIssue(opts), RuleNumber: 23})
}
if !codes["500"] {
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — missing `500` (Internal Server Error) response.", label), Severity: classifyDesignIssue(opts), RuleNumber: 23})
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — missing `500` (Internal Server Error) response.", label), Severity: classifyIssue(opts), RuleNumber: 23})
}
if (method == "post" || method == "put" || method == "patch") && !codes["400"] {
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — missing `400` (Bad Request) response.", label), Severity: classifyDesignIssue(opts), RuleNumber: 23})
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — missing `400` (Bad Request) response.", label), Severity: classifyIssue(opts), RuleNumber: 23})
}
if strings.Contains(path, "{") && !codes["404"] {
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — missing `404` (Not Found) response.", label), Severity: classifyDesignIssue(opts), RuleNumber: 23})
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — missing `404` (Not Found) response.", label), Severity: classifyIssue(opts), RuleNumber: 23})
}
}
}
Expand All @@ -282,7 +282,7 @@ func checkRule24(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
if schemes == nil || schemes.SecuritySchemes == nil || len(schemes.SecuritySchemes) == 0 {
out = append(out, Violation{
File: filePath, Message: "No security schemes declared. api.yml files with path operations must define at least one entry under `components.securitySchemes`.",
Severity: classifyDesignIssue(opts), RuleNumber: 24,
Severity: classifyIssue(opts), RuleNumber: 24,
})
}
return out
Expand Down Expand Up @@ -316,7 +316,7 @@ func checkRule25(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
File: filePath,
Message: fmt.Sprintf("GET %s — list endpoint (returns array or paged response) is missing pagination parameter(s): %s. Reference the shared parameters from v1alpha1/core/api.yml for consistent pagination across all list endpoints.",
path, strings.Join(missing, ", ")),
Severity: classifyDesignIssue(opts), RuleNumber: 25,
Severity: classifyIssue(opts), RuleNumber: 25,
})
}
}
Expand Down Expand Up @@ -348,7 +348,7 @@ func checkRule26(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
out = append(out, Violation{
File: filePath,
Message: fmt.Sprintf("%s — response %s has an inline schema with %d properties. Extract it to `components/schemas`.", label, code, len(media.Schema.Value.Properties)),
Severity: classifyDesignIssue(opts), RuleNumber: 26,
Severity: classifyIssue(opts), RuleNumber: 26,
})
}
}
Expand All @@ -368,10 +368,7 @@ var (
)

func checkRule28(filePath string, doc *openapi3.T, opts AuditOptions) []Violation {
sev := classifyContractIssue(opts)
if sev == nil {
return nil
}
sev := classifyIssue(opts)
if doc == nil || doc.Paths == nil {
return nil
}
Expand All @@ -389,7 +386,7 @@ func checkRule28(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
}
msg += " appears to create a resource but uses 200 instead of 201 (Created). Use 201 for POST endpoints that exclusively create new resources."
out = append(out, Violation{File: filePath,
Message: msg, Severity: *sev, RuleNumber: 28})
Message: msg, Severity: sev, RuleNumber: 28})
}
}
}
Expand All @@ -400,7 +397,7 @@ func checkRule28(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
if codes["200"] && !codes["204"] {
out = append(out, Violation{File: filePath,
Message: fmt.Sprintf("DELETE %s — single-resource DELETE should return 204 (No Content) instead of 200.", path),
Severity: *sev, RuleNumber: 28})
Severity: sev, RuleNumber: 28})
}
}
}
Expand Down Expand Up @@ -438,7 +435,7 @@ func checkRule30(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
out = append(out, Violation{File: filePath,
Message: fmt.Sprintf("%s %s — response %s returns an array with inline item schema (%d properties). Extract to `components/schemas`.",
strings.ToUpper(method), path, code, len(s.Items.Value.Properties)),
Severity: classifyDesignIssue(opts), RuleNumber: 30})
Severity: classifyIssue(opts), RuleNumber: 30})
}
}
}
Expand Down Expand Up @@ -471,7 +468,7 @@ func checkRule31(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
if successfullyRE.MatchString(*resp.Value.Description) {
out = append(out, Violation{File: filePath,
Message: fmt.Sprintf(`%s — response %s description contains the word "successfully". Use neutral wording.`, label, code),
Severity: classifyDesignIssue(opts), RuleNumber: 31})
Severity: classifyIssue(opts), RuleNumber: 31})
}
}
}
Expand Down Expand Up @@ -500,15 +497,15 @@ func checkRule36(filePath string, doc *openapi3.T, opts AuditOptions) []Violatio
}
label := fmt.Sprintf("%s %s", strings.ToUpper(method), path)
if len(op.Tags) == 0 {
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — operation is missing `tags`.", label), Severity: classifyDesignIssue(opts), RuleNumber: 36})
out = append(out, Violation{File: filePath, Message: fmt.Sprintf("%s — operation is missing `tags`.", label), Severity: classifyIssue(opts), RuleNumber: 36})
continue
}
if len(declaredTags) > 0 {
for _, tag := range op.Tags {
if !declaredTags[tag] {
out = append(out, Violation{File: filePath,
Message: fmt.Sprintf(`%s — operation tag %q is not declared in the document-root tags section.`, label, tag),
Severity: classifyDesignIssue(opts), RuleNumber: 36})
Severity: classifyIssue(opts), RuleNumber: 36})
}
}
}
Expand Down
16 changes: 8 additions & 8 deletions validation/rules_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ func entityRawPropGormColumn(entity *entitySchema, propName string) string {
// --- Rule 6 for entity schemas: property name casing ---

func checkRule6ForEntity(filePath string, entity *entitySchema, opts AuditOptions) []Violation {
sev := classifyStyleIssue(opts)
if sev == nil || entity == nil || entity.Properties == nil {
sev := classifyIssue(opts)
if entity == nil || entity.Properties == nil {
return nil
}
var out []Violation
Expand All @@ -258,7 +258,7 @@ func checkRule6ForEntity(filePath string, entity *entitySchema, opts AuditOption
msg += fmt.Sprintf(` Use: %q.`, suggestion)
}
msg += ` See AGENTS.md § "Casing rules at a glance".`
out = append(out, Violation{File: filePath, Message: msg, Severity: *sev, RuleNumber: 6})
out = append(out, Violation{File: filePath, Message: msg, Severity: sev, RuleNumber: 6})
}
}
return out
Expand Down Expand Up @@ -359,7 +359,7 @@ func walkEntityPropertyConstraints(filePath, scope string, properties map[string
if propDef.Description == "" && rawDesc == "" {
*out = append(*out, Violation{File: filePath,
Message: fmt.Sprintf(`Entity property %q is missing a description.`, propName),
Severity: classifyDesignIssue(opts), RuleNumber: 37})
Severity: classifyIssue(opts), RuleNumber: 37})
}

// Rule 38: string constraints. A `const` value is inherently
Expand All @@ -370,7 +370,7 @@ func walkEntityPropertyConstraints(filePath, scope string, properties map[string
if !hasConstraint {
*out = append(*out, Violation{File: filePath,
Message: fmt.Sprintf(`Entity string property %q has no validation constraint (minLength, maxLength, pattern, or format).`, propName),
Severity: classifyDesignIssue(opts), RuleNumber: 38})
Severity: classifyIssue(opts), RuleNumber: 38})
}
}

Expand All @@ -379,7 +379,7 @@ func walkEntityPropertyConstraints(filePath, scope string, properties map[string
if propDef.Minimum == nil && propDef.Maximum == nil && len(propDef.Enum) == 0 && propDef.Const == nil {
*out = append(*out, Violation{File: filePath,
Message: fmt.Sprintf(`Entity %s property %q has no bounds (minimum/maximum).`, propDef.Type, propName),
Severity: classifyDesignIssue(opts), RuleNumber: 39})
Severity: classifyIssue(opts), RuleNumber: 39})
}
}

Expand All @@ -397,7 +397,7 @@ func walkEntityPropertyConstraints(filePath, scope string, properties map[string
*out = append(*out, Violation{File: filePath,
Message: fmt.Sprintf(`Entity ID property %q should have format: uuid, use a $ref to a UUID schema, or add x-id-format: external.`,
propName),
Severity: classifyDesignIssue(opts), RuleNumber: 40})
Severity: classifyIssue(opts), RuleNumber: 40})
}
}
}
Expand All @@ -407,7 +407,7 @@ func walkEntityPropertyConstraints(filePath, scope string, properties map[string
if propDef.Minimum == nil || *propDef.Minimum < 1.0 {
*out = append(*out, Violation{File: filePath,
Message: fmt.Sprintf(`Entity page-size property %q must have minimum: 1.`, propName),
Severity: classifyDesignIssue(opts), RuleNumber: 41})
Severity: classifyIssue(opts), RuleNumber: 41})
}
}

Expand Down
8 changes: 2 additions & 6 deletions validation/rules_enum.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ import (

// checkRule8 validates that newly introduced enum values are lowercase.
func checkRule8(filePath, relFile string, doc *openapi3.T, opts AuditOptions, baselineRef string) []Violation {
sev := classifyStyleIssue(opts)
if sev == nil {
return nil
}

sev := classifyIssue(opts)
if doc == nil || doc.Components == nil || doc.Components.Schemas == nil {
return nil
}
Expand All @@ -34,7 +30,7 @@ func checkRule8(filePath, relFile string, doc *openapi3.T, opts AuditOptions, ba
}
// Use relFile (repo-relative) so that violations match advisory baseline keys.
visitEnumsInSchema(schemaRef.Value, fmt.Sprintf("Schema %q", schemaName),
baselineEnums, *sev, relFile, &violations)
baselineEnums, sev, relFile, &violations)
}

return violations
Expand Down
10 changes: 5 additions & 5 deletions validation/rules_enum_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ func TestCheckRule8_BaselineExemptsExistingValues(t *testing.T) {
},
}

opts := AuditOptions{StyleDebt: true}
opts := AuditOptions{}

// Pass a non-existent baseline ref so loadBaselineDoc returns nil (no git).
violations := checkRule8("/abs/path/api.yml", "rel/api.yml", doc, opts, "")
Expand All @@ -239,7 +239,7 @@ func TestCheckRule8_BaselineExemptsExistingValues(t *testing.T) {
}
}

func TestCheckRule8_SuppressedWhenNotStyleDebt(t *testing.T) {
func TestCheckRule8_AdvisoryByDefault(t *testing.T) {
doc := &openapi3.T{
OpenAPI: "3.0.0",
Info: &openapi3.Info{Title: "Test", Version: "v1"},
Expand All @@ -251,9 +251,9 @@ func TestCheckRule8_SuppressedWhenNotStyleDebt(t *testing.T) {
},
},
}
// Default opts — style issues suppressed.
// Advisory by default.
violations := checkRule8("/abs/path/api.yml", "rel/api.yml", doc, AuditOptions{}, "")
if len(violations) != 0 {
t.Errorf("expected 0 violations (style suppressed by default), got %d", len(violations))
if len(violations) != 1 {
t.Errorf("expected 1 violation (advisory by default), got %d", len(violations))
}
}
Loading
Loading