ajhahn.de
← eeco
Go 173 lines
package workflow

import (
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/ajhahnde/eeco/internal/ai"
	"github.com/ajhahnde/eeco/internal/config"
)

// stubProvider is a deterministic ai.Provider for workflow tests.
type stubProvider struct {
	calls int
	text  string
}

func (s *stubProvider) Name() string { return "stub" }
func (s *stubProvider) Run(context.Context, ai.Request) (ai.Response, error) {
	s.calls++
	return ai.Response{Text: s.text}, nil
}

func gateWith(t *testing.T, cfg *config.Config, p ai.Provider, consent bool) *ai.Gate {
	t.Helper()
	return &ai.Gate{
		Provider: p,
		Consent:  consent,
		Budget:   1,
		StateDir: filepath.Join(cfg.Workspace, "state"),
		Project:  "proj",
	}
}

func readLedger(t *testing.T, cfg *config.Config) string {
	t.Helper()
	b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", bugLedgerName))
	if err != nil {
		t.Fatalf("ledger: %v", err)
	}
	return string(b)
}

func TestBugSweep_FindsMarkersAndAppends(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "src/a.go", "package a\n// TODO: wire this up\nfunc A() {}\n")
	writeRepoFile(t, cfg.RepoRoot, "src/b.txt", "nothing here\n")

	res, err := bugSweep{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("code = %d, want %d (finding)", res.Code, CodeFinding)
	}
	if len(res.Findings) != 1 || !strings.Contains(res.Findings[0].Msg, "TODO") {
		t.Fatalf("findings = %+v", res.Findings)
	}
	if l := readLedger(t, cfg); !strings.Contains(l, "TODO: wire this up") {
		t.Errorf("ledger missing marker:\n%s", l)
	}
}

func TestBugSweep_LedgerIsAppendOnly(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "a.go", "// FIXME: one\n")
	if _, err := (bugSweep{}).Run(Env{Config: cfg}); err != nil {
		t.Fatal(err)
	}
	first := readLedger(t, cfg)
	if _, err := (bugSweep{}).Run(Env{Config: cfg}); err != nil {
		t.Fatal(err)
	}
	second := readLedger(t, cfg)
	if !strings.HasPrefix(second, first) {
		t.Error("second run rewrote earlier ledger content; must be append-only")
	}
	if strings.Count(second, "— static") != 2 {
		t.Errorf("want 2 static sections, got:\n%s", second)
	}
}

func TestBugSweep_CleanNoGateIsClean(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "ok.go", "package ok\nfunc Fine() {}\n")
	res, err := bugSweep{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("code = %d, want %d (clean)", res.Code, CodeClean)
	}
}

func TestBugSweep_NoMarkersNoConsentDefersAI(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "ok.go", "package ok\n")
	sp := &stubProvider{text: "analysis"}
	g := gateWith(t, cfg, sp, false)

	res, err := bugSweep{}.Run(Env{Config: cfg, Gate: g})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeAIDeferred {
		t.Fatalf("code = %d, want %d (AI deferred)", res.Code, CodeAIDeferred)
	}
	if sp.calls != 0 {
		t.Errorf("provider spent without consent: calls=%d", sp.calls)
	}
	q, _ := os.ReadFile(filepath.Join(cfg.Workspace, "state", "queue.md"))
	if !strings.Contains(string(q), "ai-parked") {
		t.Errorf("parked AI pass not queued:\n%s", q)
	}
}

func TestBugSweep_ConsentRunsAIAndAppends(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "ok.go", "package ok\n")
	sp := &stubProvider{text: "AI: looks fine, no high-risk items"}
	g := gateWith(t, cfg, sp, true)

	res, err := bugSweep{}.Run(Env{Config: cfg, Gate: g})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("code = %d, want %d (clean)", res.Code, CodeClean)
	}
	if sp.calls != 1 {
		t.Errorf("provider calls = %d, want 1", sp.calls)
	}
	l := readLedger(t, cfg)
	if !strings.Contains(l, "— ai") || !strings.Contains(l, "looks fine") {
		t.Errorf("ledger missing AI section:\n%s", l)
	}
	q, _ := os.ReadFile(filepath.Join(cfg.Workspace, "state", "queue.md"))
	if !strings.Contains(string(q), "bug-sweep") {
		t.Errorf("AI findings not queued for review:\n%s", q)
	}
}

// End-to-end proof the pre-write filter closes the gap: a provider response
// carrying an attribution fingerprint is blocked at the gate, bug-sweep takes
// the same deferred branch a parked pass takes, and the attribution text never
// reaches the bug ledger (the gitignored workspace file leak-guard never scans).
func TestBugSweep_FilterBlocksAttributionResponse(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "ok.go", "package ok\n") // no static markers -> AI pass
	sp := &stubProvider{text: fragCoAB + ": A Bot <b@x>\nlooks fine\n"}
	g := gateWith(t, cfg, sp, true)
	det, _ := NewDetector(nil)
	g.Scanner = det.ScanResponse

	res, err := bugSweep{}.Run(Env{Config: cfg, Gate: g})
	if err != nil {
		t.Fatal(err)
	}
	if sp.calls != 1 {
		t.Errorf("provider should have run once before the block; calls=%d", sp.calls)
	}
	if res.Code != CodeAIDeferred {
		t.Fatalf("a blocked AI pass must defer like a parked pass; code=%d want %d", res.Code, CodeAIDeferred)
	}
	if b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", bugLedgerName)); err == nil {
		if strings.Contains(string(b), fragCoAB) {
			t.Errorf("blocked attribution text leaked into the bug ledger:\n%s", b)
		}
	}
}