Build & push image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]> Co-committed-by: Steve Dudenhoeffer <[email protected]>
237 lines
8.1 KiB
Go
237 lines
8.1 KiB
Go
package main
|
|
|
|
// Structured findings: the machine-readable contract between a lens's output and
|
|
// the telemetry/consolidation pipeline. Each lens is asked (see
|
|
// scripts/system-prompt.txt) to append a fenced ```gadfly-findings code block
|
|
// holding a JSON array of its findings. Parsing that exact block is far more
|
|
// reliable than scraping prose with a path:line regex (emit.go's heuristic),
|
|
// and it carries PER-FINDING severity + confidence the prose verdict can't.
|
|
//
|
|
// Everything here degrades gracefully: a missing, unterminated, or malformed
|
|
// block makes extractStructuredFindings return ok=false (and yield no findings),
|
|
// so the caller falls back to the heuristic scrape — a weak model that ignores
|
|
// the contract still contributes findings, exactly as before.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// structuredFinding mirrors one element of the ```gadfly-findings JSON array.
|
|
// Line is json.Number so we tolerate both 123 and "123" from less-precise models.
|
|
type structuredFinding struct {
|
|
File string `json:"file"`
|
|
Line json.Number `json:"line"`
|
|
Severity string `json:"severity"`
|
|
Confidence string `json:"confidence"`
|
|
Title string `json:"title"`
|
|
Detail string `json:"detail"` // optional; the prose paragraph is used when absent
|
|
}
|
|
|
|
// findingsFence is the info-string that tags the machine-readable block.
|
|
const findingsFence = "gadfly-findings"
|
|
|
|
// extractStructuredFindings parses the ```gadfly-findings JSON block out of a
|
|
// lens's markdown. It returns the findings and ok=true when a TERMINATED block is
|
|
// present AND parses as a JSON array (an empty array is valid "nothing found" —
|
|
// ok is still true). A missing, unterminated, or unparseable block returns
|
|
// ok=false so the caller falls back to the heuristic scrape.
|
|
//
|
|
// Findings are deduped by file:line (keeping the first, matching parseFindings),
|
|
// findings with no usable file are dropped, and each title/detail is backfilled
|
|
// from the prose when the JSON omits it (best of both: exact location + the human
|
|
// context the model already wrote).
|
|
func extractStructuredFindings(out string) ([]finding, bool) {
|
|
lines := strings.Split(out, "\n")
|
|
start, end, ok := findingsSpan(lines)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
var raw []structuredFinding
|
|
if err := json.Unmarshal([]byte(strings.Join(lines[start+1:end], "\n")), &raw); err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
findings := make([]finding, 0, len(raw))
|
|
seen := map[string]bool{}
|
|
var prose map[string]string // built lazily — only if a finding lacks its own detail
|
|
for _, sf := range raw {
|
|
file := strings.TrimSpace(sf.File)
|
|
if file == "" {
|
|
continue
|
|
}
|
|
ln := 0
|
|
if n, err := strconv.Atoi(strings.TrimSpace(sf.Line.String())); err == nil && n > 0 {
|
|
ln = n
|
|
}
|
|
key := file + ":" + strconv.Itoa(ln)
|
|
if ln > 0 { // only dedupe concrete locations; unknown-line findings are kept
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
}
|
|
detail := strings.TrimSpace(sf.Detail)
|
|
if detail == "" && ln > 0 {
|
|
if prose == nil {
|
|
prose = proseParagraphs(out)
|
|
}
|
|
detail = prose[key]
|
|
}
|
|
title := strings.TrimSpace(sf.Title)
|
|
if title == "" { // never empty: fall back to the prose detail, then the location
|
|
if detail != "" {
|
|
title = truncate(detail, 120)
|
|
} else if ln > 0 {
|
|
title = key
|
|
} else {
|
|
title = file
|
|
}
|
|
}
|
|
findings = append(findings, finding{
|
|
file: file,
|
|
line: ln,
|
|
title: title,
|
|
detail: truncate(detail, 500),
|
|
severity: normalizeSeverity(sf.Severity),
|
|
confidence: normalizeConfidence(sf.Confidence),
|
|
})
|
|
if len(findings) >= maxFindingsPerLens {
|
|
break
|
|
}
|
|
}
|
|
return findings, true
|
|
}
|
|
|
|
// extractStructuredFindingsOrScrape returns a lens's findings, preferring the
|
|
// structured ```gadfly-findings block and falling back to the heuristic prose
|
|
// scrape when the block is absent, unterminated/malformed, OR parsed to zero
|
|
// usable findings (e.g. an empty [] emitted alongside real prose findings).
|
|
// Factored out of emit() so the fallback rule is unit-testable.
|
|
func extractStructuredFindingsOrScrape(r specialistResult) []finding {
|
|
if fs, _ := extractStructuredFindings(r.out); len(fs) > 0 {
|
|
return fs
|
|
}
|
|
return parseFindings(r.spec, r.out)
|
|
}
|
|
|
|
// stripFindingsBlock removes every TERMINATED ```gadfly-findings block from out so
|
|
// the machine-readable JSON never shows in the rendered comment. An UNTERMINATED
|
|
// fence is left in place — treating it as a block would swallow the rest of the
|
|
// comment (e.g. when a model's output was truncated mid-block). Trailing
|
|
// whitespace is trimmed.
|
|
func stripFindingsBlock(out string) string {
|
|
lines := strings.Split(out, "\n")
|
|
for {
|
|
start, end, ok := findingsSpan(lines)
|
|
if !ok {
|
|
break
|
|
}
|
|
lines = append(lines[:start:start], lines[end+1:]...)
|
|
}
|
|
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
|
|
}
|
|
|
|
// findingsSpan returns the [start,end] inclusive line indices of the first
|
|
// TERMINATED ```gadfly-findings block in lines (start = the opening fence, end =
|
|
// the closing fence), or ok=false when there is none or it is unterminated.
|
|
// extract and strip share it so they always agree on what is (and isn't) a block.
|
|
func findingsSpan(lines []string) (start, end int, ok bool) {
|
|
start = -1
|
|
for i, ln := range lines {
|
|
if isFindingsOpen(ln) {
|
|
start = i
|
|
break
|
|
}
|
|
}
|
|
if start < 0 {
|
|
return 0, 0, false
|
|
}
|
|
for j := start + 1; j < len(lines); j++ {
|
|
if isFenceClose(lines[j]) {
|
|
return start, j, true
|
|
}
|
|
}
|
|
return 0, 0, false // unterminated
|
|
}
|
|
|
|
// fenceInfo returns the info-string (text after the backticks) of a code-fence
|
|
// line and whether the line opens/closes a fence at all. A bare ``` yields ("",
|
|
// true); ```gadfly-findings yields ("gadfly-findings", true).
|
|
func fenceInfo(line string) (string, bool) {
|
|
t := strings.TrimSpace(line)
|
|
if !strings.HasPrefix(t, "```") {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(strings.TrimLeft(t, "`")), true
|
|
}
|
|
|
|
// isFindingsOpen reports whether line opens a ```gadfly-findings block, matching
|
|
// the info-string EXACTLY (not as a substring) so a fence like ```not-findings
|
|
// can't masquerade as ours.
|
|
func isFindingsOpen(line string) bool {
|
|
info, ok := fenceInfo(line)
|
|
return ok && info == findingsFence
|
|
}
|
|
|
|
// isFenceClose reports whether line is a bare closing fence (``` with no info).
|
|
func isFenceClose(line string) bool {
|
|
info, ok := fenceInfo(line)
|
|
return ok && info == ""
|
|
}
|
|
|
|
// proseParagraphs maps "file:line" -> the prose paragraph that first references
|
|
// it, so a structured finding without its own detail can borrow the human
|
|
// context the model already wrote. Built from the markdown OUTSIDE the findings
|
|
// block (the block is JSON, not prose).
|
|
func proseParagraphs(out string) map[string]string {
|
|
prose := stripFindingsBlock(out)
|
|
lines := strings.Split(prose, "\n")
|
|
m := map[string]string{}
|
|
for _, loc := range pathLineRe.FindAllStringSubmatchIndex(prose, -1) {
|
|
key := prose[loc[2]:loc[3]] + ":" + prose[loc[4]:loc[5]]
|
|
if _, dup := m[key]; dup {
|
|
continue
|
|
}
|
|
li := strings.Count(prose[:loc[0]], "\n")
|
|
m[key] = paragraphAt(lines, li)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// normalizeSeverity maps a model's severity word onto the canonical set
|
|
// (critical/high/medium/small/trivial), accepting common synonyms. An
|
|
// unrecognized value is returned lowercased so the store still sees the raw word.
|
|
func normalizeSeverity(s string) string {
|
|
switch t := strings.ToLower(strings.TrimSpace(s)); t {
|
|
case "critical", "crit", "blocker", "blocking":
|
|
return "critical"
|
|
case "high", "major", "severe":
|
|
return "high"
|
|
case "medium", "moderate":
|
|
return "medium"
|
|
case "small", "low", "minor":
|
|
return "small"
|
|
case "trivial", "nit", "nitpick", "info", "informational", "style", "cosmetic":
|
|
return "trivial"
|
|
default:
|
|
return t
|
|
}
|
|
}
|
|
|
|
// normalizeConfidence maps a model's confidence word onto high/medium/low,
|
|
// accepting common synonyms; an unrecognized value is returned lowercased.
|
|
func normalizeConfidence(s string) string {
|
|
switch t := strings.ToLower(strings.TrimSpace(s)); t {
|
|
case "high", "certain", "confirmed", "verified":
|
|
return "high"
|
|
case "medium", "med", "moderate":
|
|
return "medium"
|
|
case "low", "unsure", "tentative", "unverified", "speculative":
|
|
return "low"
|
|
default:
|
|
return t
|
|
}
|
|
}
|