tarpaulin

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: AGPL-3.0

README

tarp

tarp finds Go functions that have no direct unit test.

The problem

go test -cover measures statement coverage, which cannot distinguish a function that was tested from one that was merely executed. Take this package:

package simple

func A() string { return "A" }
func B() string { return "B" }
func C() string { return "C" }

func wrapper() { A(); B(); C() }

and these tests:

func TestA(t *testing.T)       { A() }
func TestC(t *testing.T)       { C() }
func TestWrapper(t *testing.T) { wrapper() }

go test -cover reports 100%. But B has no test of its own — it is only executed on the way to somebody else's assertion. Delete the B() call from wrapper and coverage silently drops, because the coverage was never real.

tarp reports what actually happened:

$ tarp analyze --package ./simple
Functions without direct unit tests:
in main.go:
	B on line 7

Grade: 75% (3/4 functions)
The opinion this encodes

Testing primarily asserts behavior. If a behavior is worth codifying into a function, it is worth asserting that function's behavior — in a test written for it, in a consistent style, in a predictable file location.

The tool's job is to measure against that ideal, not to be satisfiable. Expect a mediocre grade on a real codebase; that is the point.

What a reference does not prove

The ideal above is about assertions. The measurement is not: what tarp checks is that a TestXxx body references the function. It never looks at what the test does with it.

So a test that asserts nothing scores as tested — including every one in the example above, none of which has an assertion in it. So does a test that calls the function with inputs reaching only its first guard clause:

func TestParse(t *testing.T) { _, _ = Parse(nil) }   // returns on the nil check; graded 1/1

tarp and go test -cover are wrong in opposite directions. Coverage credits a function nobody tested, because somebody else's test executed it. tarp credits a test that checks nothing, because it named the function. Neither subsumes the other, and neither is a substitute for reading the test:

go test -cover says tarp says
B in the example above, executed only by wrapper's test covered untested
func TestB(t *testing.T) { B() }, no assertion covered tested
A test asserting one branch of ten partly covered tested

A function green in both has a test written for it and the statements to show for it. That is the pair worth running in CI; either alone is a metric with a known way to be satisfied cheaply.

Quickstart

Requires Go 1.26+. Docker is used for linting and shellcheck.

make setup                  # create artifacts/ and download the module cache
make build                  # compile everything, produce artifacts/tarp
./artifacts/tarp analyze --package .

Usage

tarp analyze [packages] [--package=.] [--strictness=file|package|any]
                        [--fail-on-found] [--min-score=N]
                        [--format=text|json|sarif|markdown]
tarp cover --html=<profile> [packages] [--package=.] [--output=<file>]
                            [--strictness=file|package|any]
Flag Default Meaning
--package, -p . A directory (expanded to ./... beneath it) or a go/packages pattern — see below
--strictness file How close a reference has to be to count — see below
--fail-on-found false Exit non-zero when anything is reported, without printing an error line
--min-score, -m 0 Exit non-zero when the grade falls below this percentage (0 to 100; 0 never fails)
--format, -f text text, json, sarif, or markdown — see below. Warnings always go to stderr
--json, -j false Shorthand for --format=json
--html cover only: the profile from go test -coverprofile to render
--output, -o stdout cover only: write the report to this file instead
Choosing what to analyze

Both subcommands take packages the same way, and there are exactly two rules: arguments win over --package, and a --package value that names an existing directory is analyzed as a directory; anything else is a pattern.

Invocation Loaded from Patterns
tarp analyze . ./...
tarp analyze ./cmd/... ./io . ./cmd/... ./io
tarp analyze -p ./internal ./internal ./...
tarp analyze -p ./cmd/... . ./cmd/...
tarp analyze -p example.com/mod/... . example.com/mod/...

A directory becomes the directory the go command runs in, and everything beneath it is analyzed — -p ./internal and -p ./internal/... therefore mean the same thing. Anything that is not an existing directory is handed to go/packages as written and resolved against the working directory, which is what makes module paths like example.com/mod/... work.

The target has to sit inside a Go module. Packages load in module mode, so a directory with no go.mod in it or any parent cannot be listed, and tarp says so rather than passing the go command's "directory prefix . does not contain main module" along:

$ tarp analyze --package /tmp/scratch
Error: analyzing /tmp/scratch: no go.mod in that directory or any parent, and
packages load in module mode: run `go mod init` there first
Strictness

The dial only ever weakens. The default is the strongest claim the tool can make.

