// Package coverage parses Go's cover.out profile format and stamps // per-function coverage percentages onto graph nodes. The result is // the per-symbol meta.coverage_pct field that lets agents answer // "which symbols are untested" with real numbers rather than the // reverse-edge-empty heuristic the existing get_untested_symbols // tool relies on. // // Profile format (one segment per non-header line): // // mode: set | count | atomic // /:.,. // // startLine/endLine are 1-based; numStmt is the number of source // statements in the segment; count is execution count (or 0/1 for // `mode: set`). To compute a function's coverage we sum numStmt // over segments that fall fully within the function's line range, // then sum the same numStmt over segments where count > 0; the // ratio is the percentage. package coverage import ( "bufio" "bytes" "os" "path/filepath" "strconv" "strings" "github.com/zzet/gortex/internal/graph" ) // Segment is one parsed entry from a cover profile. Lines are // 1-based; columns are kept verbatim from the file but unused by // the projection — Go's cover output puts the boundary on a line // number that always belongs to the enclosing function. type Segment struct { File string StartLine int EndLine int NumStmt int Count int } // Parse reads cover-profile content and returns one Segment per // non-header line. Malformed lines are skipped silently — the // enrichment pass is best-effort like blame. func Parse(profile []byte) []Segment { var out []Segment scanner := bufio.NewScanner(bytes.NewReader(profile)) scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // Skip the mode header (and any other lines that aren't // segment shapes). if strings.HasPrefix(line, "mode:") { continue } seg, ok := parseSegment(line) if !ok { continue } out = append(out, seg) } return out } // ParseFile is a small convenience wrapper that reads the profile // from disk and delegates to Parse. func ParseFile(path string) ([]Segment, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } return Parse(data), nil } // parseSegment splits one profile line into a Segment. Format: // `:.,. ` — the last two // are simple ints; the file:range prefix is split on `:`,`,` and // `.` boundaries. func parseSegment(line string) (Segment, bool) { colon := strings.LastIndex(line, ":") if colon < 0 { return Segment{}, false } file := line[:colon] rest := line[colon+1:] // rest is `.,. `. fields := strings.Fields(rest) if len(fields) != 3 { return Segment{}, false } rng := fields[0] comma := strings.Index(rng, ",") if comma < 0 { return Segment{}, false } startSpec := rng[:comma] endSpec := rng[comma+1:] startLine, ok := parseLineCol(startSpec) if !ok { return Segment{}, false } endLine, ok := parseLineCol(endSpec) if !ok { return Segment{}, false } numStmt, err := strconv.Atoi(fields[1]) if err != nil { return Segment{}, false } count, err := strconv.Atoi(fields[2]) if err != nil { return Segment{}, false } return Segment{ File: file, StartLine: startLine, EndLine: endLine, NumStmt: numStmt, Count: count, }, true } // parseLineCol returns just the line component of a `.` // pair. Cover-profile columns aren't useful for the line-range // projection so we keep the parser focused. func parseLineCol(spec string) (int, bool) { dot := strings.Index(spec, ".") if dot < 0 { return 0, false } v, err := strconv.Atoi(spec[:dot]) if err != nil { return 0, false } return v, true } // CoverageStats is the accumulated coverage for one node range. type CoverageStats struct { NumStmt int // total statements counted Hit int // statements with count > 0 } // Percent returns coverage as a 0–100 float, or -1 when no // statements were counted (so callers can distinguish "uncovered" // from "no measurement"). func (s CoverageStats) Percent() float64 { if s.NumStmt == 0 { return -1 } return float64(s.Hit) / float64(s.NumStmt) * 100 } // EnrichGraph projects parsed segments onto every function / // method / closure / generic_param node by line range and stamps // meta.coverage_pct (rounded to 2 decimals) plus meta.coverage = // {num_stmt, hit}. Returns the number of nodes that received a // measurement (segments with NumStmt > 0). // // modulePath is the Go module path of the indexed repo (read from // go.mod) — needed because cover-profile file paths are // module-prefixed (`github.com/foo/bar/pkg/file.go`) while graph // file paths are repo-relative (`pkg/file.go`). Pass "" to skip // the prefix-strip, useful when the profile was generated against // raw paths. func EnrichGraph(g graph.Store, segments []Segment, modulePath string) int { if g == nil || len(segments) == 0 { return 0 } // Group segments by repo-relative file path so each file is // projected once even when the profile lists thousands of // segments per package. byFile := make(map[string][]Segment) for _, s := range segments { path := stripModulePrefix(s.File, modulePath) byFile[path] = append(byFile[path], s) } enriched := 0 // Collect every node whose Meta we stamp so we can round-trip it // back through the store at the end. On the in-memory backend the // in-place mutation already persists (n is the canonical node); on // disk backends (SQLite) n is a per-call GetNode/AllNodes // reconstruction, so without the write-back the coverage_pct stamp // is silently discarded the moment AllNodes' slice goes out of // scope — leaving analyze:coverage_gaps / health_score's coverage // axis empty on the disk backend. Mirrors releases.EnrichGraph and // the reach index, which already round-trip Meta through // AddNode/AddBatch. var stamped []*graph.Node covWriter, useCovSidecar := g.(graph.CoverageEnrichmentWriter) var covRows []graph.CoverageEnrichment for _, n := range g.AllNodes() { if !shouldEnrichCoverage(n.Kind) { continue } if n.FilePath == "" || n.StartLine == 0 { continue } segs, ok := byFile[n.FilePath] if !ok { continue } stats := projectStats(segs, n.StartLine, n.EndLine) if stats.NumStmt == 0 { continue } if n.Meta == nil { n.Meta = map[string]any{} } pct := roundTwo(stats.Percent()) n.Meta["coverage_pct"] = pct n.Meta["coverage"] = map[string]any{ "num_stmt": stats.NumStmt, "hit": stats.Hit, } stamped = append(stamped, n) if useCovSidecar { covRows = append(covRows, graph.CoverageEnrichment{ NodeID: n.ID, RepoPrefix: n.RepoPrefix, CoveragePct: pct, NumStmt: stats.NumStmt, Hit: stats.Hit, }) } enriched++ // EdgeCoveredBy: invert each EdgeTests pointing at this // node so agents can ask "which tests cover X" with the // coverage metric attached, without re-deriving it from // meta.coverage_pct + a second EdgeTests walk. Skip when // pct == 0 — uncovered code has no test relation worth // advertising. Dedup on the test ID because the same test // may call the subject multiple times. if pct == 0 { continue } seen := map[string]bool{} for _, in := range g.GetInEdges(n.ID) { if in == nil || in.Kind != graph.EdgeTests { continue } if seen[in.From] { continue } seen[in.From] = true g.AddEdge(&graph.Edge{ From: n.ID, To: in.From, Kind: graph.EdgeCoveredBy, FilePath: n.FilePath, Line: n.StartLine, Origin: graph.OriginASTInferred, Meta: map[string]any{ "coverage_pct": pct, }, }) } } // Persist the stamped node Meta back through the store in one batch // (a no-op-ish re-insert on the in-memory backend, the durable write // on disk backends). Without this the coverage_pct stamps never // survive on the disk backend. // Persist coverage. Prefer the typed sidecar (change A); on success // strip the Meta stamps so the node blob stays lean and skip the // AddBatch. On sidecar write failure, fall back to persisting Meta via // AddBatch so coverage is never lost (the readers' Meta fallback then // serves it). if useCovSidecar && len(covRows) > 0 { persisted := true byPrefix := map[string][]graph.CoverageEnrichment{} for _, r := range covRows { byPrefix[r.RepoPrefix] = append(byPrefix[r.RepoPrefix], r) } for prefix, rr := range byPrefix { if err := covWriter.BulkSetCoverage(prefix, rr); err != nil { persisted = false break } } if persisted { for _, n := range stamped { delete(n.Meta, "coverage_pct") delete(n.Meta, "coverage") } } else if len(stamped) > 0 { g.AddBatch(stamped, nil) } } else if len(stamped) > 0 { g.AddBatch(stamped, nil) } return enriched } // projectStats sums numStmt and hit-count for segments whose start // line falls inside [startLine, endLine] inclusive. Using the // segment start-line as the inclusion test matches the way Go's // cover tool reports per-function counts via `go tool cover -func` // — segments are scoped to the immediately enclosing block, so a // start-line containment check is the correct projection. func projectStats(segments []Segment, startLine, endLine int) CoverageStats { if endLine < startLine { endLine = startLine } var stats CoverageStats for _, s := range segments { if s.StartLine < startLine || s.StartLine > endLine { continue } stats.NumStmt += s.NumStmt if s.Count > 0 { stats.Hit += s.NumStmt } } return stats } // shouldEnrichCoverage limits enrichment to executable-symbol // kinds. Variables, fields, types — the structural kinds — have // no coverage signal of their own. func shouldEnrichCoverage(kind graph.NodeKind) bool { switch kind { case graph.KindFunction, graph.KindMethod, graph.KindClosure: return true } return false } // stripModulePrefix turns `github.com/foo/bar/pkg/file.go` into // `pkg/file.go` when modulePath is `github.com/foo/bar`. Always // strips a leading `./` regardless of modulePath — some profiles // (notably ones generated outside a module-aware build) emit // relative paths. func stripModulePrefix(file, modulePath string) string { file = strings.TrimPrefix(file, "./") if modulePath == "" { return file } prefix := modulePath + "/" if strings.HasPrefix(file, prefix) { return file[len(prefix):] } return file } // ReadModulePath extracts the module path declared by go.mod at // repoRoot. Returns "" when go.mod is missing or malformed — // EnrichGraph treats "" as "skip prefix-strip", which still // produces correct output for profiles generated against raw // paths. func ReadModulePath(repoRoot string) string { data, err := os.ReadFile(filepath.Join(repoRoot, "go.mod")) if err != nil { return "" } scanner := bufio.NewScanner(bytes.NewReader(data)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "module ") { return strings.TrimSpace(strings.TrimPrefix(line, "module ")) } } return "" } // roundTwo rounds a float to 2 decimal places. Used for the // coverage_pct field to keep the meta payload tidy in JSON // responses. func roundTwo(v float64) float64 { if v < 0 { return v } scaled := int64(v*100 + 0.5) return float64(scaled) / 100 }