Files
zzet--gortex/internal/coverage/coverage.go
T
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

379 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// <importpath>/<file.go>:<startLine>.<startCol>,<endLine>.<endCol> <numStmt> <count>
//
// 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:
// `<file>:<sl>.<sc>,<el>.<ec> <numStmt> <count>` — 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 `<sl>.<sc>,<el>.<ec> <numStmt> <count>`.
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 `<line>.<col>`
// 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 0100 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
}