Level Rule
file Bar in foo.go must be referenced inside a TestXxx body in foo_test.go or foo_internal_test.go
package Referenced inside a TestXxx body in any _test.go in the package
any Referenced anywhere in any _test.go, test helpers included

foo_test.go and foo_internal_test.go are both accepted at file, and that pairing is load-bearing rather than a convenience: Go forbids package foo_test from referencing unexported identifiers in package foo, so when the external test file is the one that exists, a second internal test file is the only place an unexported function can be tested from.

A reference counts whether or not it is syntactically a call. Method values, method expressions, functions passed as arguments, deferred closures, range-over-func iterators, and generic instantiations are all references, because the question asked is "what does this identifier resolve to?" rather than "is this a call?".

That question is answered by resolution alone, which is what makes the dial cheap and predictable — and what a reference does not prove: none of these levels inspects what the test does after naming the function.

What is never reported

init, main in package main, anything in a file marked // Code generated ... DO NOT EDIT., everything declared in a _test.go, and anything carrying the ignore directive:

//tarp:ignore -- talks to a live payment processor; covered by the e2e suite
func Charge() error { ... }

The reason is required. A bare //tarp:ignore exempts nothing and earns a warning on stderr, because an escape hatch that costs nothing to use is just a way to make the score go up.

Methods nothing can name

A method a framework reaches by reflection will be reported no matter how well it is tested, because there is no reference to find. MarshalJSON is the canonical case: the test calls json.Marshal(value), and the string MarshalJSON appears nowhere in it. String, driver.Valuer, sort.Interface, and any interface satisfied for somebody else's benefit read the same way.

This is the tool being correct, not blind — it reports what is true of the source. The convention is a directive whose reason names the test that does the asserting, which is what tarp does to itself in internal/analysis/report.go:

//tarp:ignore -- reached by reflection through json.Marshal, so no test can name it; asserted by TestReportMarshalJSON
func (r Report) MarshalJSON() ([]byte, error) { ... }

That keeps the exemption auditable: the claim is checkable by opening the named test, and it goes stale loudly if the test is ever deleted.

In CI

There are two gates, and both are off by default:

tarp analyze --package ./... --fail-on-found   # nothing may be reported
tarp analyze --package ./... --min-score 50    # the grade may not fall below 50%

--fail-on-found exits 1 having already printed the report, with no Error: line stapled underneath it.

--min-score is the gate to reach for on an existing codebase, where demanding a clean report on day one means turning the check off again by Friday. It exits 1 the same way, and adds one line on stderr saying what the grade was measured against — the report says Grade: 44%, but only the flag knows that 50 was required:

score 44% is below the required minimum of 50%

A minimum outside 0–100 is rejected before any package is loaded, so a typo costs a CI runner nothing and does not become a build that fails forever for no visible reason. --fail-on-found is the strictly stronger gate — a score below any minimum implies something was reported — so when both are set and both trip, it wins and the score line is left unsaid.

--json gives a stable shape to parse, on stdout, unaffected by either gate:

{
  "strictness": "file",
  "untested": [{"package": "simple", "file": "main.go", "name": "B", "line": 7}],
  "warnings": [],
  "declared": 4,
  "tested": 3,
  "score": 75
}

Output is deterministic — sorted by file, then declaration line — and color is dropped when stdout is not a terminal, when NO_COLOR is set, or when TERM=dumb.

Grading a whole module

The default output lists functions, which stops being readable somewhere around the second page. --format markdown grades one row per package instead:

tarp analyze --package ../platform-go --format markdown
| Package | Score | Tested | Declared |
| --- | ---: | ---: | ---: |
| `github.com/primandproper/platform-go/v10/analytics/config` | 84% | 11 | 13 |
| `github.com/primandproper/platform-go/v10/analytics/noop` | 100% | 5 | 5 |
| `github.com/primandproper/platform-go/v10/audit` | 55% | 43 | 77 |
| **Total** | **61%** | **2684** | **4371** |

Graded at `file` strictness.

Rows are sorted by import path and always add up to the total. The import path is the identity, not the package clause — platform-go has 48 packages named config and 27 named noop, so grouping on the name would collapse 111 of its 281 rows into each other and hide which one needs the work.

Paste it into a pull request, a $GITHUB_STEP_SUMMARY, or a README.

SARIF
tarp analyze --package ./... --format sarif > tarp.sarif

SARIF is the interchange format for static analysis findings — what SARIF viewers, GitHub code scanning, Jenkins' Warnings NG, SonarQube, and the sarif-tools CLI all read. Each untested function becomes one result under the rule tarp/untested-function.

