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

302 lines
8.8 KiB
Go

package review
import (
"path/filepath"
"sort"
"strings"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/graph"
)
// Change classes — the coarse intent of a single changed symbol. The packaged
// review envelope stamps one of these on every changed symbol so a reviewer can
// triage the diff by kind before reading any code.
const (
ClassTest = "test" // the symbol (or its file) is a test
ClassConfig = "config" // a config / constant / variable surface, not behaviour
ClassFix = "fix" // a bug fix — error handling, guards, nil checks added
ClassRefactor = "refactor" // a pure restructuring — no added/removed lines, body shuffled
ClassFeature = "feature" // new behaviour — added lines that are not a fix/refactor
)
// ClassifyChange assigns a coarse change class to one changed symbol from its
// graph node kind, its file path, and the rendered +/- hunk text. The order of
// the checks is the precedence: test and config are structural (decided by the
// path / node kind), then the hunk is inspected — a fix (error-handling /
// guarding signal) outranks a feature, and a hunk with no net line change is a
// refactor. It is deterministic and graph-grounded but never calls an LLM.
func ClassifyChange(g graph.Store, sym analysis.ChangedSymbol, hunk string) string {
file := cleanPath(sym.FilePath)
// (1) Test: the file is a test file, or the symbol is a test function.
if isTestPath(file) || isTestSymbol(g, sym) {
return ClassTest
}
// (2) Config: a non-code surface (a constant / variable / config key /
// field) or a config-shaped file is a configuration change, not behaviour.
if isConfigPath(file) || isConfigKind(g, sym) {
return ClassConfig
}
added, removed := countHunkLines(hunk)
// (3) Fix: the change adds error-handling / guarding signal. A fix is the
// strongest behavioural signal so it outranks a plain feature.
if added > 0 && hunkLooksLikeFix(hunk) {
return ClassFix
}
// (4) Refactor: the hunk shuffles code without a net line change (equal
// added / removed, both non-zero) — a restructuring, not new behaviour.
if added > 0 && added == removed {
return ClassRefactor
}
// (5) Feature: net-new lines that are neither a fix nor a refactor. This is
// also the fallback when there is no hunk text to inspect.
if added >= removed {
return ClassFeature
}
// A net deletion with no other signal reads as a refactor (code removed).
return ClassRefactor
}
// isTestSymbol reports whether the changed symbol is a test function/method by
// its name or its enclosing file.
func isTestSymbol(g graph.Store, sym analysis.ChangedSymbol) bool {
if strings.HasPrefix(sym.Name, "Test") || strings.HasPrefix(sym.Name, "Benchmark") || strings.HasPrefix(sym.Name, "Fuzz") {
return true
}
if g != nil && sym.ID != "" {
if n := g.GetNode(sym.ID); n != nil && isTestPath(cleanPath(n.FilePath)) {
return true
}
}
return false
}
// isConfigKind reports whether the changed symbol's graph node is a
// non-behavioural surface — a constant, variable, field, enum member, or
// config key.
func isConfigKind(g graph.Store, sym analysis.ChangedSymbol) bool {
kind := sym.Kind
if kind == "" && g != nil && sym.ID != "" {
if n := g.GetNode(sym.ID); n != nil {
kind = string(n.Kind)
}
}
switch graph.NodeKind(kind) {
case graph.KindConstant, graph.KindVariable, graph.KindField,
graph.KindEnumMember, graph.KindConfigKey:
return true
}
return false
}
// isTestPath reports whether a path is a test file across the common language
// conventions (Go _test.go, *.test.*, *_spec.*, *Test.java, a /tests/ dir).
func isTestPath(file string) bool {
base := strings.ToLower(filepath.Base(file))
lower := strings.ToLower(filepath.ToSlash(file))
switch {
case strings.HasSuffix(base, "_test.go"):
return true
case strings.Contains(base, ".test.") || strings.Contains(base, ".spec."):
return true
case strings.HasSuffix(base, "_test.py") || strings.HasPrefix(base, "test_"):
return true
case strings.HasSuffix(base, "test.java") || strings.HasSuffix(base, "tests.java"):
return true
case strings.Contains(lower, "/tests/") || strings.Contains(lower, "/__tests__/"):
return true
}
return false
}
// isConfigPath reports whether a path is a configuration / manifest file by its
// extension or basename.
func isConfigPath(file string) bool {
base := strings.ToLower(filepath.Base(file))
switch filepath.Ext(base) {
case ".yaml", ".yml", ".toml", ".ini", ".env", ".cfg", ".conf", ".properties":
return true
}
switch base {
case "dockerfile", "makefile", ".gitignore", ".dockerignore":
return true
}
return false
}
// hunkLooksLikeFix reports whether the ADDED lines of a hunk carry an
// error-handling / guarding signal — a strong "this is a bug fix" heuristic. It
// only inspects added (`+`) lines so a fix being removed does not read as a fix.
func hunkLooksLikeFix(hunk string) bool {
for _, line := range strings.Split(hunk, "\n") {
if !strings.HasPrefix(line, "+") {
continue
}
body := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(line, "+")))
if body == "" {
continue
}
for _, kw := range fixKeywords {
if strings.Contains(body, kw) {
return true
}
}
}
return false
}
// fixKeywords are the lower-cased tokens whose presence on an added line marks
// the change as error-handling / guarding — the bug-fix signal.
var fixKeywords = []string{
"if err",
"!= nil",
"== nil",
"return err",
"recover()",
"panic(",
"errors.",
"fmt.errorf",
"try:",
"except",
"catch",
"throw",
"guard ",
"nil check",
"bounds",
"overflow",
}
// countHunkLines counts the added (`+`) and removed (`-`) content lines of a
// +/- hunk, skipping unified-diff file headers (`+++`, `---`).
func countHunkLines(hunk string) (added, removed int) {
for _, line := range strings.Split(hunk, "\n") {
switch {
case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"):
continue
case strings.HasPrefix(line, "+"):
added++
case strings.HasPrefix(line, "-"):
removed++
}
}
return added, removed
}
// VerificationCommand derives the single concrete command a reviewer runs to
// verify the change from the impacted test targets. The targets are the test
// file paths the get-test-targets walk produced; the command runs every
// distinct package directory they live in. Falls back to a whole-tree test run
// when no specific target is known so the command is always runnable.
func VerificationCommand(testTargets []string, lang string) string {
switch detectLang(lang, testTargets) {
case "go":
return goVerificationCommand(testTargets)
case "python":
if len(testTargets) == 0 {
return "pytest"
}
return "pytest " + strings.Join(uniqueSorted(testTargets), " ")
case "javascript", "typescript":
return "npm test"
case "rust":
return "cargo test"
default:
// Unknown toolchain: best-effort Go run over the targets (or the whole
// module when none is known) so the command is always runnable.
return goVerificationCommand(testTargets)
}
}
// goVerificationCommand turns a set of Go test files into a `go test` over the
// distinct package directories they live in, e.g. `go test ./internal/svc/...`.
// With no targets it runs the whole module.
func goVerificationCommand(testTargets []string) string {
dirs := map[string]bool{}
for _, t := range testTargets {
t = cleanPath(t)
if t == "" {
continue
}
dir := filepath.ToSlash(filepath.Dir(t))
if dir == "" || dir == "." {
dirs["."] = true
continue
}
dirs[dir] = true
}
if len(dirs) == 0 {
return "go test ./..."
}
pkgs := make([]string, 0, len(dirs))
for d := range dirs {
if d == "." {
pkgs = append(pkgs, "./...")
continue
}
pkgs = append(pkgs, "./"+d+"/...")
}
sort.Strings(pkgs)
return "go test " + strings.Join(pkgs, " ")
}
// detectLang resolves the toolchain for the verification command: an explicit
// language hint wins, else it is inferred from the test target extensions.
func detectLang(lang string, testTargets []string) string {
if l := normalizeLang(lang); l != "" {
return l
}
for _, t := range testTargets {
switch strings.ToLower(filepath.Ext(t)) {
case ".go":
return "go"
case ".py":
return "python"
case ".ts", ".tsx":
return "typescript"
case ".js", ".jsx":
return "javascript"
case ".rs":
return "rust"
}
}
return ""
}
func normalizeLang(lang string) string {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "go", "golang":
return "go"
case "python", "py":
return "python"
case "ts", "typescript":
return "typescript"
case "js", "javascript":
return "javascript"
case "rust", "rs":
return "rust"
}
return ""
}
func uniqueSorted(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
s = cleanPath(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
sort.Strings(out)
return out
}