ajhahn.de
← eeco
Go 220 lines
package workflow

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"

	"github.com/ajhahnde/eeco/internal/ai"
	"github.com/ajhahnde/eeco/internal/gitx"
	"github.com/ajhahnde/eeco/internal/queue"
)

// bugMarkerRE matches the conventional, uppercase bug markers. It is
// uppercase- and word-bounded on purpose: a lowercase "todo" in prose
// or a substring like "FIXMEnow" must not trip it, the same precision
// principle the attribution detector follows.
var bugMarkerRE = regexp.MustCompile(`\b(TODO|FIXME|XXX|HACK|BUG)\b`)

// bugLedgerName is the append-only ledger inside <workspace>/state/.
const bugLedgerName = "bug-ledger.md"

// bugSweep is the builtin bug finder. It does a deterministic static
// triage of the source tree into an append-only ledger, and — only with
// consent and budget — adds a gated AI reasoning pass over that triage.
// Without consent (or on provider failure) the AI prompt is parked and
// queued by the Gate; the static report still stands. It writes only
// inside the workspace and never blocks: when git is unavailable it
// falls back to a filesystem walk rather than refusing to run.
type bugSweep struct{}

func (bugSweep) Name() string { return "bug-sweep" }

func (bugSweep) Summary() string {
	return "static bug-marker triage into an append-only ledger; optional gated AI pass"
}

func (bugSweep) Run(env Env) (Result, error) {
	cfg := env.Config

	files, err := sourceFiles(cfg.RepoRoot, cfg.WorkspaceName)
	if err != nil {
		return Result{}, fmt.Errorf("bug-sweep: %w", err)
	}

	var findings []Finding
	for _, f := range files {
		ln := 0
		for _, line := range splitLines(f.content) {
			ln++
			if m := bugMarkerRE.FindString(line); m != "" {
				findings = append(findings, Finding{
					Path: f.rel,
					Line: ln,
					Msg:  m + ": " + condense(line),
				})
			}
		}
	}
	sort.Slice(findings, func(i, j int) bool {
		if findings[i].Path != findings[j].Path {
			return findings[i].Path < findings[j].Path
		}
		return findings[i].Line < findings[j].Line
	})

	stamp := time.Now().UTC()
	if err := appendBugLedger(cfg.Workspace, stamp, "static", staticLedgerBody(findings)); err != nil {
		return Result{}, fmt.Errorf("bug-sweep: ledger: %w", err)
	}

	// Gated AI reasoning pass over the static triage. The Gate enforces
	// consent, budget, and prompt-parking; a Skipped outcome is normal,
	// not an error.
	aiSkipped := false
	if env.Gate != nil {
		out, gerr := env.Gate.Run(context.Background(), ai.Request{
			Label:  "bug-sweep",
			System: "Project: " + filepath.Base(cfg.RepoRoot),
			User:   bugSweepUserPrompt(findings),
		})
		if gerr != nil {
			return Result{}, fmt.Errorf("bug-sweep: ai gate: %w", gerr)
		}
		if out.Ran {
			if err := appendBugLedger(cfg.Workspace, stamp, "ai", out.Text); err != nil {
				return Result{}, fmt.Errorf("bug-sweep: ledger: %w", err)
			}
			_ = queue.Append(filepath.Join(cfg.Workspace, "state"), queue.Item{
				Kind:    "bug-sweep",
				Title:   "AI bug-sweep findings ready for review",
				Project: filepath.Base(cfg.RepoRoot),
				Detail:  "appended to state/" + bugLedgerName,
				Date:    stamp,
			})
		} else {
			aiSkipped = true
		}
	}

	switch {
	case len(findings) > 0:
		return Result{
			Code:     CodeFinding,
			Summary:  fmt.Sprintf("%d bug marker(s) in source", len(findings)),
			Findings: findings,
		}, nil
	case aiSkipped:
		return Result{
			Code:    CodeAIDeferred,
			Summary: "no static markers; AI pass deferred (prompt parked)",
		}, nil
	default:
		return Result{Code: CodeClean, Summary: "no bug markers found"}, nil
	}
}

// srcFile is one scanned text file, repo-relative path and content.
type srcFile struct {
	rel     string
	content string
}

// sourceFiles returns the text files to triage. It prefers the
// git-tracked set (ignores vendored / generated trees automatically);
// when git is unavailable it falls back to a filesystem walk so the
// workflow is never blocked (binding design decision, PLAN.md).
func sourceFiles(root, workspaceName string) ([]srcFile, error) {
	if gitx.Available() {
		tracked, err := gitx.TrackedFiles(root)
		if err == nil {
			var out []srcFile
			for _, rel := range tracked {
				b, rerr := os.ReadFile(filepath.Join(root, rel))
				if rerr != nil || !isText(b) {
					continue
				}
				out = append(out, srcFile{rel: rel, content: string(b)})
			}
			return out, nil
		}
		// git present but listing failed (e.g. not a repo yet): walk.
	}
	var out []srcFile
	err := walkText(root, workspaceName, func(rel, content string) error {
		out = append(out, srcFile{rel: rel, content: content})
		return nil
	})
	return out, err
}

// condense trims a source line to a short, single-line ledger excerpt.
func condense(s string) string {
	s = strings.TrimSpace(s)
	const max = 100
	if len(s) > max {
		s = s[:max] + "…"
	}
	return s
}

func staticLedgerBody(findings []Finding) string {
	if len(findings) == 0 {
		return "no bug markers found"
	}
	var b strings.Builder
	for _, f := range findings {
		fmt.Fprintf(&b, "- %s:%d  %s\n", f.Path, f.Line, f.Msg)
	}
	return strings.TrimRight(b.String(), "\n")
}

// bugSweepUserPrompt builds the volatile User turn: the triage
// instruction and the static findings. The project handle is the cheap
// System block, threaded separately at the call site.
func bugSweepUserPrompt(findings []Finding) string {
	var b strings.Builder
	b.WriteString("Static bug-marker triage follows. Identify the few highest-risk " +
		"items, likely root causes, and concrete next steps. Be terse.\n\n")
	if len(findings) == 0 {
		b.WriteString("(no static markers — reason from the codebase structure)\n")
	}
	for _, f := range findings {
		fmt.Fprintf(&b, "%s:%d  %s\n", f.Path, f.Line, f.Msg)
	}
	return b.String()
}

// appendBugLedger appends one dated section to the append-only ledger,
// creating it with a header on first use. The file is opened O_APPEND
// and never truncated, so prior runs are preserved verbatim.
func appendBugLedger(workspace string, stamp time.Time, phase, body string) error {
	dir := filepath.Join(workspace, "state")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	path := filepath.Join(dir, bugLedgerName)
	created := false
	if _, err := os.Stat(path); os.IsNotExist(err) {
		created = true
	}
	fh, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
	if err != nil {
		return err
	}
	defer fh.Close()
	var b strings.Builder
	if created {
		b.WriteString("# bug-sweep ledger\n\nAppend-only. Each run adds a dated section; " +
			"earlier sections are never rewritten.\n")
	}
	fmt.Fprintf(&b, "\n## %s%s\n\n%s\n", stamp.Format(time.RFC3339), phase, body)
	_, err = fh.WriteString(b.String())
	return err
}