Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

163 lines
6.3 KiB
Go

package review
import (
"sort"
"strings"
"github.com/zzet/gortex/internal/config"
)
// GateStats is the structured suppression summary the Gate returns alongside the
// kept findings. Each counter records how many findings a single drop reason
// removed, so a caller can report "N suppressed (below confidence / below
// severity / out of category / over cap)" without re-deriving the arithmetic.
type GateStats struct {
// Input is the number of findings the gate was handed.
Input int `json:"input"`
// Kept is the number that survived every filter and the cap.
Kept int `json:"kept"`
// BelowConfidence counts findings dropped because their Confidence was
// under the configured MinConfidence.
BelowConfidence int `json:"below_confidence"`
// BelowSeverity counts findings dropped because their Severity ranked
// below the configured MinSeverity.
BelowSeverity int `json:"below_severity"`
// CategoryFiltered counts findings dropped because their Category was not
// in the configured allow-list.
CategoryFiltered int `json:"category_filtered"`
// OverCap counts findings trimmed because the kept set exceeded
// MaxFindings — the lowest-ranked findings are the ones dropped.
OverCap int `json:"over_cap"`
// IdentitySuppressed counts findings dropped because their IdentityKey was
// recorded in the durable per-repo suppression store — known false
// positives silenced permanently until explicitly removed.
IdentitySuppressed int `json:"identity_suppressed"`
}
// Suppressed is the total number of findings the gate removed for any reason.
func (g GateStats) Suppressed() int {
return g.BelowConfidence + g.BelowSeverity + g.CategoryFiltered + g.OverCap + g.IdentitySuppressed
}
// Gate is a deterministic confidence / severity / category / cap filter over a
// finding set, driven by config.ReviewConfig. A zero-value Gate (zero-value
// config) is a pass-through: it keeps every finding and reports only Input /
// Kept. Construct one with NewGate, or zero-value it inline for the no-op case.
type Gate struct {
minConfidence float64
minSeverity Severity
categories map[string]bool
maxFindings int
// supp is the durable per-repo false-positive filter consulted before a
// finding is kept. Nil (the default) disables suppression entirely; a
// finding's IdentityKey is computed on demand when supp is set and the
// finding does not already carry one.
supp *SuppressionStore
repoKey string
}
// NewGate compiles a Gate from the review config. An empty Categories list
// means "all categories"; an empty / unrecognised MinSeverity means "no
// severity floor" (info, the lowest rank); a zero MinConfidence keeps every
// confidence; a zero (or negative) MaxFindings means "no cap". The whole
// zero-value config therefore yields a pass-through gate.
func NewGate(cfg config.ReviewConfig) Gate {
g := Gate{
minConfidence: cfg.MinConfidence,
maxFindings: cfg.MaxFindings,
}
if s := normalizeSeverity(cfg.MinSeverity); cfg.MinSeverity != "" {
g.minSeverity = s
}
if len(cfg.Categories) > 0 {
g.categories = make(map[string]bool, len(cfg.Categories))
for _, c := range cfg.Categories {
c = normalizeCategory(c)
if c != "" {
g.categories[c] = true
}
}
}
return g
}
// WithSuppression returns a copy of the gate that also drops findings whose
// IdentityKey is recorded in the durable per-repo suppression store. A nil store
// (or an empty repoKey) leaves the gate's behaviour unchanged — suppression is
// purely additive over the confidence / severity / category / cap pipeline. The
// returned gate stamps each finding's IdentityKey before the suppression lookup,
// so the kept findings carry a stable identity even when the generator left it
// blank.
func (g Gate) WithSuppression(supp *SuppressionStore, repoKey string) Gate {
g.supp = supp
g.repoKey = repoKey
return g
}
// Apply filters the findings and returns the kept set plus a structured
// suppression summary. The pipeline runs in order: drop below MinConfidence →
// drop below MinSeverity → drop out-of-category → stable-sort worst-first
// (severity desc, then confidence desc) → cap at MaxFindings (keeping the
// worst). The input slice is never mutated; the kept slice is a fresh
// allocation. A zero-value Gate keeps everything.
func (g Gate) Apply(findings []Finding) ([]Finding, GateStats) {
stats := GateStats{Input: len(findings)}
kept := make([]Finding, 0, len(findings))
for _, f := range findings {
// Confidence floor. A MinConfidence of 0 admits every finding (a
// zero-confidence finding passes a zero floor).
if g.minConfidence > 0 && f.Confidence < g.minConfidence {
stats.BelowConfidence++
continue
}
// Severity floor. An empty MinSeverity leaves minSeverity at its
// zero value (SevInfo's rank, the lowest), so nothing is dropped.
if severityRank(f.Severity) < severityRank(g.minSeverity) {
stats.BelowSeverity++
continue
}
// Category allow-list. A nil map means "all categories".
if g.categories != nil && !g.categories[normalizeCategory(f.Category)] {
stats.CategoryFiltered++
continue
}
// Durable false-positive suppression. Stamp a stable IdentityKey (so the
// kept finding carries one even if the generator did not), then drop the
// finding when that identity is on the per-repo never-flag-again list.
// A nil store leaves IdentityKey populated but suppresses nothing.
if f.IdentityKey == "" {
f.IdentityKey = IdentityKey(f)
}
if g.supp != nil && g.supp.IsSuppressed(g.repoKey, f.IdentityKey) {
stats.IdentitySuppressed++
continue
}
kept = append(kept, f)
}
// Rank worst-first so the cap trims the least-severe / least-confident
// findings. Stable so equal-rank findings keep their input order.
sort.SliceStable(kept, func(i, j int) bool {
si, sj := severityRank(kept[i].Severity), severityRank(kept[j].Severity)
if si != sj {
return si > sj
}
return kept[i].Confidence > kept[j].Confidence
})
if g.maxFindings > 0 && len(kept) > g.maxFindings {
stats.OverCap = len(kept) - g.maxFindings
kept = kept[:g.maxFindings]
}
stats.Kept = len(kept)
return kept, stats
}
// normalizeCategory lower-cases and trims a category so the allow-list match is
// case / whitespace insensitive — the same tolerance dedupeFindings applies.
func normalizeCategory(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}