declscope: a linter that adds file-scoped private to Go's flat packages

Hi all! I’d like to share declscope, a linter for a gap in Go’s visibility rules.

Go has two levels of visibility, and the lower one covers the whole package:

Level Reach
Exported Every importer
Unexported Every file in the package

So in a flat package, every unexported helper and field can be used from every file. Splitting the package to get a boundary costs import cycles and forces you to export what the halves share. declscope adds the missing level: a declaration is private to the file that declares it, unless you say otherwise.

What it looks like

// user_repository.go
package database

func scanUser(row *sql.Row) (*domain.User, error) {
	// ...
	u.Email = normalizeEmail(email)
	return &u, nil
}

func normalizeEmail(s string) string {
	return strings.ToLower(strings.TrimSpace(s))
}
// order_repository.go
package database

func scanOrder(rows *sql.Rows) (domain.Order, error) {
	// ...
	o.BuyerEmail = normalizeEmail(email) // compiles fine...
	return o, nil
}
$ declscope ./...
user_repository.go:28:6: func normalizeEmail is private to namespace "userRepository", but is used from namespace "orderRepository"
order_repository.go:17:17:      used here, in namespace "orderRepository"

If sharing is intended, say so in the source. declscope -fix inserts the directive for you:

// email.go
package database

//declscope:shared
func normalizeEmail(s string) string {
	return strings.ToLower(strings.TrimSpace(s))
}

The decision now lives in the code, not in the heads of the people who wrote the package.

Highlights

  • Exported names are shared, unexported names are private by default. Directives can override either.
  • Namespaces come from file names (user_repository.go → userRepository). Tests share the namespace of their subject. A unit can span several files with //declscope:namespace <name>.
  • Struct fields are covered too, so another file can’t quietly write to a field it doesn’t own.
  • declscope shrink loads the whole module and unexports the exported declarations of internal/ packages that nothing outside their package uses.
  • declscope baseline records existing violations, so you can adopt it on a large codebase and only see new ones.
  • Works well with AI coding agents. An agent sees an unexported helper in scope and calls it. declscope tells it which boundary it crossed and gives a deterministic fix. declscope skill install installs agent skills for writing code under these rules.
  • Built on go/analysis. Run it directly or through go vet -vettool.

Where it fits

Linter Scale Question
depguard Between packages May this package import that one?
declscope Within one package May this file reach that declaration?
deadcode Whole program Is this reachable at all?

Install

mise use "github:mpyw/declscope@0.20.4"
# or
go install github.com/mpyw/declscope/cmd/declscope@latest

Links

Feedback, issues, and stories of how your team handles package-internal boundaries are very welcome!