diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-31 13:07:40 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-31 13:07:40 -0500 |
| commit | 47ca509e69b6a1472a735a4b9521a952e7434491 (patch) | |
| tree | 9101eb376d233c455f653ad857474b3a0dae767f /languages/go/claude/scripts | |
| parent | af478a42b18c4d5e0712c4cb43036126d36c56b5 (diff) | |
| download | rulesets-47ca509e69b6a1472a735a4b9521a952e7434491.tar.gz rulesets-47ca509e69b6a1472a735a4b9521a952e7434491.zip | |
feat(go): add coverage-summary as a Go bundle coverage slice
Third language in the coverage-summary fan-out, after Elisp and Python. Same kernel: count every source file on disk that's absent from the coverage profile as 0% and weight the project number by file, so an untested file stays visible instead of being averaged away.
The script at languages/go/claude/scripts/coverage-summary.go parses a cover.out profile, maps each import-path-qualified entry back to an on-disk relative path using the module path from go.mod, and reports a file-weighted number plus the missing files. It's standard library only, so it runs anywhere via go run, and it doesn't reimplement the per-function table that go tool cover -func already prints. I proved it against a real go test -coverprofile run, not just a synthetic fixture, since the Go toolchain is installed here.
Two findings to flag. Modern go test ./... already lists every module package in the profile at 0% even when untested, so for in-module code the missing-file list is usually empty. The detection earns its keep on build-tagged files and dirs outside ./.... And this is a coverage-only slice of a Go bundle that doesn't otherwise exist yet: there's no go.md rule file, so sync-language-bundle.sh can't fingerprint it (detection keys on a bundle's own .claude/rules). The script installs via make install-lang LANG=go but won't be sync-maintained until the Go bundle gets real rules and a CLAUDE.md. Building that out is the natural companion task.
Tests are black-box: a Go test in its own throwaway module runs the script via go run against temp fixtures and checks output, so the shipped script dir stays test-free. They cover missing-file detection, all-tracked, _test.go exclusion, and the missing-report error. make test gained a go test discovery path for languages/*/tests, guarded so environments without Go skip it cleanly.
Diffstat (limited to 'languages/go/claude/scripts')
| -rw-r--r-- | languages/go/claude/scripts/coverage-summary.go | 248 |
1 files changed, 248 insertions, 0 deletions
diff --git a/languages/go/claude/scripts/coverage-summary.go b/languages/go/claude/scripts/coverage-summary.go new file mode 100644 index 0000000..20bd5e7 --- /dev/null +++ b/languages/go/claude/scripts/coverage-summary.go @@ -0,0 +1,248 @@ +// Command coverage-summary prints a whole-project coverage summary from a Go +// coverage profile (cover.out). +// +// Batch helper for `make coverage-summary`. After `go test -coverprofile` writes +// a profile, this prints a file-weighted project number and the source files +// present on disk but absent from the profile. +// +// The value here is the missing-file detection: a package no test exercises +// never appears in the profile, so a statement-weighted total silently skips it +// and the suite looks healthier than it is. This counts such a file as 0% and +// weights the project number by file rather than by statement, so untested +// files stay visible. `go tool cover -func` already prints the per-function / +// per-file table, so this deliberately doesn't reimplement that. +// +// Self-contained on the standard library, so it runs anywhere the bundle lands +// via `go run coverage-summary.go`. The cover.out line shape it parses is: +// +// <import-path>/<file>.go:startLine.col,endLine.col numStmts count +// +// Profile paths are import-path-qualified; the module path from the project's +// go.mod maps them back to on-disk relative paths. +// +// Usage: +// +// go run coverage-summary.go PROFILE SOURCE_DIR [PROJECT_ROOT] +// PROFILE path to the cover.out coverage profile +// SOURCE_DIR the directory to account for (walked recursively for *.go) +// PROJECT_ROOT module root holding go.mod; defaults to the current directory +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +type counts struct{ covered, total int } + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "coverage-summary: "+format+"\n", args...) + os.Exit(1) +} + +// readModule returns the module path from PROJECT_ROOT/go.mod, or "" if absent. +func readModule(root string) string { + f, err := os.Open(filepath.Join(root, "go.mod")) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if rest, ok := strings.CutPrefix(line, "module "); ok { + return strings.TrimSpace(rest) + } + } + _ = sc.Err() // a read error just means an unknown module path; paths stay unstripped + return "" +} + +// stripModule turns an import-path-qualified file into a module-relative path. +func stripModule(path, module string) string { + if module != "" { + if rest, ok := strings.CutPrefix(path, module+"/"); ok { + return rest + } + } + return path +} + +// parseProfile reads a cover.out profile into module-relative path -> counts. +// COVERED is the statements in blocks hit at least once; TOTAL is all statements. +func parseProfile(profile, module string) map[string]counts { + f, err := os.Open(profile) + if err != nil { + die("coverage profile not found: %s", profile) + } + defer f.Close() + acc := map[string]counts{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "mode:") { + continue + } + // "<path>:<start>.<col>,<end>.<col> <numStmts> <count>" + fields := strings.Fields(line) + if len(fields) != 3 { + continue + } + idx := strings.LastIndex(fields[0], ":") + if idx < 0 { + continue + } + rel := stripModule(fields[0][:idx], module) + ns, err1 := strconv.Atoi(fields[1]) + cnt, err2 := strconv.Atoi(fields[2]) + if err1 != nil || err2 != nil { + continue + } + c := acc[rel] + c.total += ns + if cnt > 0 { + c.covered += ns + } + acc[rel] = c + } + if err := sc.Err(); err != nil { + die("reading %s: %v", profile, err) + } + return acc +} + +// underDir keeps the entries whose path is at or below sourceRel. +func underDir(table map[string]counts, sourceRel string) map[string]counts { + if sourceRel == "." || sourceRel == "" { + return table + } + prefix := sourceRel + "/" + out := map[string]counts{} + for path, c := range table { + if path == sourceRel || strings.HasPrefix(path, prefix) { + out[path] = c + } + } + return out +} + +// sourceFiles returns *.go files under sourceDir (relative to root), skipping +// test files, vendored code, and the local tooling footprint. +func sourceFiles(sourceDir, root string) []string { + var out []string + _ = filepath.WalkDir(sourceDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + switch d.Name() { + case "vendor", ".git", ".claude", "testdata": + return filepath.SkipDir + } + return nil + } + name := d.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + return nil + } + rel, err := filepath.Rel(root, path) + if err == nil { + out = append(out, filepath.ToSlash(rel)) + } + return nil + }) + sort.Strings(out) + return out +} + +func missing(tracked map[string]counts, sourceDir, root string) []string { + var out []string + for _, f := range sourceFiles(sourceDir, root) { + if _, ok := tracked[f]; !ok { + out = append(out, f) + } + } + return out +} + +func filePct(c counts) float64 { + if c.total > 0 { + return 100.0 * float64(c.covered) / float64(c.total) + } + return 100.0 +} + +func projectPct(tracked map[string]counts, miss []string) float64 { + total := len(tracked) + len(miss) + if total == 0 { + return 0.0 + } + score := 0.0 + for _, c := range tracked { + score += filePct(c) + } + return score / float64(total) +} + +func summaryText(tracked map[string]counts, miss []string, sourceRel string) string { + total := len(tracked) + len(miss) + pct := projectPct(tracked, miss) + var b strings.Builder + fmt.Fprintf(&b, "Coverage summary for %s\n\n", sourceRel) + fmt.Fprintf(&b, "Project coverage: %.1f%% (%d tracked, %d missing, %d total; missing files count as 0%%)\n\n", + pct, len(tracked), len(miss), total) + plural := "s" + if len(miss) == 1 { + plural = "" + } + fmt.Fprintf(&b, "Not in coverage report: %d file%s\n", len(miss), plural) + if len(miss) > 0 { + b.WriteString("These files had no coverage entry; they count as 0% in project coverage.\n") + sort.Strings(miss) + for _, m := range miss { + fmt.Fprintf(&b, " %s\n", m) + } + } else { + b.WriteString("Every source file appears in the coverage report.\n") + } + b.WriteString("\n(Per-file table: run `go tool cover -func=<profile>`.)\n") + return b.String() +} + +func main() { + args := os.Args[1:] + if len(args) < 2 || len(args) > 3 { + fmt.Fprintln(os.Stderr, "usage: coverage-summary.go PROFILE SOURCE_DIR [PROJECT_ROOT]") + os.Exit(2) + } + profile, sourceDir := args[0], args[1] + root := "." + if len(args) == 3 { + root = args[2] + } + absRoot, err := filepath.Abs(root) + if err != nil { + die("bad project root %s: %v", root, err) + } + absSource, err := filepath.Abs(sourceDir) + if err != nil { + die("bad source dir %s: %v", sourceDir, err) + } + sourceRel, err := filepath.Rel(absRoot, absSource) + if err != nil { + die("source dir %s is not under project root %s", sourceDir, root) + } + sourceRel = filepath.ToSlash(sourceRel) + + module := readModule(absRoot) + tracked := underDir(parseProfile(profile, module), sourceRel) + miss := missing(tracked, absSource, absRoot) + + fmt.Println() + fmt.Print(summaryText(tracked, miss, sourceRel)) +} |
