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

287 lines
8.7 KiB
Go

package review
import (
"fmt"
"sort"
"strings"
)
// Audience selects how a ReviewReport is rendered to text. The human audience
// gets a readable section packet (verdict header, per-file grouped findings, a
// summary line, and the cost block); the agent audience gets a terse,
// machine-first summary with no narrative prose — a one-line verdict, one
// compact line per finding, and a single cost line — so a coding agent shelling
// the review verb can parse the output cheaply and act on it.
type Audience int
const (
// AudienceHuman renders the readable section packet. This is the default.
AudienceHuman Audience = iota
// AudienceAgent renders the terse machine-first summary.
AudienceAgent
)
// RenderSummary renders a review report to text for the given audience.
//
// AudienceAgent produces a terse, prose-free summary:
//
// VERDICT: block (1 critical, 2 error)
// findings:
// internal/x.go:14 critical nil-deref — possible nil dereference
// internal/y.go:7 error inverted-err — inverted error check
// cost: in=1234 out=456 cache_r=2000 cache_w=0 usd=0.012000 elapsed=4.2s
//
// AudienceHuman produces the readable section packet: a verdict header, an
// optional one-line summary, the per-file risk ranking, the findings grouped by
// file and ordered by line, and the cost block.
//
// A nil report renders an empty APPROVE verdict for the requested audience so
// the caller never has to nil-check before formatting.
func RenderSummary(report *ReviewReport, audience Audience) string {
if report == nil {
report = &ReviewReport{Verdict: VerdictApprove}
}
if audience == AudienceAgent {
return renderAgentSummary(report)
}
return renderHumanSummary(report)
}
// renderAgentSummary is the terse, machine-first rendering: a single verdict
// line, one compact line per finding, and one cost line. It carries no narrative
// prose — no section headers, no sentences — so an agent can parse it cheaply.
func renderAgentSummary(report *ReviewReport) string {
var b strings.Builder
verdict := report.Verdict
if verdict == "" {
verdict = VerdictApprove
}
fmt.Fprintf(&b, "VERDICT: %s", strings.ToLower(string(verdict)))
if hist := severityHistogram(report.Findings); hist != "" {
fmt.Fprintf(&b, " (%s)", hist)
}
b.WriteByte('\n')
if len(report.Findings) > 0 {
b.WriteString("findings:\n")
for _, f := range sortedFindings(report.Findings) {
fmt.Fprintf(&b, " %s\n", agentFindingLine(f))
}
}
b.WriteString(costLine(report.Cost))
b.WriteByte('\n')
return b.String()
}
// agentFindingLine formats one finding as a single compact, prose-free line:
//
// file:line severity rule — message
//
// The rule falls back to the category, then to "rule" so the field is never
// empty. The message is the short headline; the full markdown body is never
// emitted in the terse form.
func agentFindingLine(f Finding) string {
file := cleanPath(f.File)
if file == "" {
file = "?"
}
line := findingLine(f)
rule := f.Rule
if rule == "" {
rule = f.Category
}
if rule == "" {
rule = "rule"
}
sev := f.Severity
if sev == "" {
sev = SevInfo
}
msg := strings.TrimSpace(firstLine(f.Message))
if msg == "" {
msg = strings.TrimSpace(firstLine(f.Body))
}
return fmt.Sprintf("%s:%d %s %s — %s", file, line, sev, rule, msg)
}
// renderHumanSummary is the readable section packet: a verdict header, an
// optional summary line, the per-file risk ranking, and the findings grouped by
// file and ordered by line, followed by the cost block.
func renderHumanSummary(report *ReviewReport) string {
var b strings.Builder
verdict := report.Verdict
if verdict == "" {
verdict = VerdictApprove
}
fmt.Fprintf(&b, "Verdict: %s\n", verdict)
if s := strings.TrimSpace(report.Summary); s != "" {
fmt.Fprintf(&b, "%s\n", s)
}
if len(report.FileRisk) > 0 {
b.WriteString("\nFile risk:\n")
for _, fr := range report.FileRisk {
fmt.Fprintf(&b, " %-8s %s (%d finding(s))", fr.Risk, fr.File, fr.Findings)
// Evidence, when the impact analysis supplied it: the blast
// radius behind the tier, and — when the graph indexes tests
// at all — whether tests stand behind the change.
if fr.Affected > 0 || fr.Symbols > 0 {
fmt.Fprintf(&b, " — %d affected", fr.Affected)
if fr.Symbols > 0 {
if fr.Uncovered > 0 {
fmt.Fprintf(&b, ", %d/%d changed symbols untested", fr.Uncovered, fr.Symbols)
} else {
fmt.Fprintf(&b, ", all %d changed symbols test-covered ✓", fr.Symbols)
}
}
}
b.WriteByte('\n')
}
}
if len(report.Findings) == 0 {
b.WriteString("\n✓ No inline findings — the deterministic rulepack passed.\n")
} else {
fmt.Fprintf(&b, "\nFindings (%d):\n", len(report.Findings))
for _, file := range findingFilesSorted(report.Findings) {
fmt.Fprintf(&b, "\n%s\n", file)
for _, f := range findingsForFile(report.Findings, file) {
rule := f.Rule
if f.Category != "" {
rule = strings.TrimSpace(f.Category + "/" + f.Rule)
}
fmt.Fprintf(&b, " L%-5d %-8s %s", findingLine(f), f.Severity, firstLine(f.Message))
if rule != "" {
fmt.Fprintf(&b, " [%s]", strings.Trim(rule, "/"))
}
b.WriteByte('\n')
}
}
}
b.WriteString("\n")
b.WriteString(costLine(report.Cost))
b.WriteByte('\n')
return b.String()
}
// severityHistogram renders the kept-finding severity counts worst-first as a
// compact comma-joined string, e.g. "1 critical, 2 error". An empty finding set
// yields the empty string so the verdict line omits the parenthetical.
func severityHistogram(findings []Finding) string {
counts := map[Severity]int{}
for _, f := range findings {
sev := f.Severity
if sev == "" {
sev = SevInfo
}
counts[sev]++
}
order := []Severity{SevCritical, SevError, SevWarning, SevInfo}
parts := make([]string, 0, len(order))
for _, sev := range order {
if n := counts[sev]; n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, sev))
}
}
return strings.Join(parts, ", ")
}
// costLine renders the per-review token + USD accounting as a single line:
//
// cost: in=1234 out=456 cache_r=2000 cache_w=0 usd=0.012000 elapsed=4.2s
//
// A nil cost (the deterministic-only / legacy path) renders the zero block so
// the line is always present and parseable.
func costLine(cost *CostBreakdown) string {
var c CostBreakdown
if cost != nil {
c = *cost
}
elapsed := float64(c.ElapsedMs) / 1000.0
return fmt.Sprintf("cost: in=%d out=%d cache_r=%d cache_w=%d usd=%.6f elapsed=%.1fs",
c.InputTokens, c.OutputTokens, c.CacheReadTokens, c.CacheWriteTokens, c.USD, elapsed)
}
// findingLine returns the new-side line a finding is anchored to, falling back
// to StartLine when the grounder left Line at zero (a multi-line anchor).
func findingLine(f Finding) int {
if f.Line != 0 {
return f.Line
}
return f.StartLine
}
// firstLine returns the first line of a possibly multi-line string, trimmed of
// trailing space, so a finding's headline never bleeds the verdict / findings
// block onto extra lines.
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return strings.TrimRight(s[:i], " \t\r")
}
return s
}
// sortedFindings returns the findings ordered worst-first (severity desc, then
// confidence desc, then file/line) without mutating the input slice — the same
// ranking the Gate applies, so the terse list reads most-severe first.
func sortedFindings(findings []Finding) []Finding {
out := make([]Finding, len(findings))
copy(out, findings)
sort.SliceStable(out, func(i, j int) bool {
si, sj := severityRank(out[i].Severity), severityRank(out[j].Severity)
if si != sj {
return si > sj
}
if out[i].Confidence != out[j].Confidence {
return out[i].Confidence > out[j].Confidence
}
if fi, fj := cleanPath(out[i].File), cleanPath(out[j].File); fi != fj {
return fi < fj
}
return findingLine(out[i]) < findingLine(out[j])
})
return out
}
// findingFilesSorted returns the distinct files the findings touch, in sorted
// order, so the human packet reads as a deterministic per-file pass.
func findingFilesSorted(findings []Finding) []string {
seen := map[string]bool{}
files := make([]string, 0)
for _, f := range findings {
file := cleanPath(f.File)
if file == "" {
file = "?"
}
if !seen[file] {
seen[file] = true
files = append(files, file)
}
}
sort.Strings(files)
return files
}
// findingsForFile returns the findings anchored to one file, ordered by line, so
// each file's block reads top-to-bottom.
func findingsForFile(findings []Finding, file string) []Finding {
out := make([]Finding, 0)
for _, f := range findings {
ff := cleanPath(f.File)
if ff == "" {
ff = "?"
}
if ff == file {
out = append(out, f)
}
}
sort.SliceStable(out, func(i, j int) bool {
return findingLine(out[i]) < findingLine(out[j])
})
return out
}