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 areprivateby 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 shrinkloads the whole module and unexports the exported declarations ofinternal/packages that nothing outside their package uses.declscope baselinerecords 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 installinstalls agent skills for writing code under these rules. - Built on
go/analysis. Run it directly or throughgo 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
- GitHub: GitHub - mpyw/declscope: Keep Go packages flat without losing their internal boundaries: file-level private and package scopes for declarations, methods and struct fields · GitHub
- Documentation: declscope
- Write-up (why I built it, and the design behind it): declscope: a linter that dramatically improves the Go code AI writes - DEV Community
Feedback, issues, and stories of how your team handles package-internal boundaries are very welcome!
