Skip to content

Findings & Severity

Findings capture specific issues discovered during evaluation, with severity levels following InfoSec conventions.

Finding Structure

type Finding struct {
    ID             string   `json:"id,omitempty"`
    Severity       Severity `json:"severity"`
    Category       string   `json:"category"`
    Title          string   `json:"title"`
    Description    string   `json:"description,omitempty"`
    Location       string   `json:"location,omitempty"`
    Recommendation string   `json:"recommendation,omitempty"`
    References     []string `json:"references,omitempty"`
}

Severity Levels

Severity Icon Blocking Description
Critical 🔴 Yes Must fix before approval; security vulnerabilities, data loss risks
High 🔴 Yes Must fix before approval; major functionality gaps
Medium 🟡 No Should fix; tracked issues that don't block
Low 🟢 No Nice to fix; minor improvements
Info No Informational; observations, suggestions

Category Severity Rollup (v0.11.0)

CategoryResult carries a Severity field — the highest-severity finding in that category, or empty if it has none. It's computed automatically from the category's Findings via WorstSeverity(), never set independently by the judge, so it can't drift out of sync with what was actually found:

type CategoryResult struct {
    Category string     `json:"category"`
    Score    ScoreValue `json:"score"`
    Severity Severity   `json:"severity,omitempty"`
    Findings []Finding  `json:"findings,omitempty"`
    // ...
}

It's computed in two places, so every path is covered:

// AddFinding recomputes Severity on every call.
cat := rubric.CategoryResult{Category: "security"}
cat.AddFinding(rubric.Finding{Severity: rubric.SeverityHigh, Title: "..."})
cat.Severity // SeverityHigh

// AddCategoryResult and Evaluate compute it as a safety net for categories
// built via struct literal or appended directly to Rubric.Categories.
report.AddCategoryResult(rubric.CategoryResult{
    Category: "security",
    Findings: []rubric.Finding{{Severity: rubric.SeverityCritical}},
})
// report.Categories[len(report.Categories)-1].Severity == SeverityCritical

An explicitly set Severity is never overwritten — the compute-if-unset behavior only fills it in when it's still the zero value, matching the same convention already used for Rubric.IntScore and Rubric.Confidence.

This is distinct from Score/IntScore, which measure quality. Severity exists specifically to answer "which categories should I fix first?" — useful for sorting or highlighting categories in a UI independent of their pass/partial/fail status.

// Sort categories by severity for a "fix these first" view
sort.Slice(report.Categories, func(i, j int) bool {
    return report.Categories[i].Severity.Weight() > report.Categories[j].Severity.Weight()
})

Creating Findings

// Critical finding - blocks approval
report.AddFinding(rubric.Finding{
    Severity:       rubric.SeverityCritical,
    Category:       "security",
    Title:          "SQL injection vulnerability",
    Description:    "User input is concatenated directly into SQL query",
    Location:       "src/db/queries.go:142",
    Recommendation: "Use parameterized queries",
    References:     []string{"OWASP-A03:2021"},
})

// Medium finding - tracked but doesn't block
report.AddFinding(rubric.Finding{
    Severity:       rubric.SeverityMedium,
    Category:       "documentation",
    Title:          "Missing API documentation",
    Description:    "Public endpoints lack OpenAPI annotations",
    Recommendation: "Add swagger comments to all public handlers",
})

// Info finding - observation
report.AddFinding(rubric.Finding{
    Severity:    rubric.SeverityInfo,
    Category:    "style",
    Title:       "Consider using table-driven tests",
    Description: "Test file has repetitive test cases",
})

Finding Counts

The decision tracks finding counts by severity:

type FindingCounts struct {
    Critical int `json:"critical"`
    High     int `json:"high"`
    Medium   int `json:"medium"`
    Low      int `json:"low"`
    Info     int `json:"info"`
    Total    int `json:"total"`
}

// BlockingCount returns critical + high
func (fc FindingCounts) BlockingCount() int {
    return fc.Critical + fc.High
}

Usage

counts := report.Decision.FindingCounts

if counts.BlockingCount() > 0 {
    fmt.Printf("❌ %d blocking issues found\n", counts.BlockingCount())
}

fmt.Printf("Findings: %d critical, %d high, %d medium, %d low\n",
    counts.Critical, counts.High, counts.Medium, counts.Low)

Severity Methods

severity := rubric.SeverityHigh

