diff --git a/.agents/skills/code-style/SKILL.md b/.agents/skills/code-style/SKILL.md index 01d277d5..85f38e2c 100644 --- a/.agents/skills/code-style/SKILL.md +++ b/.agents/skills/code-style/SKILL.md @@ -147,8 +147,9 @@ Generated code follows this rule too, read in the output, not in the template: - **Complete assertions**: one `assert.Equal` on the whole value over field-by-field checks. - **Test file mirrors source**: `foo.go` is tested in `foo_test.go`. No orphan test files. - **Fixtures** for specs live in `testdata/` next to the test; keep each fixture minimal. -- **Coverage**: every package passes the 100% gate (`make cover-check`). Exclusions go in - `.covignore` with a reason in the commit message, never by skipping tests. +- **Coverage**: every file and package passes the 100% gate (`make cover-check`). Exclusions go in + `.testcoverage.yml` with a reason in the commit message, never in a `// coverage-ignore` comment + and never by skipping tests. ### 3. Variable naming @@ -214,8 +215,8 @@ helper adds behavior: validation, defaults, an error return, type translation. ### 10. No logging or printing in library code Library packages never log and never print (`fmt.Print*`, `println`, `log.*`, `slog.*`). They return -errors and diagnostics; the caller decides what to show. Only `pkg/cli`, to the writers it is -given, and `scripts/` print. forbidigo enforces the print half. +errors and diagnostics; the caller decides what to show. Only `pkg/cli` prints, to the writers it +is given. forbidigo enforces the print half. ### 11. Package naming and location diff --git a/.covignore b/.covignore deleted file mode 100644 index 719c4e73..00000000 --- a/.covignore +++ /dev/null @@ -1,3 +0,0 @@ -# Module-relative paths left out of the coverage gate. "dir/..." skips a whole tree. -cmd/mockzilla-codegen/main.go -scripts/covercheck/main.go diff --git a/.golangci.yaml b/.golangci.yaml index da78159e..c5197ad8 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -91,7 +91,7 @@ linters: - examples - testdata rules: - - path: ^(cmd|scripts)/ + - path: ^cmd/ linters: - forbidigo - path: _test\.go diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 00000000..e4481283 --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,8 @@ +profile: coverage.out +# The tool rounds to 0.1%, so a package of 2,000 statements passes with one missed; files are smaller. +threshold: + file: 100 + package: 100 +exclude: + paths: + - ^cmd/mockzilla-codegen/main\.go$ diff --git a/AGENTS.md b/AGENTS.md index daabe3af..8ab49470 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Module `github.com/mockzilla/mockzilla-codegen`. Built on github.com/pb33f/libop - `make test-integration-ci` runs what the Integration workflow runs, started by hand for now: `.github/ci-specs.txt` on every variant with chi, the specs `.github/ci-router-specs.txt` lists on every framework too. `BATCH=i/n` runs one of n batches, as the workflow's four jobs do. -- Coverage gate exclusions live in `.covignore`. +- The coverage gate is go-test-coverage; its threshold and exclusions live in `.testcoverage.yml`. ## Rules @@ -56,4 +56,3 @@ Public packages live under `pkg/`, private ones under `internal/`. No Go files i | `internal/...` | provider, spec IR, transforms, naming, Go model, rendering, layout | | `examples/` | separate module: golden examples and tests of generated code | | `test/` | parse sweep, integration test, benchmarks (build tags) | -| `scripts/covercheck` | per-package coverage gate | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33ad2fc8..82ce0ddf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ specs and takes a while; run one spec, or `make test-integration-ci` for the set - Output is deterministic: the same spec and config give the same bytes on every run. - Templates hold no logic beyond `range` and `if` on precomputed fields. - Golden files change only through `UPDATE=1` runs, never by hand. -- Every line of new code is covered by a test. Exceptions go in `.covignore`. +- Every line of new code is covered by a test. Exceptions go in `.testcoverage.yml`. ## Adding a router diff --git a/Makefile b/Makefile index af5ef25d..e801d16a 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,8 @@ PKG ?= ./... RUN ?= CI_SPECS = $(shell grep -v '^\#' .github/ci-specs.txt) CI_ROUTER_SPECS = $(shell grep -v '^\#' .github/ci-router-specs.txt) -MIN_COVERAGE ?= 100 +# go-test-coverage must not need a newer Go than the go directive in go.mod; bump the two together. +COVERAGE_VERSION ?= v2.19.0 # golangci-lint must be built with a Go at least as new as the go directive in go.mod; bump the two # together. GOLANGCI_VERSION ?= v2.14.0 @@ -34,8 +35,8 @@ cover: ## Write coverage.out and coverage.html go tool cover -html=coverage.out -o coverage.html .PHONY: cover-check -cover-check: cover ## Fail when a package is below MIN_COVERAGE (default 100) - go run ./scripts/covercheck -profile coverage.out -min $(MIN_COVERAGE) -ignore .covignore +cover-check: cover ## Fail when a file or package is below 100% coverage; exclusions in .testcoverage.yml + go run github.com/vladopajic/go-test-coverage/v2@$(COVERAGE_VERSION) --config=.testcoverage.yml .PHONY: lint-tools lint-tools: ## Install golangci-lint GOLANGCI_VERSION into bin/ unless that version is there diff --git a/scripts/covercheck/errors.go b/scripts/covercheck/errors.go deleted file mode 100644 index 65baabb5..00000000 --- a/scripts/covercheck/errors.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -package main - -import "errors" - -var ( - errNoMode = errors.New("profile has no mode line") - errBadLine = errors.New("malformed profile line") - errNoModule = errors.New("go.mod has no module line") - errBadPattern = errors.New("bad ignore pattern") - errBelowMinimum = errors.New("coverage below minimum") -) diff --git a/scripts/covercheck/ignore.go b/scripts/covercheck/ignore.go deleted file mode 100644 index 7adb3c18..00000000 --- a/scripts/covercheck/ignore.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -// The patterns of .covignore: files, or folders with /..., left out of the gate. - -package main - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path" - "strings" -) - -func loadIgnore(file string) ([]string, error) { - if file == "" { - return nil, nil - } - - data, err := os.ReadFile(file) - if errors.Is(err, fs.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("read ignore file: %w", err) - } - - var patterns []string - for line := range strings.Lines(string(data)) { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - if _, err = path.Match(line, ""); err != nil { - return nil, fmt.Errorf("%w %q: %w", errBadPattern, line, err) - } - patterns = append(patterns, line) - } - - return patterns, nil -} - -func ignored(rel string, patterns []string) bool { - for _, p := range patterns { - if dir, ok := strings.CutSuffix(p, "/..."); ok { - if rel == dir || strings.HasPrefix(rel, dir+"/") { - return true - } - continue - } - if ok, _ := path.Match(p, rel); ok { - return true - } - } - return false -} diff --git a/scripts/covercheck/ignore_test.go b/scripts/covercheck/ignore_test.go deleted file mode 100644 index 7eb4d254..00000000 --- a/scripts/covercheck/ignore_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -package main - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLoadIgnore(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - content *string - isDir bool - noPath bool - want []string - wantErr error - }{ - { - name: "No path means no patterns", - noPath: true, - }, - { - name: "Missing file means no patterns", - }, - { - name: "Comments and blank lines are skipped", - content: new("# generated\n\ncmd/codegen/main.go\n internal/x/... \n"), - want: []string{"cmd/codegen/main.go", "internal/x/..."}, - }, - { - name: "Malformed pattern is rejected", - content: new("[\n"), - wantErr: errBadPattern, - }, - { - name: "Directory in place of a file fails to read", - isDir: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - file := filepath.Join(t.TempDir(), ".covignore") - if tc.content != nil { - require.NoError(t, os.WriteFile(file, []byte(*tc.content), 0o600)) - } - if tc.isDir { - require.NoError(t, os.Mkdir(file, 0o700)) - } - if tc.noPath { - file = "" - } - - got, err := loadIgnore(file) - - switch { - case tc.wantErr != nil: - require.ErrorIs(t, err, tc.wantErr) - case tc.isDir: - require.ErrorContains(t, err, "read ignore file") - default: - require.NoError(t, err) - assert.Equal(t, tc.want, got) - } - }) - } -} - -func TestIgnored(t *testing.T) { - t.Parallel() - - patterns := []string{"cmd/*/main.go", "internal/gen/...", "doc.go"} - - tests := []struct { - name string - rel string - want bool - }{ - {name: "Glob matches a file", rel: "cmd/codegen/main.go", want: true}, - {name: "Glob does not match another file", rel: "cmd/codegen/run.go", want: false}, - {name: "Tree pattern matches a file below it", rel: "internal/gen/models/x.go", want: true}, - {name: "Tree pattern matches the directory itself", rel: "internal/gen", want: true}, - {name: "Tree pattern does not match a sibling with the same prefix", rel: "internal/generator/x.go", want: false}, - {name: "Exact name matches", rel: "doc.go", want: true}, - {name: "Unlisted file is kept", rel: "version.go", want: false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, tc.want, ignored(tc.rel, patterns)) - }) - } -} diff --git a/scripts/covercheck/main.go b/scripts/covercheck/main.go deleted file mode 100644 index 1ecaa5fb..00000000 --- a/scripts/covercheck/main.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -// Command covercheck fails when any package in a coverage profile is below a minimum. -package main - -import "os" - -func main() { - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) -} diff --git a/scripts/covercheck/module.go b/scripts/covercheck/module.go deleted file mode 100644 index b1b2151c..00000000 --- a/scripts/covercheck/module.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -// The module path of go.mod, which turns the import paths of the profile into file paths. - -package main - -import ( - "fmt" - "os" - "strings" -) - -func modulePath(gomod string) (string, error) { - data, err := os.ReadFile(gomod) - if err != nil { - return "", fmt.Errorf("read go.mod: %w", err) - } - - for line := range strings.Lines(string(data)) { - if mod, ok := strings.CutPrefix(strings.TrimSpace(line), "module "); ok { - return strings.Trim(strings.TrimSpace(mod), `"`), nil - } - } - - return "", errNoModule -} diff --git a/scripts/covercheck/module_test.go b/scripts/covercheck/module_test.go deleted file mode 100644 index dd93daa6..00000000 --- a/scripts/covercheck/module_test.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -package main - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestModulePath(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - content *string - want string - wantErr error - }{ - { - name: "Module line gives the module path", - content: new("// comment\nmodule example.com/m\n\ngo 1.26.0\n"), - want: "example.com/m", - }, - { - name: "Quoted module path is unquoted", - content: new(`module "example.com/quoted"` + "\n"), - want: "example.com/quoted", - }, - { - name: "File without a module line is rejected", - content: new("go 1.26.0\n"), - wantErr: errNoModule, - }, - { - name: "Missing file fails to read", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - file := filepath.Join(t.TempDir(), "go.mod") - if tc.content != nil { - require.NoError(t, os.WriteFile(file, []byte(*tc.content), 0o600)) - } - - got, err := modulePath(file) - - switch { - case tc.wantErr != nil: - require.ErrorIs(t, err, tc.wantErr) - case tc.content == nil: - require.ErrorContains(t, err, "read go.mod") - default: - require.NoError(t, err) - assert.Equal(t, tc.want, got) - } - }) - } -} diff --git a/scripts/covercheck/profile.go b/scripts/covercheck/profile.go deleted file mode 100644 index 5f5a20e9..00000000 --- a/scripts/covercheck/profile.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -// The coverage profile read into blocks. - -package main - -import ( - "bufio" - "cmp" - "fmt" - "io" - "slices" - "strings" -) - -const maxLineBytes = 1 << 20 - -type block struct { - file string - startLine int - startCol int - endLine int - endCol int - stmts int - count int -} - -func parseProfile(r io.Reader) ([]block, error) { - sc := bufio.NewScanner(r) - sc.Buffer(make([]byte, 0, 64*1024), maxLineBytes) - - if !sc.Scan() || !strings.HasPrefix(sc.Text(), "mode: ") { - if err := sc.Err(); err != nil { - return nil, fmt.Errorf("read profile: %w", err) - } - return nil, errNoMode - } - - merged := map[block]int{} - for sc.Scan() { - line := sc.Text() - if line == "" { - continue - } - - b, err := parseLine(line) - if err != nil { - return nil, err - } - - key := b - key.count = 0 - merged[key] = max(merged[key], b.count) - } - if err := sc.Err(); err != nil { - return nil, fmt.Errorf("read profile: %w", err) - } - - blocks := make([]block, 0, len(merged)) - for key, count := range merged { - key.count = count - blocks = append(blocks, key) - } - slices.SortFunc(blocks, compareBlocks) - - return blocks, nil -} - -func parseLine(line string) (block, error) { - colon := strings.LastIndexByte(line, ':') - if colon < 0 { - return block{}, fmt.Errorf("%w: %q", errBadLine, line) - } - - b := block{file: line[:colon]} - _, err := fmt.Sscanf(line[colon+1:], "%d.%d,%d.%d %d %d", - &b.startLine, &b.startCol, &b.endLine, &b.endCol, &b.stmts, &b.count) - if err != nil { - return block{}, fmt.Errorf("%w: %q", errBadLine, line) - } - - return b, nil -} - -func compareBlocks(a, b block) int { - return cmp.Or( - cmp.Compare(a.file, b.file), - cmp.Compare(a.startLine, b.startLine), - cmp.Compare(a.startCol, b.startCol), - cmp.Compare(a.endLine, b.endLine), - cmp.Compare(a.endCol, b.endCol), - ) -} diff --git a/scripts/covercheck/profile_test.go b/scripts/covercheck/profile_test.go deleted file mode 100644 index 42e39400..00000000 --- a/scripts/covercheck/profile_test.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -package main - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseProfile(t *testing.T) { - t.Parallel() - - long := strings.Repeat("x", maxLineBytes+1) - - tests := []struct { - name string - input string - want []block - wantErr error - wantErrText string - }{ - { - name: "Blocks are parsed and sorted by file and position", - input: "mode: atomic\n" + - "example.com/m/b.go:3.1,4.2 1 0\n" + - "example.com/m/a.go:9.1,9.20 2 5\n" + - "example.com/m/a.go:1.1,2.2 1 1\n", - want: []block{ - {file: "example.com/m/a.go", startLine: 1, startCol: 1, endLine: 2, endCol: 2, stmts: 1, count: 1}, - {file: "example.com/m/a.go", startLine: 9, startCol: 1, endLine: 9, endCol: 20, stmts: 2, count: 5}, - {file: "example.com/m/b.go", startLine: 3, startCol: 1, endLine: 4, endCol: 2, stmts: 1, count: 0}, - }, - }, - { - name: "Duplicate blocks keep the highest count", - input: "mode: set\n" + - "example.com/m/a.go:1.1,2.2 1 0\n" + - "\n" + - "example.com/m/a.go:1.1,2.2 1 3\n", - want: []block{ - {file: "example.com/m/a.go", startLine: 1, startCol: 1, endLine: 2, endCol: 2, stmts: 1, count: 3}, - }, - }, - { - name: "Blocks with the same start are ordered by end", - input: "mode: set\n" + - "example.com/m/a.go:1.1,5.2 1 0\n" + - "example.com/m/a.go:1.1,3.2 1 0\n" + - "example.com/m/a.go:1.1,3.1 1 0\n", - want: []block{ - {file: "example.com/m/a.go", startLine: 1, startCol: 1, endLine: 3, endCol: 1, stmts: 1}, - {file: "example.com/m/a.go", startLine: 1, startCol: 1, endLine: 3, endCol: 2, stmts: 1}, - {file: "example.com/m/a.go", startLine: 1, startCol: 1, endLine: 5, endCol: 2, stmts: 1}, - }, - }, - { - name: "Empty input has no mode line", - input: "", - wantErr: errNoMode, - }, - { - name: "First line that is not a mode line is rejected", - input: "example.com/m/a.go:1.1,2.2 1 0\n", - wantErr: errNoMode, - }, - { - name: "Line without a colon is rejected", - input: "mode: set\nnot a block\n", - wantErr: errBadLine, - }, - { - name: "Line with bad numbers is rejected", - input: "mode: set\nexample.com/m/a.go:1.x,2.2 1 0\n", - wantErr: errBadLine, - }, - { - name: "Mode line longer than the buffer fails to read", - input: long, - wantErrText: "read profile", - }, - { - name: "Block line longer than the buffer fails to read", - input: "mode: set\n" + long, - wantErrText: "read profile", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got, err := parseProfile(strings.NewReader(tc.input)) - - switch { - case tc.wantErr != nil: - require.ErrorIs(t, err, tc.wantErr) - case tc.wantErrText != "": - require.ErrorContains(t, err, tc.wantErrText) - default: - require.NoError(t, err) - assert.Equal(t, tc.want, got) - } - }) - } -} diff --git a/scripts/covercheck/report.go b/scripts/covercheck/report.go deleted file mode 100644 index 4cacaa17..00000000 --- a/scripts/covercheck/report.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -package main - -import ( - "fmt" - "io" - "math" - "path" - "slices" - "strconv" - "strings" -) - -type pkgCoverage struct { - pkg string - total int - covered int - uncovered []block -} - -func (p pkgCoverage) percent() float64 { - return float64(p.covered) * 100 / float64(p.total) -} - -func (p pkgCoverage) meets(minPct float64) bool { - return float64(p.covered)*100 >= minPct*float64(p.total) -} - -func summarize(blocks []block, module string, patterns []string) []pkgCoverage { - byPkg := map[string]*pkgCoverage{} - for _, b := range blocks { - if ignored(relative(b.file, module), patterns) { - continue - } - - pkg := path.Dir(b.file) - pc, ok := byPkg[pkg] - if !ok { - pc = &pkgCoverage{pkg: pkg} - byPkg[pkg] = pc - } - - pc.total += b.stmts - if b.count > 0 { - pc.covered += b.stmts - } else { - pc.uncovered = append(pc.uncovered, b) - } - } - - pkgs := make([]pkgCoverage, 0, len(byPkg)) - for _, pc := range byPkg { - if pc.total > 0 { - pkgs = append(pkgs, *pc) - } - } - slices.SortFunc(pkgs, func(a, b pkgCoverage) int { return strings.Compare(a.pkg, b.pkg) }) - - return pkgs -} - -func report(w io.Writer, pkgs []pkgCoverage, minPct float64, module string) bool { - failed := 0 - for _, p := range pkgs { - if p.meets(minPct) { - continue - } - - failed++ - _, _ = fmt.Fprintf(w, "FAIL %s %s%% (min %s%%)\n", p.pkg, formatPercent(p.percent()), formatPercent(minPct)) - for _, b := range p.uncovered { - _, _ = fmt.Fprintf(w, " %s:%d-%d\n", relative(b.file, module), b.startLine, b.endLine) - } - } - - if failed > 0 { - return false - } - - _, _ = fmt.Fprintf(w, "coverage ok: %d packages at or above %s%%\n", len(pkgs), formatPercent(minPct)) - return true -} - -func relative(file, module string) string { - return strings.TrimPrefix(strings.TrimPrefix(file, module), "/") -} - -// formatPercent floors to one decimal so 99.96% never prints as 100.0%. -func formatPercent(p float64) string { - return strconv.FormatFloat(math.Floor(p*10)/10, 'f', 1, 64) -} diff --git a/scripts/covercheck/report_test.go b/scripts/covercheck/report_test.go deleted file mode 100644 index 789e1766..00000000 --- a/scripts/covercheck/report_test.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -package main - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -const testModule = "example.com/m" - -func TestSummarize(t *testing.T) { - t.Parallel() - - blocks := []block{ - {file: "example.com/m/a/x.go", startLine: 1, endLine: 2, stmts: 2, count: 1}, - {file: "example.com/m/a/x.go", startLine: 4, endLine: 5, stmts: 1, count: 0}, - {file: "example.com/m/b/y.go", startLine: 1, endLine: 1, stmts: 3, count: 7}, - {file: "example.com/m/cmd/main.go", startLine: 1, endLine: 1, stmts: 1, count: 0}, - {file: "example.com/m/empty/z.go", startLine: 1, endLine: 1, stmts: 0, count: 0}, - } - - got := summarize(blocks, testModule, []string{"cmd/main.go"}) - - assert.Equal(t, []pkgCoverage{ - { - pkg: "example.com/m/a", - total: 3, - covered: 2, - uncovered: []block{blocks[1]}, - }, - {pkg: "example.com/m/b", total: 3, covered: 3}, - }, got) -} - -func TestReport(t *testing.T) { - t.Parallel() - - full := pkgCoverage{pkg: "example.com/m/a", total: 4, covered: 4} - partial := pkgCoverage{ - pkg: "example.com/m/b", - total: 3, - covered: 2, - uncovered: []block{ - {file: "example.com/m/b/y.go", startLine: 10, endLine: 12}, - }, - } - - tests := []struct { - name string - pkgs []pkgCoverage - min float64 - want string - wantOK bool - }{ - { - name: "All packages at the minimum pass", - pkgs: []pkgCoverage{full}, - min: 100, - want: "coverage ok: 1 packages at or above 100.0%\n", - wantOK: true, - }, - { - name: "Package below the minimum fails with its uncovered ranges", - pkgs: []pkgCoverage{full, partial}, - min: 100, - want: "FAIL example.com/m/b 66.6% (min 100.0%)\n b/y.go:10-12\n", - wantOK: false, - }, - { - name: "Lower minimum lets a partial package pass", - pkgs: []pkgCoverage{partial}, - min: 60, - want: "coverage ok: 1 packages at or above 60.0%\n", - wantOK: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - var out strings.Builder - ok := report(&out, tc.pkgs, tc.min, testModule) - - assert.Equal(t, tc.wantOK, ok) - assert.Equal(t, tc.want, out.String()) - }) - } -} - -func TestFormatPercent(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in float64 - want string - }{ - {name: "Whole number keeps one decimal", in: 100, want: "100.0"}, - {name: "Value just below a hundred is floored", in: 99.96, want: "99.9"}, - {name: "Zero prints as zero", in: 0, want: "0.0"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, tc.want, formatPercent(tc.in)) - }) - } -} diff --git a/scripts/covercheck/run.go b/scripts/covercheck/run.go deleted file mode 100644 index 0aa41b6d..00000000 --- a/scripts/covercheck/run.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -package main - -import ( - "bytes" - "errors" - "flag" - "fmt" - "io" - "os" -) - -const ( - exitOK = iota - exitBelow - exitError -) - -type options struct { - profile string - minPct float64 - ignore string - gomod string -} - -func run(args []string, stdout, stderr io.Writer) int { - flags := flag.NewFlagSet("covercheck", flag.ContinueOnError) - flags.SetOutput(stderr) - - var opts options - flags.StringVar(&opts.profile, "profile", "coverage.out", "coverage profile to check") - flags.Float64Var(&opts.minPct, "min", 100, "minimum coverage per package, in percent") - flags.StringVar(&opts.ignore, "ignore", ".covignore", "file listing module-relative paths to skip") - flags.StringVar(&opts.gomod, "gomod", "go.mod", "go.mod of the module under test") - - if err := flags.Parse(args); err != nil { - return exitError - } - - err := check(opts, stdout) - if errors.Is(err, errBelowMinimum) { - return exitBelow - } - if err != nil { - _, _ = fmt.Fprintf(stderr, "covercheck: %v\n", err) - return exitError - } - - return exitOK -} - -func check(opts options, w io.Writer) error { - module, err := modulePath(opts.gomod) - if err != nil { - return err - } - - patterns, err := loadIgnore(opts.ignore) - if err != nil { - return err - } - - data, err := os.ReadFile(opts.profile) - if err != nil { - return fmt.Errorf("read profile: %w", err) - } - - blocks, err := parseProfile(bytes.NewReader(data)) - if err != nil { - return err - } - - if !report(w, summarize(blocks, module, patterns), opts.minPct, module) { - return errBelowMinimum - } - - return nil -} diff --git a/scripts/covercheck/run_test.go b/scripts/covercheck/run_test.go deleted file mode 100644 index b677474e..00000000 --- a/scripts/covercheck/run_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) 2026 Mockzilla -// SPDX-License-Identifier: MIT -// Licensed under the MIT License, see LICENSE in the repository root. This copyright notice and -// permission notice shall be included in all copies or substantial portions of the Software. - -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const ( - testGoMod = "module example.com/m\n" - coveredProfile = "mode: atomic\nexample.com/m/a/x.go:1.1,2.2 1 1\n" - partialProfile = "mode: atomic\nexample.com/m/a/x.go:1.1,2.2 1 1\nexample.com/m/a/x.go:4.1,5.2 1 0\n" -) - -func TestRun(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - gomod *string - profile *string - ignore *string - extraArgs []string - wantCode int - wantStdout string - wantStderr string - }{ - { - name: "Fully covered profile passes", - gomod: new(testGoMod), - profile: new(coveredProfile), - wantCode: exitOK, - wantStdout: "coverage ok: 1 packages at or above 100.0%\n", - }, - { - name: "Partly covered profile fails the gate", - gomod: new(testGoMod), - profile: new(partialProfile), - wantCode: exitBelow, - wantStdout: "FAIL example.com/m/a 50.0% (min 100.0%)\n a/x.go:4-5\n", - }, - { - name: "Minimum flag lowers the gate", - gomod: new(testGoMod), - profile: new(partialProfile), - extraArgs: []string{"-min", "50"}, - wantCode: exitOK, - wantStdout: "coverage ok: 1 packages at or above 50.0%\n", - }, - { - name: "Ignored file does not count", - gomod: new(testGoMod), - profile: new(partialProfile + "example.com/m/b/main.go:1.1,2.2 1 0\n"), - ignore: new("b/main.go\n"), - extraArgs: []string{"-min", "50"}, - wantCode: exitOK, - wantStdout: "coverage ok: 1 packages at or above 50.0%\n", - }, - { - name: "Unknown flag is a usage error", - extraArgs: []string{"-nope"}, - wantCode: exitError, - wantStderr: "flag provided but not defined: -nope", - }, - { - name: "Missing go.mod is an error", - profile: new(coveredProfile), - wantCode: exitError, - wantStderr: "covercheck: read go.mod", - }, - { - name: "Bad ignore file is an error", - gomod: new(testGoMod), - profile: new(coveredProfile), - ignore: new("[\n"), - wantCode: exitError, - wantStderr: "covercheck: bad ignore pattern", - }, - { - name: "Missing profile is an error", - gomod: new(testGoMod), - wantCode: exitError, - wantStderr: "covercheck: read profile", - }, - { - name: "Malformed profile is an error", - gomod: new(testGoMod), - profile: new("garbage\n"), - wantCode: exitError, - wantStderr: "covercheck: profile has no mode line", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - args := []string{ - "-gomod", writeFile(t, dir, "go.mod", tc.gomod), - "-profile", writeFile(t, dir, "coverage.out", tc.profile), - "-ignore", writeFile(t, dir, ".covignore", tc.ignore), - } - args = append(args, tc.extraArgs...) - - var stdout, stderr strings.Builder - code := run(args, &stdout, &stderr) - - assert.Equal(t, tc.wantCode, code) - assert.Equal(t, tc.wantStdout, stdout.String()) - assert.Contains(t, stderr.String(), tc.wantStderr) - }) - } -} - -func writeFile(t *testing.T, dir, name string, content *string) string { - t.Helper() - - file := filepath.Join(dir, name) - if content != nil { - require.NoError(t, os.WriteFile(file, []byte(*content), 0o600)) - } - return file -}