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

245 lines
9.0 KiB
Go

package review
import (
"fmt"
"strings"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/llm"
)
// Depth is the adaptive review depth a changeset is classified into. It gates
// how much of the phased flow runs: a tiny change is reviewed by the
// deterministic rules alone (no model call), a medium change gets a single LLM
// pass, and a large / sprawling change gets the full plan → main → relocate
// pipeline with the planner tool catalogue grounded in the prompt.
type Depth int
const (
// DepthQuick reviews with the deterministic rulepack only — no LLM call.
// A small, narrowly-scoped change does not need a model pass.
DepthQuick Depth = iota
// DepthStandard runs a single LLM review pass over the deterministic
// substrate. The default for a mid-sized change.
DepthStandard
// DepthDeep runs the full plan → main → relocate pipeline and grounds the
// planner's reference tool catalogue in the prompt. The default for a large
// or wide change.
DepthDeep
)
// String returns the lowercase label recorded on the report.
func (d Depth) String() string {
switch d {
case DepthQuick:
return "quick"
case DepthDeep:
return "deep"
default:
return "standard"
}
}
// Default depth thresholds applied when the corresponding ReviewConfig field is
// left at its zero value. A change at or under defaultQuickMaxLines is reviewed
// by the rules alone; a change at or above defaultDeepMinLines lines OR
// defaultDeepMinFiles files escalates to the deep pipeline.
const (
defaultQuickMaxLines = 40
defaultDeepMinLines = 400
defaultDeepMinFiles = 20
)
// DepthThresholds are the resolved line/file bounds the classifier compares a
// changeset against. They derive from ReviewConfig, substituting the built-in
// defaults for any field left at zero.
type DepthThresholds struct {
// QuickMaxLines is the changed-line ceiling (inclusive) at or under which
// a change is classified DepthQuick.
QuickMaxLines int
// DeepMinLines is the changed-line floor (inclusive) at or above which a
// change is classified DepthDeep.
DeepMinLines int
// DeepMinFiles is the changed-file floor (inclusive) at or above which a
// change is classified DepthDeep.
DeepMinFiles int
}
// ThresholdsFromConfig resolves the depth thresholds from a ReviewConfig,
// substituting the built-in defaults for any field left at its zero value so a
// caller that configures none gets sensible behaviour.
func ThresholdsFromConfig(cfg config.ReviewConfig) DepthThresholds {
t := DepthThresholds{
QuickMaxLines: cfg.QuickMaxLines,
DeepMinLines: cfg.DeepMinLines,
DeepMinFiles: cfg.DeepMinFiles,
}
if t.QuickMaxLines <= 0 {
t.QuickMaxLines = defaultQuickMaxLines
}
if t.DeepMinLines <= 0 {
t.DeepMinLines = defaultDeepMinLines
}
if t.DeepMinFiles <= 0 {
t.DeepMinFiles = defaultDeepMinFiles
}
return t
}
// depthConfigured reports whether the caller opted into adaptive depth by
// setting at least one depth threshold. When none are set the review flow keeps
// its pre-adaptive behaviour (UseLLM alone gates the single LLM pass) so the
// legacy callers see the report they always did.
func depthConfigured(cfg config.ReviewConfig) bool {
return cfg.QuickMaxLines > 0 || cfg.DeepMinLines > 0 || cfg.DeepMinFiles > 0
}
// DepthSignals are the cheap, deterministic inputs the depth classifier scores.
// They are gathered from the diff without an LLM call.
type DepthSignals struct {
// LinesChanged is the total number of changed (added/removed) lines.
LinesChanged int
// FilesChanged is the number of files the change touches.
FilesChanged int
}
// ClassifyDepth classifies a changeset into its review depth from the raw
// changed-line and changed-file counts and the review config. A change at or
// under QuickMaxLines is DepthQuick; a change at or above DeepMinLines lines OR
// DeepMinFiles files is DepthDeep; everything between is DepthStandard. Config
// fields left at zero fall back to the built-in defaults, so the legacy callers
// that pass a zero-value ReviewConfig get the default ladder.
func ClassifyDepth(linesChanged, filesChanged int, cfg config.ReviewConfig) Depth {
return ClassifySignals(DepthSignals{LinesChanged: linesChanged, FilesChanged: filesChanged}, ThresholdsFromConfig(cfg))
}
// ClassifySignals is the structured form of ClassifyDepth: it scores already
// gathered DepthSignals against resolved thresholds. The deep test runs first so
// a wide-but-short change (many files, few lines) still escalates; the quick
// test is last so a deep-by-files change is never demoted to quick.
func ClassifySignals(sig DepthSignals, t DepthThresholds) Depth {
switch {
case sig.LinesChanged >= t.DeepMinLines || sig.FilesChanged >= t.DeepMinFiles:
return DepthDeep
case sig.LinesChanged <= t.QuickMaxLines:
return DepthQuick
default:
return DepthStandard
}
}
// DepthToComplexity maps a review depth onto the routing-complexity tier the
// LLM router selects a model from: quick and standard are simple single-pass
// work; deep is the complex, multi-phase pipeline.
func DepthToComplexity(d Depth) llm.Complexity {
if d == DepthDeep {
return llm.ComplexityComplex
}
return llm.ComplexitySimple
}
// ChangedLinesFromDiff sums the changed-line span over every hunk in the diff
// result. Each hunk contributes (EndLine - StartLine + 1) new-side lines; a
// degenerate hunk (EndLine < StartLine) counts as one line.
func ChangedLinesFromDiff(diff *analysis.DiffResult) int {
if diff == nil {
return 0
}
total := 0
for _, h := range diff.Hunks {
span := h.EndLine - h.StartLine + 1
if span < 1 {
span = 1
}
total += span
}
return total
}
// ChangedFilesFromDiff counts the distinct files a diff touches, unioning the
// explicit changed-file list with the files the hunks and changed symbols land
// in so a diff that only carries hunks (no separate file list) is still counted.
func ChangedFilesFromDiff(diff *analysis.DiffResult) int {
if diff == nil {
return 0
}
files := map[string]bool{}
for _, f := range diff.ChangedFiles {
if f = cleanPath(f); f != "" {
files[f] = true
}
}
for _, h := range diff.Hunks {
if f := cleanPath(h.FilePath); f != "" {
files[f] = true
}
}
for _, cs := range diff.ChangedSymbols {
if f := cleanPath(cs.FilePath); f != "" {
files[f] = true
}
}
return len(files)
}
// PlannerTool is one entry in the reference-only planner catalogue: the name of
// a graph tool the planner may REFERENCE when reasoning about what to inspect,
// plus a one-line description of what it surfaces. These are not callable tools
// in the review flow — they ground the model in the analyses that already exist.
type PlannerTool struct {
Name string
Description string
}
// plannerCatalogue is the fixed set of graph tools the deep-path planner can
// reference. They are presented to the model as context — "these analyses
// exist" — never as a callable tool surface, so the flow stays a single
// freeform completion with no tool-call loop.
var plannerCatalogue = []PlannerTool{
{"detect_changes", "the changed-symbol set + blast scope of this diff"},
{"diff_context", "the graph-enriched view of each changed symbol and its neighbours"},
{"explain_change_impact", "the downstream callers and dependents a change ripples to"},
{"verify_change", "the callers / interface implementors a signature change would break"},
{"contracts", "the API / behavioural contracts the changed symbols participate in"},
{"check_guards", "the team-convention guards the change must not violate"},
}
// PlannerCatalogue returns the reference-only graph-tool catalogue the deep
// planner is grounded in. The entries are descriptive references, NOT a callable
// tool surface — the review flow never dispatches them.
func PlannerCatalogue() []PlannerTool {
out := make([]PlannerTool, len(plannerCatalogue))
copy(out, plannerCatalogue)
return out
}
// PlannerCatalogueSpecs projects the reference catalogue onto the provider
// ToolSpec shape for callers that want to surface the same names through the
// llm tooling vocabulary. They remain reference-only — the review flow passes
// them as prompt context, not as a constrained tool enum.
func PlannerCatalogueSpecs() []llm.ToolSpec {
out := make([]llm.ToolSpec, 0, len(plannerCatalogue))
for _, t := range plannerCatalogue {
out = append(out, llm.ToolSpec{Name: t.Name, Description: t.Description})
}
return out
}
// renderPlannerCatalogue renders the reference catalogue as a prose block for
// the prompt. It is explicitly labelled reference-only so the model treats the
// entries as available analyses to reason about, not tools it can call.
func renderPlannerCatalogue() string {
if len(plannerCatalogue) == 0 {
return ""
}
var b strings.Builder
b.WriteString("## Graph analyses available (reference only — not callable here)\n")
b.WriteString("These graph tools have already characterised this change; reason about ")
b.WriteString("their results, do not request to call them:\n")
for _, t := range plannerCatalogue {
fmt.Fprintf(&b, "- %s — %s (reference only)\n", t.Name, t.Description)
}
return b.String()
}