aboutsummaryrefslogtreecommitdiff
path: root/languages/go/claude/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-31 13:07:40 -0500
committerCraig Jennings <c@cjennings.net>2026-05-31 13:07:40 -0500
commit47ca509e69b6a1472a735a4b9521a952e7434491 (patch)
tree9101eb376d233c455f653ad857474b3a0dae767f /languages/go/claude/scripts
parentaf478a42b18c4d5e0712c4cb43036126d36c56b5 (diff)
downloadrulesets-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.go248
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))
+}