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
259 lines
8.4 KiB
Go
259 lines
8.4 KiB
Go
package review
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/analysis"
|
|
)
|
|
|
|
// ReviewReport is the output of the hybrid review flow: a worst-of verdict over
|
|
// the merged deterministic + LLM findings, the per-file risk ranking, a one-line
|
|
// summary, and the run statistics. The findings are already line-anchored (the
|
|
// relocation phase ran) and deduplicated.
|
|
type ReviewReport struct {
|
|
Verdict Verdict `json:"verdict"`
|
|
Findings []Finding `json:"findings"`
|
|
FileRisk []FileRisk `json:"file_risk"`
|
|
Summary string `json:"summary"`
|
|
Stats ReviewStats `json:"stats"`
|
|
// Depth is the adaptive review depth this changeset was classified into
|
|
// (quick | standard | deep). It gates whether the LLM phase ran.
|
|
Depth string `json:"depth"`
|
|
// Cost is the per-review token + USD accounting. Set only when the
|
|
// review ran through a usage-aware LLM seam (RunWithUsage); nil for
|
|
// the deterministic-only or legacy Run path.
|
|
Cost *CostBreakdown `json:"cost,omitempty"`
|
|
}
|
|
|
|
// FileRisk ranks one changed file by its impact-derived risk tier and the number
|
|
// of findings anchored to it. The tier is a blast-radius fact; Affected,
|
|
// Symbols, and Uncovered carry the evidence behind it so "CRITICAL" is
|
|
// explainable: how many symbols the change can reach, and how much of the
|
|
// change has covering tests. A fully covered file (Symbols > 0, Uncovered 0)
|
|
// contributes at most REVIEW to the verdict — blast radius alone does not
|
|
// block a well-tested change.
|
|
type FileRisk struct {
|
|
File string `json:"file"`
|
|
Risk string `json:"risk"`
|
|
Findings int `json:"findings"`
|
|
// Affected is the widest blast radius among the file's changed symbols
|
|
// (total transitively affected symbols from the impact analysis).
|
|
Affected int `json:"affected,omitempty"`
|
|
// Symbols counts the changed symbols in this file with impact data;
|
|
// Uncovered counts how many of them have no covering test.
|
|
Symbols int `json:"symbols,omitempty"`
|
|
Uncovered int `json:"uncovered,omitempty"`
|
|
}
|
|
|
|
// ReviewStats records how the report was assembled: how many findings each
|
|
// source contributed and how many LLM candidates were dropped because no exact
|
|
// line could be grounded.
|
|
type ReviewStats struct {
|
|
Rulepack int `json:"rulepack"` // deterministic rule findings merged in
|
|
LLM int `json:"llm"` // LLM findings that anchored to a line
|
|
Dropped int `json:"dropped"` // LLM candidates dropped (unresolved anchor)
|
|
Total int `json:"total"` // findings in the report after dedup
|
|
BySeverity map[string]int `json:"by_severity"` // severity histogram of the report
|
|
Truncated bool `json:"truncated"` // a budget bound dropped some candidates
|
|
LLMRequested bool `json:"llm_requested"` // the LLM phase was asked to run
|
|
Gate GateStats `json:"gate"` // confidence / severity / category / cap suppression summary
|
|
Depth string `json:"depth"` // adaptive review depth: quick | standard | deep
|
|
}
|
|
|
|
// computeVerdict is the worst-of verdict over the report's findings and per-file
|
|
// risk: any error/critical finding (or a critical-risk file) → BLOCK; any
|
|
// warning finding (or a high/medium-risk file) → REVIEW; otherwise APPROVE. It
|
|
// reuses the FromSeverity / FromRisk adapters so the ladder is never
|
|
// re-implemented here.
|
|
//
|
|
// Coverage tempers the risk side: a file whose changed symbols are all
|
|
// test-covered contributes at most REVIEW — blast radius says how far a
|
|
// regression could reach, covering tests say how likely it is to land
|
|
// unnoticed, and only the combination blocks.
|
|
func computeVerdict(findings []Finding, fileRisk []FileRisk) Verdict {
|
|
worst := VerdictApprove
|
|
for _, f := range findings {
|
|
worst = worseVerdict(worst, FromSeverity(f.Severity))
|
|
}
|
|
for _, fr := range fileRisk {
|
|
v := FromRisk(analysis.RiskLevel(fr.Risk))
|
|
if fr.Symbols > 0 && fr.Uncovered == 0 && verdictRank(v) > verdictRank(VerdictReview) {
|
|
v = VerdictReview
|
|
}
|
|
worst = worseVerdict(worst, v)
|
|
}
|
|
return worst
|
|
}
|
|
|
|
// verdictRank orders the verdicts so "worst-of" is a max over the rank.
|
|
func verdictRank(v Verdict) int {
|
|
switch v {
|
|
case VerdictBlock:
|
|
return 2
|
|
case VerdictReview:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// worseVerdict returns the more severe of two verdicts.
|
|
func worseVerdict(a, b Verdict) Verdict {
|
|
if verdictRank(b) > verdictRank(a) {
|
|
return b
|
|
}
|
|
return a
|
|
}
|
|
|
|
// rankFileRisk derives the per-file risk ranking from the impact analysis and the
|
|
// findings anchored to each changed file. A file's risk tier is the impact tier
|
|
// of the worst changed symbol it contains; with no impact data it falls back to a
|
|
// finding-count heuristic via the same assessRisk ladder. The result is sorted
|
|
// worst-first, then by file for determinism.
|
|
//
|
|
// repoPrefix normalizes the two path vocabularies onto one key: changed-symbol
|
|
// (and finding) paths come from graph nodes, which multi-repo daemons key as
|
|
// "<prefix>/<rel>", while the diff's changed files are repo-relative. Without
|
|
// stripping the prefix every file would surface twice — once with its real
|
|
// impact tier and once as a LOW diff-only row.
|
|
//
|
|
// coverageKnown gates the coverage evidence: when the graph indexes no test
|
|
// symbols at all, "no covering test" is blindness, not a finding — the rows
|
|
// then carry no untested counts and the verdict keeps the conservative
|
|
// ladder.
|
|
func rankFileRisk(diff *analysis.DiffResult, impact map[string]*analysis.ImpactResult, findings []Finding, repoPrefix string, coverageKnown bool) []FileRisk {
|
|
norm := func(file string) string {
|
|
file = cleanPath(file)
|
|
if repoPrefix != "" {
|
|
file = strings.TrimPrefix(file, repoPrefix+"/")
|
|
}
|
|
return file
|
|
}
|
|
|
|
byFile := map[string]string{}
|
|
findingCount := map[string]int{}
|
|
type coverage struct {
|
|
affected int
|
|
symbols int
|
|
uncovered int
|
|
}
|
|
coverByFile := map[string]*coverage{}
|
|
|
|
for _, f := range findings {
|
|
if f.File != "" {
|
|
findingCount[norm(f.File)]++
|
|
}
|
|
}
|
|
|
|
if diff != nil {
|
|
for _, cs := range diff.ChangedSymbols {
|
|
file := norm(cs.FilePath)
|
|
if file == "" {
|
|
continue
|
|
}
|
|
risk := string(analysis.RiskLow)
|
|
if impact != nil {
|
|
ir := impact[cs.ID]
|
|
if ir != nil && ir.Risk != "" {
|
|
risk = string(ir.Risk)
|
|
}
|
|
// Blast-radius evidence (how far the worst changed symbol
|
|
// reaches) is a graph fact and always recorded. Coverage
|
|
// evidence is recorded only when the graph indexes tests
|
|
// at all — an index that excludes test files must not
|
|
// read as "untested".
|
|
cov := coverByFile[file]
|
|
if cov == nil {
|
|
cov = &coverage{}
|
|
coverByFile[file] = cov
|
|
}
|
|
if ir != nil && ir.TotalAffected > cov.affected {
|
|
cov.affected = ir.TotalAffected
|
|
}
|
|
if coverageKnown {
|
|
cov.symbols++
|
|
if ir == nil || len(ir.TestFiles) == 0 {
|
|
cov.uncovered++
|
|
}
|
|
}
|
|
}
|
|
byFile[file] = worseRisk(byFile[file], risk)
|
|
}
|
|
for _, file := range diff.ChangedFiles {
|
|
file = norm(file)
|
|
if file == "" {
|
|
continue
|
|
}
|
|
if _, ok := byFile[file]; !ok {
|
|
byFile[file] = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// Files that carry findings but no diff/impact entry still get a row.
|
|
for file := range findingCount {
|
|
if _, ok := byFile[file]; !ok {
|
|
byFile[file] = ""
|
|
}
|
|
}
|
|
|
|
rows := make([]FileRisk, 0, len(byFile))
|
|
for file, risk := range byFile {
|
|
if risk == "" {
|
|
risk = string(riskFromFindingCount(findingCount[file]))
|
|
}
|
|
row := FileRisk{File: file, Risk: risk, Findings: findingCount[file]}
|
|
if cov := coverByFile[file]; cov != nil {
|
|
row.Affected = cov.affected
|
|
row.Symbols = cov.symbols
|
|
row.Uncovered = cov.uncovered
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
ri, rj := riskRank(rows[i].Risk), riskRank(rows[j].Risk)
|
|
if ri != rj {
|
|
return ri > rj
|
|
}
|
|
return rows[i].File < rows[j].File
|
|
})
|
|
return rows
|
|
}
|
|
|
|
// riskFromFindingCount maps the number of findings anchored to a file onto the
|
|
// impact-risk ladder so a file with no graph impact still ranks by how much the
|
|
// review flagged in it.
|
|
func riskFromFindingCount(n int) analysis.RiskLevel {
|
|
switch {
|
|
case n >= 5:
|
|
return analysis.RiskHigh
|
|
case n >= 2:
|
|
return analysis.RiskMedium
|
|
case n >= 1:
|
|
return analysis.RiskLow
|
|
default:
|
|
return analysis.RiskLow
|
|
}
|
|
}
|
|
|
|
func riskRank(r string) int {
|
|
switch analysis.RiskLevel(r) {
|
|
case analysis.RiskCritical:
|
|
return 3
|
|
case analysis.RiskHigh:
|
|
return 2
|
|
case analysis.RiskMedium:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func worseRisk(a, b string) string {
|
|
if riskRank(b) > riskRank(a) {
|
|
return b
|
|
}
|
|
return a
|
|
}
|