severity.Icon()        // "🔴"
severity.IsBlocking()  // true
severity.Priority()    // 2 (lower = more severe)
Severity Icon IsBlocking Priority
Critical 🔴 true 1
High 🔴 true 2
Medium 🟡 false 3
Low 🟢 false 4
Info false 5

Grouping Findings

Findings can be grouped by category or severity for display:

// Group by category
byCategory := make(map[string][]Finding)
for _, f := range report.Findings {
    byCategory[f.Category] = append(byCategory[f.Category], f)
}

// Group by severity
bySeverity := make(map[Severity][]Finding)
for _, f := range report.Findings {
    bySeverity[f.Severity] = append(bySeverity[f.Severity], f)
}

Best Practices

Writing Good Finding Titles

  • ✅ "Missing input validation on user ID parameter"
  • ❌ "Bad code"
  • ✅ "API rate limiting not implemented"
  • ❌ "Needs work"

Writing Recommendations

  • Be specific and actionable
  • Reference standards when applicable
  • Provide code examples if helpful
Finding{
    Title:          "Passwords stored in plaintext",
    Recommendation: "Use bcrypt with cost factor ≥12. See OWASP Password Storage Cheat Sheet.",
}

Using Location

Include file paths and line numbers when available:

Finding{
    Location: "src/auth/handler.go:89",  // File:line
    Location: "Section 3.2",              // Document section
    Location: "API: POST /users",         // Endpoint
}

Reason Codes (v0.9.0)

Reason codes provide standardized finding identifiers for automated repair workflows.

ReasonCode Format

Codes use {CATEGORY}-{ISSUE} format:

// Category prefixes
CategoryREQ    = "REQ"    // Requirements
CategorySEC    = "SEC"    // Security
CategoryARCH   = "ARCH"   // Architecture
CategoryDOC    = "DOC"    // Documentation
CategoryMETRIC = "METRIC" // Metrics
CategoryUSER   = "USER"   // User personas
CategorySCALE  = "SCALE"  // Scalability
CategoryINFRA  = "INFRA"  // Infrastructure
CategorySCOPE  = "SCOPE"  // Scope
CategoryUX     = "UX"     // UX/Accessibility

Pre-defined Codes

// Requirements
CodeREQAmbiguous     // "REQ-AMBIGUOUS"
CodeREQIncomplete    // "REQ-INCOMPLETE"
CodeREQConflict      // "REQ-CONFLICT"

// Security
CodeSECMissingAuth   // "SEC-MISSING_AUTH"
CodeSECNoValidation  // "SEC-NO_VALIDATION"

// Metrics
CodeMETRICNoBaseline // "METRIC-NO_BASELINE"
CodeMETRICNoTarget   // "METRIC-NO_TARGET"

Creating Findings with Codes

// Using NewFindingWithCode (inherits severity from registry)
finding := rubric.NewFindingWithCode(
    "f1",
    "requirements",
    rubric.CodeREQAmbiguous,
    "Ambiguous requirement",
    "Requirement REQ-12 can be interpreted multiple ways",
)

// Or set code on existing finding
finding := rubric.NewFinding("f2", "security", rubric.SeverityHigh, "Missing auth", "...")
finding.SetCode(rubric.CodeSECMissingAuth)
finding.SetLocation("Section 3.2")

Automated Repair

Each code has a repair prompt in the registry:

// Get repair prompt for automated fixes
prompt := finding.GetRepairPrompt()
// Returns: "Rewrite this requirement to be specific and testable..."

// Or access code info directly
info := rubric.GetReasonCodeInfo(rubric.CodeREQAmbiguous)
info.Category        // "REQ"
info.DefaultSeverity // SeverityMedium
info.RepairPrompt    // "Rewrite this requirement..."

Aggregating by Code

// Count findings by reason code
counts := rubric.CountFindingsByCode(report.Findings)
// map[ReasonCode]int{"REQ-AMBIGUOUS": 3, "SEC-MISSING_AUTH": 1}

// Get blocking codes
blocking := rubric.GetBlockingCodes(report.Findings)
// []ReasonCode{"SEC-MISSING_AUTH"}

Blocking Codes on Rubric

The rubric tracks which codes caused failure:

report.Evaluate(rubricSet)

if !report.Pass {
    for _, code := range report.Blocking {
        fmt.Printf("Blocked by: %s\n", code)
    }
}

Next Steps