check-safety/registry.go
Alex Dunmow c4d01de82c feat: concise output + diff-scoped any check
Reporter is now a collector: checks declare a verdict (OK/Skip/Warn/Fail/
Fatal) plus findings, and a single central render() prints output. Default
output is silent on pass/skip — only FAIL/WARN/ERR checks print, followed by
one tally line, so a clean run is two lines. --verbose restores full per-check
output. All ~30 checks were converted to this API; orphaned guidance/label
helpers (printPerTargetOKLines, per-check *Help blocks, colors_format.go) were
removed.

The any-usage check (2e) now defaults to only the unstaged working-tree diff
(changed lines), via a new per-repo git-diff index in changedlines.go; --all-any
restores the full scan. Not-a-git-repo / no-diff warns on nothing.

Golden fixtures regenerated; integration tests updated to the new format; added
unit tests for the diff index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:16:56 +08:00

223 lines
6.1 KiB
Go

package main
import (
"fmt"
"os"
"strings"
)
// ScanContext holds all resolved scan state, built once before any check runs.
// It is read-only to checks.
type ScanContext struct {
repoRoot string
backendDir string
orchestratorOnly bool
includeCoreTargets bool
allAny bool // --all-any: scan every source for `any`, not just the unstaged diff
verbose bool // --verbose/-v: print every check, not just warn/fail/err
backendTargets []backendScanTarget
pluginTargets []pluginScanTarget // filtered
resolvedPluginTargets []pluginScanTarget // pre-filter
frontendTargets []frontendScanTarget
unresolvedPluginRoots []string
pluginPageDirs []string
}
// status is a check's verdict. Ordered by severity so it can only escalate.
// statusUnset is the default before any verdict; the first call to any
// OK/Skip/Warn/Fail/Fatal sets it, and later more-severe calls win. A check
// that declares both Skip (some targets) and OK (others) resolves to OK.
type status int
const (
statusUnset status = iota // no verdict declared yet (default)
statusSkip
statusOK
statusWarn
statusFail
statusErr // most severe
)
func (s status) label() string {
switch s {
case statusSkip:
return "SKIP"
case statusWarn:
return "WARN"
case statusFail:
return "FAIL"
case statusErr:
return "ERR "
default: // statusUnset and statusOK both render as a pass
return "OK "
}
}
// checkReport accumulates one check's verdict and detail lines.
type checkReport struct {
seq int
id string
title string
status status
summary string // short note shown after the title (verbose, or for OK/SKIP)
findings []string // detail lines, printed indented under the header
}
// Reporter collects every check's report and renders them once at the end.
// It also tracks the process exit code.
type Reporter struct {
exitCode int
verbose bool
current *checkReport
reports []*checkReport
}
// begin starts a fresh report for the next check. Default status is OK; checks
// escalate as needed.
func (r *Reporter) begin(seq int, id, title string) {
r.current = &checkReport{seq: seq, id: id, title: title, status: statusUnset}
}
// end files the current report.
func (r *Reporter) end() {
if r.current != nil {
r.reports = append(r.reports, r.current)
r.current = nil
}
}
// setStatus escalates the current check to the most severe verdict seen.
func (r *Reporter) setStatus(s status, summary string) {
if r.current == nil {
return
}
if s >= r.current.status {
r.current.status = s
if summary != "" {
r.current.summary = summary
}
} else if r.current.summary == "" && summary != "" {
r.current.summary = summary
}
}
// OK marks the current check as passing.
func (r *Reporter) OK(format string, a ...any) { r.setStatus(statusOK, fmt.Sprintf(format, a...)) }
// Skip marks the current check as skipped (nothing to scan).
func (r *Reporter) Skip(format string, a ...any) { r.setStatus(statusSkip, fmt.Sprintf(format, a...)) }
// Warn marks the current check as a warning. Does not affect the exit code.
func (r *Reporter) Warn(format string, a ...any) { r.setStatus(statusWarn, fmt.Sprintf(format, a...)) }
// Fail marks the current check as failed and sets exit code 1.
func (r *Reporter) Fail(format string, a ...any) {
r.setStatus(statusFail, fmt.Sprintf(format, a...))
r.exitCode = 1
}
// Fatal reports an unrecoverable error for the current check, renders what has
// been collected so far, and exits with code 2 — matching the pre-existing
// hard-error behavior of checks that could not run.
func (r *Reporter) Fatal(format string, a ...any) {
r.setStatus(statusErr, fmt.Sprintf(format, a...))
r.exitCode = 2
r.end()
r.render()
os.Exit(2)
}
// Finding appends one detail line to the current check.
func (r *Reporter) Finding(line string) {
if r.current != nil {
r.current.findings = append(r.current.findings, line)
}
}
// Findingf is Finding with formatting.
func (r *Reporter) Findingf(format string, a ...any) { r.Finding(fmt.Sprintf(format, a...)) }
// render prints the collected reports. In concise mode (default) only
// warn/fail/err checks print; --verbose prints every check.
func (r *Reporter) render() {
printed := false
for _, c := range r.reports {
show := r.verbose || c.status == statusWarn || c.status == statusFail || c.status == statusErr
if !show {
continue
}
// For warn/fail/err the summary is the specific, descriptive headline
// (the check title would just duplicate it); id still identifies the
// check. OK/SKIP (verbose only) show the title plus any summary note.
var headline string
if c.status == statusWarn || c.status == statusFail || c.status == statusErr {
headline = c.summary
if headline == "" {
headline = c.title
}
} else {
headline = c.title
if c.summary != "" {
headline += " — " + c.summary
}
}
fmt.Printf("%s %s %s\n", c.status.label(), c.id, headline)
for _, f := range c.findings {
fmt.Printf(" %s\n", f)
}
printed = true
}
if printed {
fmt.Println()
}
fmt.Println(r.summaryLine())
}
// summaryLine builds the trailing one-line tally.
func (r *Reporter) summaryLine() string {
var ok, skip, warn, fail, errc int
for _, c := range r.reports {
switch c.status {
case statusOK, statusUnset:
ok++
case statusSkip:
skip++
case statusWarn:
warn++
case statusFail:
fail++
case statusErr:
errc++
}
}
parts := []string{}
add := func(n int, label string) {
if n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, label))
}
}
add(ok, "ok")
add(skip, "skip")
add(warn, "warn")
add(fail, "fail")
add(errc, "err")
verdict := "OK"
if r.exitCode != 0 {
verdict = "FAIL"
}
return fmt.Sprintf("%d checks: %s -> %s", len(r.reports), strings.Join(parts, " "), verdict)
}
// Check is one registered safety check. Run declares its verdict via the
// Reporter (rep.OK/Skip/Warn/Fail/Fatal + rep.Finding); the Reporter renders.
type Check struct {
Seq int
ID string
Title string
Run func(ctx *ScanContext, rep *Reporter)
}
var registry []Check
func register(c Check) { registry = append(registry, c) }