Emitting it needs no entitlement. Only uploading it into GitHub code scanning does, which is why it is an output format rather than something the Action presumes. Plenty of consumers never touch GitHub with it:

Consumer What you get
VS Code's SARIF Viewer Click through findings in the Problems pane
sarif-tools diff Baseline a legacy codebase; see only what is new
sarif-fmt Readable terminal output
Jenkins Warnings NG / SonarQube Native ingest
actions/upload-artifact Just download the file

Two things it does that the JSON cannot:

  • Locations are stated against a declared base. %SRCROOT% is the module root, so a document produced on a runner resolves correctly on a laptop — and paths are right even when a subdirectory was analyzed.
  • Findings carry a fingerprint keyed on the function's name, not its position, so a declaration moving down the file is the same finding rather than a new one. That is what lets a consumer dismiss something and have it stay dismissed.

SARIF carries findings, not scores, so the grade rides in the run's property bag. --min-score is unaffected by the format and remains how a build fails.

The coverage view
go test -coverprofile=coverage.out ./...
tarp cover --html=coverage.out --package ./... -o coverage.html

cover renders the page go tool cover -html renders — same layout, same file picker, same spans in the same places — with the green split in two:

Color Meaning
red Never ran
yellow Ran, in a function no test names directly
green Ran, in a function a TestXxx body references
grey Ran, in a declaration tarp does not grade (init, main, generated, ignored) — or in a package that was not analyzed

Yellow is the whole point: it is the code go test -cover calls covered and this tool calls untested. On the example above, the file reads 100% covered and 3/4 tested, and B is the yellow one.

The report goes to stdout unless --output names a file, and no browser is opened. The profile's packages are analyzed exactly as analyze analyzes them, so --package and --strictness mean the same thing here. The grade in the header covers the files the profile describes, so it can differ from tarp analyze's when the profile does not reach every package.

How it works

golang.org/x/tools/go/packages loads each package with Tests: true, which yields four variants: the package itself, the package plus its internal tests, the external test package, and the synthesized test binary (which is skipped — it references every TestXxx and would otherwise credit every test to itself).

Every identifier in a TestXxx body is looked up in TypesInfo.Uses; the ones resolving to a function declared in the package under test are recorded. Functions are keyed on their declaration position, never on *types.Func identity, because the same function has different object pointers in different variants of its package.

Two bounded extensions to lexical scope:

  • Package-level test tables. A reference from a TestXxx body to a package-level var declared in a test file follows one hop into that var's initializer. One hop, test files only, var initializers only — following calls arbitrarily would quietly collapse file mode into any.
  • Interface dispatch. i.Do() resolves to Iface.Do, since that is the static type at the call site. When exactly one named type in the package implements the interface, its method gets the credit; when two or more do, none of them does, because there is no honest way to say which one ran.

Common commands

make format     # imports (gci), field/tag alignment, gofmt -s
make lint       # golangci-lint (Docker) + shellcheck (Docker)
make test       # go test -shuffle -race -vet=all -failfast (excludes cmd)
make bench      # benchmarks; a run is ~99.9% go/packages loading (see CLAUDE.md)
make build      # compile all packages + build the binary with version metadata
make release    # cross-compile the release archives into artifacts/release

GitHub Action

- name: Check tarp coverage
  uses: primandproper/tarpaulin@v0.1.0
  with:
    strictness: file
    failure_score_threshold: 50

It downloads a prebuilt binary for the runner (verifying its checksum), runs tarp analyze --json, annotates the untested functions inline on the pull request, writes the report to the job summary, and exits with tarp's own code.

Input Default Meaning
version pinned Which tarp release to run
package ./... A directory or a go/packages pattern, same two rules as the CLI
strictness file file, package, or any
failure_score_threshold 0 Fail when the grade drops below this percentage; 0 never fails
fail_on_found false Fail when anything at all is reported; strictly stronger
working-directory . Where to run tarp from
annotate true Emit inline annotations
max_annotations 10 GitHub renders at most 10 warnings per step; see below
summary true Write the report to the job summary
sarif_output Also write a SARIF document to this path; you decide what to do with it

Outputs score, declared, tested, untested_count, and report (the path to the raw JSON), so later steps can use the numbers:

- id: tarp
  uses: primandproper/tarpaulin@v0.1.0
- run: echo "graded ${{ steps.tarp.outputs.score }}%"

