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

340 lines
13 KiB
Go

package review
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/astquery"
"github.com/zzet/gortex/internal/config"
)
// sampleDiff is a tiny unified diff with two added lines the relocation phase can
// anchor verbatim snippets against.
const sampleDiff = `diff --git a/app/svc.go b/app/svc.go
index 1111111..2222222 100644
--- a/app/svc.go
+++ b/app/svc.go
@@ -1,3 +1,6 @@
package app
+func Risky(p *Account) int {
+ return p.Balance // nil-deref: p may be nil
+}
`
// stubGen returns a canned reply regardless of the prompt. It records the last
// prompt it was given so the test can assert grounding without a live model.
type stubGen struct {
reply string
err error
lastPrompt string
}
func (s *stubGen) gen(_ context.Context, prompt string, _ int) (string, error) {
s.lastPrompt = prompt
if s.err != nil {
return "", s.err
}
return s.reply, nil
}
func testResolver(t *testing.T) *RuleResolver {
t.Helper()
// repoRoot empty + customPath empty → only the global + embedded layers; the
// embedded `**` catch-all guarantees RuleFor always resolves.
r, err := NewRuleResolver("", "")
require.NoError(t, err)
return r
}
// TestRunRelocatesAndBlocks proves the happy path: a stubbed LLM returns a
// critical finding whose snippet matches an added line; the relocation phase
// anchors it to that exact new-side line, the deterministic rulepack finding is
// merged in, and the worst-of verdict over the merged set is BLOCK.
func TestRunRelocatesAndBlocks(t *testing.T) {
gen := &stubGen{reply: `[
{"file":"app/svc.go","snippet":"return p.Balance","message":"possible nil dereference of p","severity":"critical","category":"correctness"}
]`}
// A deterministic rulepack match the caller hands in (already line-exact).
matches := []astquery.Match{
{Detector: "go-loop-query-call", File: "app/svc.go", Line: 4, EndLine: 4,
Severity: "warning", Text: "query inside loop", SymbolID: "app/svc.go::Risky"},
}
report, err := Run(context.Background(), nil, gen.gen, Options{
RepoRoot: "/tmp/repo",
Diff: sampleDiff,
RulepackMatches: matches,
Rules: testResolver(t),
UseLLM: true,
})
require.NoError(t, err)
require.NotNil(t, report)
// The LLM finding relocated to an exact line (RELOCATE worked).
var llm *Finding
for i := range report.Findings {
if report.Findings[i].Source == "llm" {
llm = &report.Findings[i]
break
}
}
require.NotNil(t, llm, "the LLM finding must survive relocation")
require.Greater(t, llm.Line, 0, "an LLM finding must be line-anchored")
require.Equal(t, llm.Line, llm.StartLine)
require.NotEqual(t, AnchorUnresolved, llm.Anchor, "the finding must carry a resolved anchor tier")
require.Equal(t, "app/svc.go", llm.File)
// The deterministic rulepack finding is merged in.
hasRulepack := false
for _, f := range report.Findings {
if f.Source == "rulepack" {
hasRulepack = true
}
}
require.True(t, hasRulepack, "the deterministic rule finding must be merged into the report")
// Worst-of verdict: the critical finding blocks.
require.Equal(t, VerdictBlock, report.Verdict)
require.Equal(t, 1, report.Stats.Rulepack)
require.Equal(t, 1, report.Stats.LLM)
require.Equal(t, 0, report.Stats.Dropped)
}
// TestRunDisabledLLMKeepsDeterministic proves Run is total when the LLM is off:
// it still returns the deterministic rule findings plus a verdict, no error, no
// LLM call.
func TestRunDisabledLLMKeepsDeterministic(t *testing.T) {
matches := []astquery.Match{
{Detector: "go-inverted-err-check", File: "app/svc.go", Line: 3, EndLine: 3,
Severity: "error", Text: "inverted error check", SymbolID: "app/svc.go::Risky"},
}
report, err := Run(context.Background(), nil, nil /* gen */, Options{
RepoRoot: "/tmp/repo",
Diff: sampleDiff,
RulepackMatches: matches,
Rules: testResolver(t),
UseLLM: false,
})
require.NoError(t, err)
require.NotNil(t, report)
require.Len(t, report.Findings, 1, "only the deterministic rule finding")
require.Equal(t, "rulepack", report.Findings[0].Source)
require.Equal(t, VerdictBlock, report.Verdict, "an error-severity rule finding blocks")
require.Equal(t, 0, report.Stats.LLM)
require.False(t, report.Stats.LLMRequested)
}
// TestRunGarbageLLMDegrades proves a failing / garbage LLM never errors and never
// drops the deterministic findings: a gen that errors and a gen that returns
// non-JSON both yield a report with the rule findings + a verdict.
func TestRunGarbageLLMDegrades(t *testing.T) {
matches := []astquery.Match{
{Detector: "go-self-comparison", File: "app/svc.go", Line: 3,
Severity: "warning", Text: "self comparison", SymbolID: "app/svc.go::Risky"},
}
cases := []*stubGen{
{err: errors.New("model offline")},
{reply: "I think the code looks fine, no JSON here."},
{reply: "[]"}, // valid empty array
}
for _, gen := range cases {
report, err := Run(context.Background(), nil, gen.gen, Options{
RepoRoot: "/tmp/repo",
Diff: sampleDiff,
RulepackMatches: matches,
Rules: testResolver(t),
UseLLM: true,
})
require.NoError(t, err)
require.NotNil(t, report)
require.Len(t, report.Findings, 1, "the deterministic finding survives a bad LLM")
require.Equal(t, "rulepack", report.Findings[0].Source)
require.Equal(t, VerdictReview, report.Verdict, "a warning-severity finding → REVIEW")
require.Equal(t, 0, report.Stats.LLM)
}
}
// TestRunUnresolvedLLMDropped proves an LLM finding whose snippet matches nothing
// in the change is dropped (not anchored to a bogus line) and counted.
func TestRunUnresolvedLLMDropped(t *testing.T) {
gen := &stubGen{reply: `[
{"file":"app/svc.go","snippet":"this text appears nowhere in the diff","message":"ghost","severity":"error","category":"x"}
]`}
report, err := Run(context.Background(), nil, gen.gen, Options{
RepoRoot: "/tmp/repo",
Diff: sampleDiff,
RulepackMatches: nil,
Rules: testResolver(t),
UseLLM: true,
})
require.NoError(t, err)
require.Empty(t, report.Findings, "an un-anchorable LLM finding is dropped")
require.Equal(t, 1, report.Stats.Dropped)
require.Equal(t, VerdictApprove, report.Verdict, "no findings → APPROVE")
}
// TestBuildReviewPromptPure asserts the prompt builder is a pure function whose
// output carries the resolved per-file rule and the pack context — the grounding
// the MAIN phase relies on.
func TestBuildReviewPromptPure(t *testing.T) {
rules := map[string]config.ReviewRule{
"app/svc.go": {Name: "auth-strict", Path: "app/**", Severity: "error", Rulepack: "security"},
}
pack := &ReviewPack{
Changed: []PackEntry{{ID: "app/svc.go::Risky", File: "app/svc.go", Line: 3, Tier: TierChanged,
Diff: "+func Risky(p *Account) int {\n"}},
Budget: 0,
}
det := []Finding{{Rule: "test", Severity: SevWarning, File: "app/svc.go", Line: 4, Message: "existing rulepack hit"}}
in := promptInput{Rules: rules, Pack: pack, Deterministic: det}
p1 := buildReviewPrompt(in)
p2 := buildReviewPrompt(in)
require.Equal(t, p1, p2, "buildReviewPrompt must be deterministic (pure)")
require.Contains(t, p1, "auth-strict", "the resolved rule name must be in the prompt")
require.Contains(t, p1, "security", "the resolved rulepack must be in the prompt")
require.Contains(t, p1, "app/svc.go", "the changed file must be grounded in the prompt")
require.Contains(t, p1, "func Risky", "the pack diff context must be in the prompt")
require.Contains(t, p1, "existing rulepack hit", "established deterministic findings must be in the prompt")
require.True(t, strings.Contains(p1, "JSON"), "the prompt must instruct a JSON reply")
}
// TestCandidateSeverityFloor proves a rule's severity floor raises a weaker model
// verdict when a candidate is converted to a finding.
func TestCandidateSeverityFloor(t *testing.T) {
c := reviewCandidate{File: "app/svc.go", Message: "x", Severity: "info"}
f := candidateToFinding(c, config.ReviewRule{Name: "r", Severity: "error"})
require.Equal(t, SevError, f.Severity, "the rule severity floor must raise the finding severity")
require.Equal(t, "llm", f.Source)
}
// TestRankFileRiskUsesImpact proves per-file risk is taken from the impact map
// and the report is ranked worst-first.
func TestRankFileRiskUsesImpact(t *testing.T) {
diff := &analysis.DiffResult{
ChangedSymbols: []analysis.ChangedSymbol{
{ID: "app/a.go::A", FilePath: "app/a.go", Line: 1},
{ID: "app/b.go::B", FilePath: "app/b.go", Line: 1},
},
ChangedFiles: []string{"app/a.go", "app/b.go"},
}
impact := map[string]*analysis.ImpactResult{
"app/a.go::A": {Risk: analysis.RiskCritical},
"app/b.go::B": {Risk: analysis.RiskLow},
}
rows := rankFileRisk(diff, impact, nil, "", true)
require.Len(t, rows, 2)
require.Equal(t, "app/a.go", rows[0].File, "the critical-risk file ranks first")
require.Equal(t, string(analysis.RiskCritical), rows[0].Risk)
require.Equal(t, string(analysis.RiskLow), rows[1].Risk)
}
// TestRankFileRiskNormalizesRepoPrefix pins the multi-repo shape: changed
// symbols carry graph-prefixed file paths while the diff's changed files are
// repo-relative. The rollup must merge both onto one row per file (keyed
// relative), carrying the symbol's impact tier — not emit a prefixed
// impact-tier row plus a LOW diff-only duplicate.
func TestRankFileRiskNormalizesRepoPrefix(t *testing.T) {
diff := &analysis.DiffResult{
ChangedSymbols: []analysis.ChangedSymbol{
{ID: "myrepo/app/a.go::A", FilePath: "myrepo/app/a.go", Line: 1},
},
ChangedFiles: []string{"app/a.go", "app/b.go"},
}
impact := map[string]*analysis.ImpactResult{
"myrepo/app/a.go::A": {Risk: analysis.RiskCritical},
}
rows := rankFileRisk(diff, impact, nil, "myrepo", true)
require.Len(t, rows, 2, "one row per file, prefixed and relative forms merged")
require.Equal(t, "app/a.go", rows[0].File)
require.Equal(t, string(analysis.RiskCritical), rows[0].Risk)
require.Equal(t, "app/b.go", rows[1].File)
}
// TestRankFileRiskCoverageEvidence pins the per-file coverage rollup: the
// widest blast radius among the file's changed symbols, the changed-symbol
// count, and how many of them lack a covering test.
func TestRankFileRiskCoverageEvidence(t *testing.T) {
diff := &analysis.DiffResult{
ChangedSymbols: []analysis.ChangedSymbol{
{ID: "app/a.go::A", FilePath: "app/a.go", Line: 1},
{ID: "app/a.go::B", FilePath: "app/a.go", Line: 20},
},
ChangedFiles: []string{"app/a.go"},
}
impact := map[string]*analysis.ImpactResult{
"app/a.go::A": {Risk: analysis.RiskCritical, TotalAffected: 42, TestFiles: []string{"app/a_test.go"}},
"app/a.go::B": {Risk: analysis.RiskLow, TotalAffected: 3},
}
rows := rankFileRisk(diff, impact, nil, "", true)
require.Len(t, rows, 1)
require.Equal(t, 42, rows[0].Affected, "the widest symbol's blast radius wins")
require.Equal(t, 2, rows[0].Symbols)
require.Equal(t, 1, rows[0].Uncovered, "B has no covering test")
}
// TestRankFileRiskCoverageUnknown pins the epistemic guard: when the graph
// indexes no test symbols (coverageKnown false), no row may claim untested
// symbols — blindness is not a finding. The risk tier itself stays.
func TestRankFileRiskCoverageUnknown(t *testing.T) {
diff := &analysis.DiffResult{
ChangedSymbols: []analysis.ChangedSymbol{
{ID: "app/a.go::A", FilePath: "app/a.go", Line: 1},
},
ChangedFiles: []string{"app/a.go"},
}
impact := map[string]*analysis.ImpactResult{
"app/a.go::A": {Risk: analysis.RiskCritical, TotalAffected: 42},
}
rows := rankFileRisk(diff, impact, nil, "", false)
require.Len(t, rows, 1)
require.Equal(t, string(analysis.RiskCritical), rows[0].Risk, "the blast-radius tier stays")
require.Equal(t, 42, rows[0].Affected, "blast radius is a graph fact, not a coverage claim")
require.Zero(t, rows[0].Symbols, "no coverage claims when the index carries no tests")
require.Zero(t, rows[0].Uncovered)
}
// TestSummarizeCoveragePhrasing pins the three risk-driven headlines: untested
// risk, fully covered risk, and unknown coverage.
func TestSummarizeCoveragePhrasing(t *testing.T) {
critical := string(analysis.RiskCritical)
untested := summarize(VerdictBlock, nil, []FileRisk{{File: "a.go", Risk: critical, Symbols: 2, Uncovered: 1}})
require.Contains(t, untested, "1 without covering tests")
covered := summarize(VerdictReview, nil, []FileRisk{{File: "a.go", Risk: critical, Symbols: 2}})
require.Contains(t, covered, "all test-covered")
unknown := summarize(VerdictBlock, nil, []FileRisk{{File: "a.go", Risk: critical}})
require.Contains(t, unknown, "test coverage unknown")
}
// TestComputeVerdictCoverageCap pins the coverage temper: a critical-risk
// file whose changed symbols are all test-covered contributes at most
// REVIEW; the same file with an untested changed symbol blocks.
func TestComputeVerdictCoverageCap(t *testing.T) {
covered := []FileRisk{{File: "a.go", Risk: string(analysis.RiskCritical), Symbols: 2, Uncovered: 0}}
require.Equal(t, VerdictReview, computeVerdict(nil, covered),
"blast radius alone must not block a fully test-covered change")
untested := []FileRisk{{File: "a.go", Risk: string(analysis.RiskCritical), Symbols: 2, Uncovered: 1}}
require.Equal(t, VerdictBlock, computeVerdict(nil, untested))
// No coverage evidence (no impact data) keeps the conservative ladder.
unknown := []FileRisk{{File: "a.go", Risk: string(analysis.RiskCritical)}}
require.Equal(t, VerdictBlock, computeVerdict(nil, unknown))
}