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
245 lines
9.0 KiB
Go
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()
|
|
}
|