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 /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 }