Skip to content

Unexporting what no importer uses

declscope shrink finds the exported declarations of internal/ packages that nothing outside their package uses. With -fix it unexports them.

Why it matters. An exported declaration takes shared scope by default, so the analyzer never reports a boundary on it. An exported name that nothing outside needs hides a declaration from every check here. Unexported, it takes private, and the analyzer checks who reaches it.

Why a subcommand. The analyzer reads one package, and any importer might use an exported name. Inside internal/, Go limits the importers to one directory tree. shrink loads the whole module, so it sees every one of them. In a workspace, it loads every module of the workspace. A nested module whose path lies under an internal/ parent can import the package too, so shrink loads it as well, and counts its uses. go vet and golangci-lint never run it.

// internal/user/user.go
package user

import "fmt"

func Load(id int) string { return Format(id) }

// Format renders an ID for display.
func Format(id int) string { return fmt.Sprint(id) }

type Record struct {
    ID   int
    Name string
}

func Dump(r Record) { fmt.Println(r) }

api/api.go calls user.Load and names user.Dump. Nothing outside user names Format or Record:

$ declscope shrink
internal/user/user.go:8:6: func Format is exported, but nothing outside example.com/app/internal/user uses it

declscope shrink -fix renames Format to format, and the doc comment that opens with it. Record is not reported: fmt.Println reads its fields through reflection, which is a use.

Run it before the analyzer

Apply shrink -fix first, then work on the analyzer's reports. A declaration it unexports becomes private to its namespace. Wherever another file uses it, the analyzer now reports a crossing, which declscope -fix or a move settles. The other order takes a second round.

$ declscope shrink -fix ./...
$ declscope ./...

Run them in the same order in CI. shrink exits 3 when it reports anything, as the analyzer does. With -fix, only the reports left without a fix count.

Patterns

The patterns only choose which packages are reported. To find uses, shrink always reads every package of the module, whatever the patterns name.

For example, this reports only on internal/user:

$ declscope shrink ./internal/user/...
internal/user/user.go:8:6: func Format is exported, but nothing outside example.com/app/internal/user uses it

Load is not reported, although api/ is outside the pattern. api/api.go calls user.Load, and that call is a use.

Otherwise, a pattern behaves as it does for go vet.

What counts as a use, and when the fix is withheld

A fix is offered only where no use can exist outside the package. Where a use may exist but cannot be proved, the report stays and says why no fix is offered.

Code Result
// internal/user/user.go
type ID int

func (ID) String() string { return "" }

func New() ID { return 0 }

// api/api.go
var _ fmt.Stringer = user.New()
Not reported. String satisfies fmt.Stringer
// internal/user/user.go
type Record struct {
    ID   int
    Name string
}

func Dump(r Record) { fmt.Println(r) }
Not reported. fmt reads the fields through reflection
// internal/user/user.go
type Record struct {
    Name string `json:"name"`
}
Not reported. A tag says a marshaller reads the struct
// api/api_windows.go, excluded from this build
var _ = user.Format
Not reported. A build-excluded file that writes user.Format uses it
// internal/user/user.go
type Status string

const (
    Active  Status = "active"
    Blocked Status = "blocked"
)

const DefaultStatus Status = Active

// api/api.go
var _ = user.Active
Not reported: Blocked. A const block of 2 or more constants of one type is one set. If the type or any value is used, no value is reported. If not, all of them are reported together.

Reported: DefaultStatus. It is outside the block, so it is judged alone
// internal/user/user_test.go
package user_test

func TestFormat(t *testing.T) { _ = user.Format(1) }
Reported: (no fix: external tests use it)
// api/api_windows.go, excluded from this build
var _ io.Closer = user.Open()
Reported for Conn.Close: (no fix: a build-excluded file of another package may use it). The file is not type-checked, and a method satisfies an interface without its name
// internal/user/user.go
func Format(id int) string { return format(id) }

func format(id int) string { return "" }
Reported: (no fix: the unexported name is taken or would be captured)
// internal/user/user.go
var Version = "dev"
Reported: (no fix: a string variable may be set by -ldflags -X, which names it)
// tools/go.mod, a nested module
module example.com/app/tools