Three things worth knowing:

  • version is pinned, not floating. A stricter analyzer arriving on its own would break a consumer's CI on a day they changed nothing.
  • Annotations stay warnings even when the build fails. Promoting them to errors paints every untested function in the Files Changed view, including the ones the pull request never touched. The gate is what fails the build.
  • Only the first 10 are annotated, because that is what GitHub renders per step. The rest are not dropped quietly: the count is logged as a notice, and the job summary always carries the full list.
SARIF from the Action

sarif_output writes the document and stops there — what happens to it is yours to decide, because uploading into code scanning needs an entitlement this action does not assume. Keep it as an artifact:

- id: tarp
  uses: primandproper/tarpaulin@v0.1.0
  with:
    sarif_output: tarp.sarif
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: tarp-sarif
    path: ${{ steps.tarp.outputs.sarif }}

Or, if your repository has code scanning — public repos, or private ones with GitHub Advanced Security — send it to the Security tab instead:

- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: ${{ steps.tarp.outputs.sarif }}

Both need if: always(), or a tripped gate skips the upload on exactly the runs worth uploading. Asking for SARIF costs a second analysis pass, so it is off by default.

Releases

Publishing a GitHub release triggers .github/workflows/release.yaml, which runs the test suite, cross-compiles six targets, and uploads them to the release it was triggered by:

tarp_<tag>_{linux,darwin,windows}_{amd64,arm64}.{tar.gz,zip}
checksums.txt

Each archive holds the binary, LICENSE, and README.md, flat. The tag is used verbatim in the asset name — no leading v is stripped — so a consumer can interpolate a version straight into the URL without reproducing a transformation here. checksums.txt is SHA-256 over exactly the archives that run produced.

make release runs the same script locally as a dry run, defaulting VERSION to git describe; pass VERSION=v1.2.0 to pin it. The binary knows which release it is — tarp version reports it, injected at link time alongside the commit metadata.

Layout

cmd/main/                      # entrypoint: signal-cancellable context -> cli.Execute
internal/analysis/             # the analyzer: load, declarations, references, strictness
internal/analysis/testdata/    # the fixture corpus, one directory per case
internal/coverage/             # the cover profile -> annotated HTML renderer
internal/cli/                  # cobra root command, analyze/cover subcommands, output
internal/config/               # assembles observability.Config and builds the pillars
version/                       # build metadata, injected via -ldflags by scripts/build.sh
scripts/                       # build/format/lint/test/shellcheck helpers

Configuration

The CLI inherits two settings from its platform-go scaffolding, via flags or environment variables:

Flag Environment variable Default Values
--log-level TARP_LOG_LEVEL info debug, info, warn, error
--service-name TARP_SERVICE_NAME tarp any string

Logs are structured slog written to stdout, and nothing is emitted at the default info level, so tarp analyze --json stays machine-parseable.

History

The original tarp (2017) was renamed blanket (2018) and is now rewritten. The old implementation hand-rolled type inference across ~150 lines because it had no type information, and asked "is this a call?" — a question with an unbounded number of syntactic answers. Everything that made it hard is free under go/types; the fixtures that were expensive to pass in 2017 are kept in the corpus precisely because they once were.

The one idea carried forward whole is the three-color coverage view, which is what tarp cover --html renders — rebuilt against today's go tool cover output rather than the 2017 fork of it.

License

AGPL-3.0.

Directories

Path Synopsis
cmd
main command
Command main is the application's entrypoint.
Command main is the application's entrypoint.
tools/codegen/configs command
Command configs renders the application's per-environment configuration files from real, typed Go objects.
Command configs renders the application's per-environment configuration files from real, typed Go objects.
internal
analysis
Package analysis finds functions that carry no direct unit test.
Package analysis finds functions that carry no direct unit test.
cli
Package cli wires the command-line interface together and bootstraps the platform-go observability suite that the rest of the application builds on.
Package cli wires the command-line interface together and bootstraps the platform-go observability suite that the rest of the application builds on.
config
Package config assembles the application's configuration, most notably the observability settings that the platform-go observability suite consumes.
Package config assembles the application's configuration, most notably the observability settings that the platform-go observability suite consumes.
coverage
Package coverage renders a cover profile as HTML, colored by whether each function carries a direct unit test rather than by statement counts alone.
Package coverage renders a cover profile as HTML, colored by whether each function carries a direct unit test rather than by statement counts alone.
sarif
Package sarif renders an analysis report as a SARIF 2.1.0 document.
Package sarif renders an analysis report as a SARIF 2.1.0 document.
Package version exposes build metadata about the compiled binary.
Package version exposes build metadata about the compiled binary.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL