4b8f9aa39b
Build & push image / build-and-push (push) Successful in 33s
Two Phase-2 swarm upgrades: - auto.go: GADFLY_SPECIALISTS=auto routes the review — a selector model (GADFLY_SELECTOR_MODEL, else the review model) reads the changed files + PR description and picks the smallest relevant lens set from the catalog, and may propose ad-hoc lenses for gaps (e.g. migrations). Structured output via majordomo.Generate[T]; capped + de-duped; falls back to the default suite. - delegate.go: GADFLY_WORKER_MODEL adds a delegate_investigation tool so the reviewer offloads mechanical legwork (trace callers, gather usages) to a cheap worker sub-agent that returns an evidence-cited digest — the top model reasons over summaries, not raw file dumps. Workers get an fs-only toolbox (no sub-delegation). Unset = off. resolveSpecialists now also returns the registry + an auto flag. Docs (README Specialists + config table, CLAUDE.md, main.go header) + tests updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
136 lines
3.8 KiB
Go
136 lines
3.8 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func names(specs []Specialist) []string {
|
|
out := make([]string, len(specs))
|
|
for i, s := range specs {
|
|
out[i] = s.Name
|
|
}
|
|
return out
|
|
}
|
|
|
|
func eq(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func TestResolveSpecialists_DefaultSuite(t *testing.T) {
|
|
t.Setenv("GADFLY_SPECIALISTS", "")
|
|
specs, _, _, errs := resolveSpecialists(t.TempDir())
|
|
if len(errs) != 0 {
|
|
t.Fatalf("unexpected errors: %v", errs)
|
|
}
|
|
if !eq(names(specs), defaultSuite) {
|
|
t.Errorf("default = %v, want %v", names(specs), defaultSuite)
|
|
}
|
|
}
|
|
|
|
func TestResolveSpecialists_EnvSelection(t *testing.T) {
|
|
t.Setenv("GADFLY_SPECIALISTS", "security, tests")
|
|
specs, _, _, errs := resolveSpecialists(t.TempDir())
|
|
if len(errs) != 0 {
|
|
t.Fatalf("unexpected errors: %v", errs)
|
|
}
|
|
if !eq(names(specs), []string{"security", "tests"}) {
|
|
t.Errorf("got %v", names(specs))
|
|
}
|
|
}
|
|
|
|
func TestResolveSpecialists_AutoFlag(t *testing.T) {
|
|
t.Setenv("GADFLY_SPECIALISTS", "auto")
|
|
specs, registry, auto, errs := resolveSpecialists(t.TempDir())
|
|
if len(errs) != 0 {
|
|
t.Fatalf("unexpected errors: %v", errs)
|
|
}
|
|
if !auto {
|
|
t.Error("expected auto=true for GADFLY_SPECIALISTS=auto")
|
|
}
|
|
if specs != nil {
|
|
t.Errorf("auto mode should return nil specs, got %v", names(specs))
|
|
}
|
|
if len(registry) == 0 {
|
|
t.Error("auto mode should still return the registry catalog")
|
|
}
|
|
}
|
|
|
|
func TestResolveSpecialists_UnknownNameErrors(t *testing.T) {
|
|
t.Setenv("GADFLY_SPECIALISTS", "security,bogus")
|
|
specs, _, _, errs := resolveSpecialists(t.TempDir())
|
|
if len(errs) == 0 {
|
|
t.Fatal("expected an error for unknown specialist")
|
|
}
|
|
if !eq(names(specs), []string{"security"}) {
|
|
t.Errorf("valid ones should still resolve, got %v", names(specs))
|
|
}
|
|
}
|
|
|
|
func TestResolveSpecialists_EnvCustomDefinition(t *testing.T) {
|
|
t.Setenv("GADFLY_SPECIALIST_MIGRATIONS", "Review DB migrations for destructive ops.")
|
|
t.Setenv("GADFLY_SPECIALISTS", "migrations")
|
|
specs, _, _, errs := resolveSpecialists(t.TempDir())
|
|
if len(errs) != 0 {
|
|
t.Fatalf("unexpected errors: %v", errs)
|
|
}
|
|
if len(specs) != 1 || specs[0].Name != "migrations" || specs[0].Focus == "" {
|
|
t.Fatalf("custom specialist not registered: %+v", specs)
|
|
}
|
|
}
|
|
|
|
func TestResolveSpecialists_FileConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfg := `specialists: [security, migrations]
|
|
define:
|
|
- name: migrations
|
|
title: "DB migrations"
|
|
focus: "Review schema migrations for destructive or unindexed changes."
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, ".gadfly.yml"), []byte(cfg), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("GADFLY_SPECIALISTS", "") // let the file drive selection
|
|
specs, _, _, errs := resolveSpecialists(dir)
|
|
if len(errs) != 0 {
|
|
t.Fatalf("unexpected errors: %v", errs)
|
|
}
|
|
if !eq(names(specs), []string{"security", "migrations"}) {
|
|
t.Errorf("got %v", names(specs))
|
|
}
|
|
}
|
|
|
|
func TestParseVerdictAndWorst(t *testing.T) {
|
|
cases := map[string]verdict{
|
|
"VERDICT: No material issues found.": verdictClean,
|
|
"Minor issues\n- nit": verdictMinor,
|
|
"**Blocking issues found**": verdictBlocking,
|
|
"something unparseable": verdictUnknown,
|
|
}
|
|
for in, want := range cases {
|
|
if got := parseVerdict(in); got != want {
|
|
t.Errorf("parseVerdict(%q) = %v, want %v", in, got, want)
|
|
}
|
|
}
|
|
results := []specialistResult{
|
|
{spec: Specialist{Name: "security"}, verdict: verdictMinor},
|
|
{spec: Specialist{Name: "correctness"}, verdict: verdictBlocking},
|
|
{spec: Specialist{Name: "improvements"}, verdict: verdictBlocking}, // must not count
|
|
}
|
|
if w := worstVerdict(results[:2]); w != verdictBlocking {
|
|
t.Errorf("worst = %v, want blocking", w)
|
|
}
|
|
if w := worstVerdict([]specialistResult{results[0], results[2]}); w != verdictMinor {
|
|
t.Errorf("improvements should not escalate; worst = %v, want minor", w)
|
|
}
|
|
}
|