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 | |
| 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.
| -rw-r--r-- | Makefile | 9 | ||||
| -rw-r--r-- | languages/go/claude/scripts/coverage-summary.go | 248 | ||||
| -rw-r--r-- | languages/go/coverage-makefile.txt | 43 | ||||
| -rw-r--r-- | languages/go/gitignore-add.txt | 9 | ||||
| -rw-r--r-- | languages/go/tests/coverage_summary_test.go | 142 | ||||
| -rw-r--r-- | languages/go/tests/go.mod | 3 | ||||
| -rw-r--r-- | scripts/tests/install-lang.bats | 12 | ||||
| -rw-r--r-- | todo.org | 13 |
8 files changed, 472 insertions, 7 deletions
@@ -468,6 +468,15 @@ test: ## Run all test suites (pytest + ERT + bats) echo "pytest: $$d"; \ ( cd "$$d" && python3 -m pytest -q ); \ done + @if command -v go >/dev/null 2>&1; then \ + set -e; for d in languages/*/tests; do \ + ls "$$d"/*_test.go >/dev/null 2>&1 || continue; \ + echo "go test: $$d"; \ + ( cd "$$d" && go test ./... ); \ + done; \ + else \ + echo "go test: skipped (go not installed)"; \ + fi @set -e; for f in .ai/scripts/tests/test-*.el; do \ [ -e "$$f" ] || continue; \ echo "ert: $$(basename "$$f")"; \ 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)) +} diff --git a/languages/go/coverage-makefile.txt b/languages/go/coverage-makefile.txt new file mode 100644 index 0000000..7aba9e9 --- /dev/null +++ b/languages/go/coverage-makefile.txt @@ -0,0 +1,43 @@ +# Go coverage — Makefile fragment + setup recommendation +# +# This file is owned by the project, not the rulesets bundle. The bundle never +# edits your Makefile. Copy the two targets below into your own Makefile (and +# adjust the variables at the top), then delete this file or keep it as a note. +# +# What you get: +# make coverage runs the suite with a coverage profile and prints +# `go tool cover -func`'s own per-function table +# make coverage-summary prints a file-weighted project number and every +# source file on disk absent from the profile, at 0%. +# +# Why the summary matters: it weights by file and counts a file absent from the +# profile as 0%, so an untested file stays visible instead of being averaged +# away. Note that modern `go test ./...` already lists every module package in +# the profile (at 0% when untested), so for in-module code the missing-file list +# is usually empty; it earns its keep on files outside the test compilation +# (build-tagged files, a dir not covered by ./...). The summary does not +# reimplement the per-function table — `go tool cover -func` already prints it. +# +# --------------------------------------------------------------------------- +# Prerequisite: none beyond the Go toolchain. `go test -coverprofile` and +# `go tool cover` ship with Go; the summary script is pure standard library. +# --------------------------------------------------------------------------- + +# Variables — adjust to your layout. +GO ?= go +SOURCE_DIR ?= . +COVERAGE_FILE ?= cover.out +# The summary script ships with the bundle under .claude/scripts/ (gitignored). +COVERAGE_SUMMARY ?= .claude/scripts/coverage-summary.go + +coverage: + @$(GO) test ./... -coverprofile=$(COVERAGE_FILE) + @$(GO) tool cover -func=$(COVERAGE_FILE) + @$(MAKE) coverage-summary + +coverage-summary: + @if [ ! -f $(COVERAGE_FILE) ]; then \ + echo "[!] No coverage file at $(COVERAGE_FILE). Run 'make coverage' first."; \ + exit 1; \ + fi + @$(GO) run $(COVERAGE_SUMMARY) $(COVERAGE_FILE) $(SOURCE_DIR) . diff --git a/languages/go/gitignore-add.txt b/languages/go/gitignore-add.txt new file mode 100644 index 0000000..d9efa67 --- /dev/null +++ b/languages/go/gitignore-add.txt @@ -0,0 +1,9 @@ +# Claude Code — local tooling, delivered by install/sync, not committed +.claude/ +CLAUDE.md +githooks/ + +# Go coverage + build artifacts (generated) +cover.out +coverage.out +*.test diff --git a/languages/go/tests/coverage_summary_test.go b/languages/go/tests/coverage_summary_test.go new file mode 100644 index 0000000..6b2fb44 --- /dev/null +++ b/languages/go/tests/coverage_summary_test.go @@ -0,0 +1,142 @@ +// Black-box tests for the Go bundle coverage-summary script. +// +// The script ships into a project's .claude/scripts/ and runs via `go run`, so +// these tests exercise the real CLI rather than importing it: build a throwaway +// module (go.mod + source files + a cover.out profile), run the script against +// it, and assert on stdout. Keeping the test in its own module here means the +// shipped script dir stays test-free. +// +// Normal / Boundary / Error coverage at the behavior level: missing-file +// detection, the file-weighted number, all-tracked, ignoring _test.go, and a +// missing-report error. +package gocovtest + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// scriptPath resolves coverage-summary.go relative to this test file. +func scriptPath(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate test file") + } + p := filepath.Join(filepath.Dir(thisFile), "..", "claude", "scripts", "coverage-summary.go") + abs, err := filepath.Abs(p) + if err != nil { + t.Fatal(err) + } + return abs +} + +// fixture writes a go.mod, the given source files, and a cover.out under a temp +// module root. sources maps relpath -> contents; profile is the raw cover.out. +func fixture(t *testing.T, sources map[string]string, profile string) string { + t.Helper() + root := t.TempDir() + write := func(rel, body string) { + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module example.com/m\n\ngo 1.26\n") + for rel, body := range sources { + write(rel, body) + } + write("cover.out", profile) + return root +} + +// run executes the script against a fixture root, returning combined output + err. +func run(t *testing.T, root, sourceDir string) (string, error) { + t.Helper() + cmd := exec.Command("go", "run", scriptPath(t), + filepath.Join(root, "cover.out"), filepath.Join(root, sourceDir), root) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func TestMissingFileSurfacedAndCountedZero(t *testing.T) { + root := fixture(t, + map[string]string{ + "calc/calc.go": "package calc\n\nfunc Add(a, b int) int { return a + b }\n", + "calc/untested.go": "package calc\n\nfunc Mul(a, b int) int { return a * b }\n", + }, + // calc.go fully covered (2/2 stmts); untested.go absent from the profile. + "mode: set\nexample.com/m/calc/calc.go:3.40,3.56 2 1\n", + ) + out, err := run(t, root, ".") + if err != nil { + t.Fatalf("script failed: %v\n%s", err, out) + } + if !strings.Contains(out, "calc/untested.go") { + t.Errorf("missing file not surfaced:\n%s", out) + } + if !strings.Contains(out, "0%") { + t.Errorf("missing-as-0%% note absent:\n%s", out) + } + // calc.go = 100%, untested.go missing = 0% -> 50.0% + if !strings.Contains(out, "50.0%") { + t.Errorf("expected project number 50.0%%:\n%s", out) + } +} + +func TestAllTrackedNoMissing(t *testing.T) { + root := fixture(t, + map[string]string{"calc/calc.go": "package calc\n\nfunc Add(a, b int) int { return a + b }\n"}, + "mode: set\nexample.com/m/calc/calc.go:3.40,3.56 2 1\n", + ) + out, err := run(t, root, ".") + if err != nil { + t.Fatalf("script failed: %v\n%s", err, out) + } + if !strings.Contains(out, "Not in coverage report: 0 file") { + t.Errorf("expected zero missing files:\n%s", out) + } + if !strings.Contains(out, "100.0%") { + t.Errorf("expected 100.0%% project number:\n%s", out) + } +} + +func TestTestFilesAreNotCountedAsSource(t *testing.T) { + root := fixture(t, + map[string]string{ + "calc/calc.go": "package calc\n\nfunc Add(a, b int) int { return a + b }\n", + "calc/calc_test.go": "package calc\n\nimport \"testing\"\n\nfunc TestX(t *testing.T) {}\n", + }, + "mode: set\nexample.com/m/calc/calc.go:3.40,3.56 2 1\n", + ) + out, err := run(t, root, ".") + if err != nil { + t.Fatalf("script failed: %v\n%s", err, out) + } + if strings.Contains(out, "calc_test.go") { + t.Errorf("_test.go wrongly counted as source:\n%s", out) + } + if !strings.Contains(out, "100.0%") { + t.Errorf("expected 100.0%% (test file excluded):\n%s", out) + } +} + +func TestMissingReportErrors(t *testing.T) { + root := fixture(t, + map[string]string{"calc/calc.go": "package calc\n"}, + "mode: set\n", + ) + cmd := exec.Command("go", "run", scriptPath(t), + filepath.Join(root, "does-not-exist.out"), root, root) + out, err := cmd.CombinedOutput() + if err == nil { + t.Errorf("expected non-zero exit for missing report; output:\n%s", out) + } +} diff --git a/languages/go/tests/go.mod b/languages/go/tests/go.mod new file mode 100644 index 0000000..db72cb1 --- /dev/null +++ b/languages/go/tests/go.mod @@ -0,0 +1,3 @@ +module gocovtest + +go 1.26 diff --git a/scripts/tests/install-lang.bats b/scripts/tests/install-lang.bats index a523186..a9f3bfe 100644 --- a/scripts/tests/install-lang.bats +++ b/scripts/tests/install-lang.bats @@ -68,3 +68,15 @@ teardown() { grep -qxF ".claude/" "$PROJECT/.gitignore" grep -qxF "coverage.json" "$PROJECT/.gitignore" } + +@test "install-lang go: coverage-only slice lands without a CLAUDE.md" { + run bash "$INSTALL_LANG" go "$PROJECT" + + [ "$status" -eq 0 ] + [ -f "$PROJECT/.claude/scripts/coverage-summary.go" ] + [ -f "$PROJECT/coverage-makefile.txt" ] + grep -qxF ".claude/" "$PROJECT/.gitignore" + grep -qxF "cover.out" "$PROJECT/.gitignore" + # The slice ships no rules of its own, so there is no Go CLAUDE.md to seed. + [ ! -f "$PROJECT/CLAUDE.md" ] +} @@ -1156,19 +1156,18 @@ Reference (dotemacs): =scripts/coverage-summary.el=, =modules/coverage-core.el=, Origin: handoff from the .emacs.d session, 2026-05-25. -** TODO [#C] Fan out coverage-summary to Go and TypeScript bundles :feature: +** TODO [#C] Fan out coverage-summary to the TypeScript bundle :feature: :PROPERTIES: :CREATED: [2026-05-31 Sun] :END: -The Elisp pilot proved the pattern; Python followed (both DONE above). Python confirmed the plumbing is genuinely generic — =sync-language-bundle.sh= auto-fixes any =claude/scripts/*= and inbox-drops any =coverage-makefile.txt=; =install-lang.sh= seeds the fragment; =make test= now discovers both =languages/*/tests/test-*.el= (ERT) and =languages/*/tests/test_*.py= (pytest). So Go and TS are each just: the parser script, its tests, and the fragment. +The Elisp pilot proved the pattern; Python and Go followed (all three DONE above). The plumbing is generic: =install-lang.sh= seeds the fragment, and =make test= now discovers ERT (=test-*.el=), pytest (=test_*.py=), and =go test= (=*_test.go=) under =languages/*/tests/=. TypeScript is the last one. -- Go: =go test -coverprofile=cover.out=; parse =cover.out= (simple text), or =go tool cover -func=. Note Go has no =make test= discovery path yet — add a =go test= runner for =languages/go/tests= when this lands. -- TypeScript/JS: nyc/Istanbul =coverage-final.json= / json-summary. Needs a JS test-discovery path in =make test= too. +- TypeScript/JS: nyc/Istanbul =coverage-final.json= / =coverage-summary.json=. Same kernel: file-weighted project number, on-disk =*.ts=/=*.js= absent from the report counted as 0%. nyc prints its own table, so the script focuses on the missing-file list and the number. Needs a vitest/jest (or =node --test=) discovery path in =make test=, mirroring the go-test block. -Keep the kernel identical: file-weighted project number, source files absent from the report counted as 0%. Don't reimplement the per-file table where the built-in reporter already prints one — Go's =go tool cover -func= and nyc both do, so those scripts focus on the missing-file list and the project number. - -Python notes for the next person: the script parses coverage.py's =files[path].summary.{covered_lines,num_statements}= (stable since coverage 5.x), resolves report paths against the report's parent dir (= project root), recurses the source dir for =*.py=, and was proven against a synthetic report matching the documented schema — not yet against a live =coverage json= run (coverage.py wasn't installed in the rulesets env). First real adopter should sanity-check against an actual report. +Notes for the next person, from the Python + Go runs: +- Python: parses coverage.py's =files[path].summary.{covered_lines,num_statements}= (stable since coverage 5.x), resolves report paths against the report's parent dir. Proven against a synthetic report, not a live =coverage json= run (coverage.py wasn't installed). Sanity-check against a real one. +- Go: =languages/go/= is a coverage-only slice with no rule file, so =sync-language-bundle.sh= can't fingerprint it (detection keys on a bundle's own =.claude/rules/*.md=). The script is delivered by =make install-lang LANG=go= but is not sync-maintained until the Go bundle gets a real rule file + =CLAUDE.md=. Building out that bundle is the natural companion task. Also: modern =go test ./...= already lists every module package in the profile at 0%, so the missing-file list is usually empty for in-module code; it earns its keep on build-tagged files and dirs outside =./...=. ** TODO [#B] Cross-project pattern catalog :spec:thinking: :PROPERTIES: |
