chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
// 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 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
|
||||
}
|
||||
Reference in New Issue
Block a user