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
}