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>
223 lines
6.1 KiB
Go
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) }
|