require example.com/app/gen v1.0.0
Reported, with no fix for any package in the range, when a nested module builds a copy from outside the tree whose files import an internal package in the range: (no fix: the build of the nested module example.com/app/tools uses example.com/app/gen from /path/to/modcache/example.com/app/[email protected], which imports example.com/app/internal/user). Its uses are of other source. Every fix is withheld when the build list of a nested module cannot be read

Deleting unused code is out of scope. Once a declaration is unexported, staticcheck's unused and gopls' unusedfunc report it if nothing uses it.

Which packages it does not judge

shrink stands down wherever an importer could be unseen. Each internal/ package it skips is named on stderr with the reason, and the exit status ignores it. Each such line starts with declscope shrink:, which the table leaves out.

Code Why shrink prints on stderr
// api/api.go
package api
Another module may import it Nothing. Only internal/ packages are listed
// internal/tool/main.go
package main
-buildmode=plugin looks its exported symbols up by name not judged: example.com/app/internal/tool: package main
// internal/c/c.go
// int one() { return 1; }
import "C"
cgo names Go symbols where go/types does not look not judged: example.com/app/internal/c: the package uses cgo
internal/fast/fast.go
internal/fast/fast_arm64.s
So does assembly, for any architecture not judged: example.com/app/internal/fast: the package holds non-Go files
// tools/go.mod
module example.com/app/tools

require example.com/app v0.0.0

replace example.com/app => ../../elsewhere
A nested module may import the package, and this one reads it from another directory, so its uses are of other source. A published version, or a module that fails to load, is the same not judged: example.com/app/internal/user: the nested module example.com/app/tools may import it, and it reads example.com/app/internal/user from /path/to/elsewhere
// go.mod
module example.com/app

require example.com/app/tools v1.0.0
The build list names a module whose path lies in the range, and the build reads it from somewhere other than a nested module this run loaded: a published version, a replace outside the tree, or a vendored copy. Its uses are of other source. A build list that cannot be read is the same not judged: example.com/app/internal/user: the build uses example.com/app/tools from /path/to/modcache/example.com/app/[email protected]
$ declscope shrink ./internal/user/testdata/fixture
./... leaves it out, so this run has read none of its uses not judged: example.com/app/internal/user/testdata/fixture: ./... leaves it out, so this run does not load it

An interface's method names, and the test functions of a _test.go file, are never reported either. Every implementation would rename too, and go test finds a test by name.

A package that does not type-check refuses the whole run, since it would show no uses at all. So does a package that imports one that does not, directly or not.

Important

Two uses are outside what shrink can see. Silence either with the ignore below.

  • A name written as a string that no interface leads to, such as a template field or a constant given to reflect.Value.MethodByName.
  • A module outside this module's directory tree whose path extends an internal/ parent, such as a /v2 on another branch, or lies above one and holds its directory, when neither this module's build list nor a nested module's names it. Go checks internal/ by import path. One this module's build list names leaves the range unjudged, and one a nested module's names withholds the fixes, as the tables above show.

Silencing it

Write //declscope:ignore overexported // <why> where any other ignore goes: on the declaration, on a field's type, or before the package clause. A bare //declscope:ignore does not reach this rule.

shrink reports an ignore of it that silenced nothing. //declscope:ignore unused answers that report, as it does for the analyzer.

One ignore passes every build configuration. Where a field, a method, or a type a struct embeds is used outside its package only by files behind a build constraint, by name or by passing it to an interface, its ignore is not reported unused, though it silences nothing there:

// internal/a/a.go
type Mode struct {
    //declscope:ignore overexported // app/gui.go sets it behind the gui tag
    Flag string
}
// app/gui.go
//go:build gui

var _ = a.Mode{Flag: "gui"}
Run What happens to Flag
declscope shrink ./... app/gui.go is left out and read for names only, so Flag would be reported with no fix. The ignore silences it
GOFLAGS=-tags=gui declscope shrink ./... app/gui.go uses Flag. The ignore is kept for the run without the tag