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
301 lines
9.0 KiB
Go
301 lines
9.0 KiB
Go
package review
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/platform"
|
|
)
|
|
|
|
// isolateGlobalConfig points platform.ConfigDir() at a fresh, empty
|
|
// tempdir for the duration of a test so the global rule layer never
|
|
// reads the developer's real ~/.gortex/config.yaml. It returns the
|
|
// resolved config dir (already created) so callers can plant a global
|
|
// config.yaml when they want to exercise that layer.
|
|
func isolateGlobalConfig(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", root)
|
|
dir := platform.ConfigDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir config dir: %v", err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// TestReviewConfigRoundTrip asserts config.Load round-trips a `review:`
|
|
// block carrying every field: the rule list, the gating thresholds, the
|
|
// depth-selection bounds, and post.allow_public.
|
|
func TestReviewConfigRoundTrip(t *testing.T) {
|
|
yaml := `
|
|
review:
|
|
rules:
|
|
- name: tests
|
|
path: "**/*_test.go"
|
|
severity: warning
|
|
rulepack: test
|
|
disabled: false
|
|
- name: auth
|
|
path: "internal/auth/**"
|
|
severity: error
|
|
rulepack: security
|
|
disabled: true
|
|
min_confidence: 0.75
|
|
min_severity: warning
|
|
categories:
|
|
- nil-deref
|
|
- injection
|
|
max_findings: 25
|
|
quick_max_lines: 40
|
|
deep_min_lines: 400
|
|
deep_min_files: 12
|
|
post:
|
|
allow_public: true
|
|
`
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, ".gortex.yaml")
|
|
if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load: %v", err)
|
|
}
|
|
r := cfg.Review
|
|
|
|
if len(r.Rules) != 2 {
|
|
t.Fatalf("rules: got %d want 2", len(r.Rules))
|
|
}
|
|
if r.Rules[0].Name != "tests" || r.Rules[0].Path != "**/*_test.go" ||
|
|
r.Rules[0].Severity != "warning" || r.Rules[0].Rulepack != "test" || r.Rules[0].Disabled {
|
|
t.Errorf("rule[0] mismatch: %+v", r.Rules[0])
|
|
}
|
|
if r.Rules[1].Name != "auth" || r.Rules[1].Path != "internal/auth/**" ||
|
|
r.Rules[1].Severity != "error" || r.Rules[1].Rulepack != "security" || !r.Rules[1].Disabled {
|
|
t.Errorf("rule[1] mismatch: %+v", r.Rules[1])
|
|
}
|
|
if r.MinConfidence != 0.75 {
|
|
t.Errorf("min_confidence: got %v want 0.75", r.MinConfidence)
|
|
}
|
|
if r.MinSeverity != "warning" {
|
|
t.Errorf("min_severity: got %q want warning", r.MinSeverity)
|
|
}
|
|
if len(r.Categories) != 2 || r.Categories[0] != "nil-deref" || r.Categories[1] != "injection" {
|
|
t.Errorf("categories: got %v", r.Categories)
|
|
}
|
|
if r.MaxFindings != 25 {
|
|
t.Errorf("max_findings: got %d want 25", r.MaxFindings)
|
|
}
|
|
if r.QuickMaxLines != 40 {
|
|
t.Errorf("quick_max_lines: got %d want 40", r.QuickMaxLines)
|
|
}
|
|
if r.DeepMinLines != 400 {
|
|
t.Errorf("deep_min_lines: got %d want 400", r.DeepMinLines)
|
|
}
|
|
if r.DeepMinFiles != 12 {
|
|
t.Errorf("deep_min_files: got %d want 12", r.DeepMinFiles)
|
|
}
|
|
if !r.Post.AllowPublic {
|
|
t.Errorf("post.allow_public: got false want true")
|
|
}
|
|
}
|
|
|
|
// TestRuleResolverDefaultsOnly asserts that with no config files present
|
|
// the embedded defaults resolve: a test file selects the test rulepack
|
|
// (ahead of the `**` catch-all), a non-test file falls to general, and
|
|
// `**` matches arbitrarily nested paths.
|
|
func TestRuleResolverDefaultsOnly(t *testing.T) {
|
|
isolateGlobalConfig(t)
|
|
repoRoot := t.TempDir() // empty repo — no .gortex.yaml, no .gortex/review.yaml
|
|
|
|
rr, err := NewRuleResolver("", repoRoot)
|
|
if err != nil {
|
|
t.Fatalf("NewRuleResolver: %v", err)
|
|
}
|
|
|
|
// `**/*_test.go` wins over `**` for a test file, at any depth.
|
|
for _, p := range []string{"foo_test.go", "internal/pkg/foo_test.go", "a/b/c/d_test.go"} {
|
|
got, ok := rr.RuleFor(p)
|
|
if !ok {
|
|
t.Fatalf("RuleFor(%q): no match", p)
|
|
}
|
|
if got.Rulepack != "test" {
|
|
t.Errorf("RuleFor(%q): rulepack=%q want test", p, got.Rulepack)
|
|
}
|
|
}
|
|
|
|
// Non-test files fall through to the `**` general catch-all, nested too.
|
|
for _, p := range []string{"main.go", "internal/auth/login.go", "a/b/c/d/e/f.go"} {
|
|
got, ok := rr.RuleFor(p)
|
|
if !ok {
|
|
t.Fatalf("RuleFor(%q): no match (** must match nested paths)", p)
|
|
}
|
|
if got.Rulepack != "general" {
|
|
t.Errorf("RuleFor(%q): rulepack=%q want general", p, got.Rulepack)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRuleResolverLayerPrecedence asserts the four layers merge in
|
|
// precedence order: a custom rule shadows a project rule for the same
|
|
// glob, and the project layer in turn shadows the global layer.
|
|
func TestRuleResolverLayerPrecedence(t *testing.T) {
|
|
globalDir := isolateGlobalConfig(t)
|
|
|
|
// Global layer: config.yaml with a rule for *.go.
|
|
globalYAML := `
|
|
review:
|
|
rules:
|
|
- name: global-go
|
|
path: "**/*.go"
|
|
rulepack: global-pack
|
|
`
|
|
if err := os.WriteFile(filepath.Join(globalDir, "config.yaml"), []byte(globalYAML), 0o644); err != nil {
|
|
t.Fatalf("write global config: %v", err)
|
|
}
|
|
|
|
// Project layer: .gortex.yaml shadows the global rule for *.go.
|
|
repoRoot := t.TempDir()
|
|
projectYAML := `
|
|
review:
|
|
rules:
|
|
- name: project-go
|
|
path: "**/*.go"
|
|
rulepack: project-pack
|
|
`
|
|
if err := os.WriteFile(filepath.Join(repoRoot, ".gortex.yaml"), []byte(projectYAML), 0o644); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
|
|
// Custom layer (--rules): shadows everything for the same glob.
|
|
customDir := t.TempDir()
|
|
customPath := filepath.Join(customDir, "review.yaml")
|
|
customYAML := `
|
|
rules:
|
|
- name: custom-go
|
|
path: "**/*.go"
|
|
rulepack: custom-pack
|
|
`
|
|
if err := os.WriteFile(customPath, []byte(customYAML), 0o644); err != nil {
|
|
t.Fatalf("write custom config: %v", err)
|
|
}
|
|
|
|
// All four layers present → custom wins.
|
|
rr, err := NewRuleResolver(customPath, repoRoot)
|
|
if err != nil {
|
|
t.Fatalf("NewRuleResolver: %v", err)
|
|
}
|
|
got, ok := rr.RuleFor("internal/x.go")
|
|
if !ok {
|
|
t.Fatalf("RuleFor: no match")
|
|
}
|
|
if got.Name != "custom-go" || got.Rulepack != "custom-pack" {
|
|
t.Errorf("custom should shadow project+global: got %+v", got)
|
|
}
|
|
|
|
// Drop the custom layer → project wins over global.
|
|
rr2, err := NewRuleResolver("", repoRoot)
|
|
if err != nil {
|
|
t.Fatalf("NewRuleResolver (no custom): %v", err)
|
|
}
|
|
got2, ok := rr2.RuleFor("internal/x.go")
|
|
if !ok {
|
|
t.Fatalf("RuleFor (no custom): no match")
|
|
}
|
|
if got2.Name != "project-go" || got2.Rulepack != "project-pack" {
|
|
t.Errorf("project should shadow global: got %+v", got2)
|
|
}
|
|
}
|
|
|
|
// TestRuleResolverRepoLocalCustom asserts the repo-local
|
|
// .gortex/review.yaml is picked up as the custom layer when no explicit
|
|
// --rules path is given.
|
|
func TestRuleResolverRepoLocalCustom(t *testing.T) {
|
|
isolateGlobalConfig(t)
|
|
repoRoot := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(repoRoot, ".gortex"), 0o755); err != nil {
|
|
t.Fatalf("mkdir .gortex: %v", err)
|
|
}
|
|
repoLocalYAML := `
|
|
rules:
|
|
- name: repo-local-go
|
|
path: "**/*.go"
|
|
rulepack: repo-local-pack
|
|
`
|
|
if err := os.WriteFile(filepath.Join(repoRoot, ".gortex", "review.yaml"), []byte(repoLocalYAML), 0o644); err != nil {
|
|
t.Fatalf("write repo-local review.yaml: %v", err)
|
|
}
|
|
|
|
rr, err := NewRuleResolver("", repoRoot)
|
|
if err != nil {
|
|
t.Fatalf("NewRuleResolver: %v", err)
|
|
}
|
|
got, ok := rr.RuleFor("internal/x.go")
|
|
if !ok {
|
|
t.Fatalf("RuleFor: no match")
|
|
}
|
|
if got.Name != "repo-local-go" || got.Rulepack != "repo-local-pack" {
|
|
t.Errorf("repo-local .gortex/review.yaml should win: got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestRuleResolverDisabledSkipped asserts a Disabled rule is skipped
|
|
// during resolution, so a lower-precedence rule (or the embedded
|
|
// catch-all) governs the file instead.
|
|
func TestRuleResolverDisabledSkipped(t *testing.T) {
|
|
isolateGlobalConfig(t)
|
|
repoRoot := t.TempDir()
|
|
|
|
// A disabled custom rule for *.go must NOT shadow anything. A second,
|
|
// enabled custom rule for the same glob proves the disabled one was
|
|
// skipped (the enabled one wins) rather than the disabled one matching.
|
|
customDir := t.TempDir()
|
|
customPath := filepath.Join(customDir, "review.yaml")
|
|
customYAML := `
|
|
rules:
|
|
- name: disabled-go
|
|
path: "**/*.go"
|
|
rulepack: disabled-pack
|
|
disabled: true
|
|
- name: enabled-go
|
|
path: "**/*.go"
|
|
rulepack: enabled-pack
|
|
`
|
|
if err := os.WriteFile(customPath, []byte(customYAML), 0o644); err != nil {
|
|
t.Fatalf("write custom config: %v", err)
|
|
}
|
|
|
|
rr, err := NewRuleResolver(customPath, repoRoot)
|
|
if err != nil {
|
|
t.Fatalf("NewRuleResolver: %v", err)
|
|
}
|
|
got, ok := rr.RuleFor("internal/x.go")
|
|
if !ok {
|
|
t.Fatalf("RuleFor: no match")
|
|
}
|
|
if got.Name == "disabled-go" {
|
|
t.Fatalf("disabled rule must be skipped, but it matched: %+v", got)
|
|
}
|
|
if got.Name != "enabled-go" || got.Rulepack != "enabled-pack" {
|
|
t.Errorf("enabled rule should win after disabled skipped: got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestDefaultReviewRules asserts the embedded layer is the documented
|
|
// pair: a `**/*_test.go` test rulepack ahead of a `**` general catch-all.
|
|
func TestDefaultReviewRules(t *testing.T) {
|
|
rules := defaultReviewRules()
|
|
if len(rules) != 2 {
|
|
t.Fatalf("defaultReviewRules: got %d want 2", len(rules))
|
|
}
|
|
if rules[0].Path != "**/*_test.go" || rules[0].Rulepack != "test" {
|
|
t.Errorf("rule[0]: got %+v want test/**/*_test.go", rules[0])
|
|
}
|
|
if rules[1].Path != "**" || rules[1].Rulepack != "general" {
|
|
t.Errorf("rule[1]: got %+v want general/**", rules[1])
|
|
}
|
|
}
|