Why¶
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 |
There is no third level. In a flat package, every helper is a package-wide name, and every field is a package-wide reach.
Splitting the package to get a boundary has a price.
- Import cycles, and interfaces written only to break them.
- Every name the two halves share has to be exported. You wanted a boundary between two files, and you published an API.
- A wrong boundary costs more. Moving a declaration between files is free. Moving it between packages breaks every importer.
internal/ does not help here. It limits who may import a package, but it adds no level below unexported. So most Go code is better off flat, and the cost appears inside the package.
Teams handle this with a convention: this helper belongs to this file. The convention lives only in the heads of the people who wrote the package.
An AI agent does not know it either. It sees an unexported helper in scope, so it calls it. It sees an unexported field, so it writes to it. Each edit compiles and passes a quick review, and the package becomes a mesh.
declscope checks the convention instead. When an agent crosses a boundary, it is told what it crossed and given a fix. The decision is then written in the source, where the next agent reads it.
What it reports¶
One database package holds a repository per entity.
// user_repository.go
package database
import (
"context"
"database/sql"
"strings"
"example.com/app/domain"
)
type UserRepository struct{ db *sql.DB }
func (r *UserRepository) Find(ctx context.Context, id int64) (*domain.User, error) {
row := r.db.QueryRowContext(ctx, `SELECT id, email FROM users WHERE id = ?`, id)
return scanUser(row)
}
func scanUser(row *sql.Row) (*domain.User, error) {
var u domain.User
var email string
if err := row.Scan(&u.ID, &email); err != nil {
return nil, err
}
u.Email = normalizeEmail(email)
return &u, nil
}
func normalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// order_repository.go
package database
import (
"database/sql"
"example.com/app/domain"
)
type OrderRepository struct{ db *sql.DB }
func scanOrder(rows *sql.Rows) (domain.Order, error) {
var o domain.Order
var email string
if err := rows.Scan(&o.ID, &email); err != nil {
return domain.Order{}, err
}
o.BuyerEmail = normalizeEmail(email)
return o, nil
}
The two scan helpers are named apart by hand, since only one could be called scan. That mark says which repository owns which helper, and nothing holds anyone to it.
normalizeEmail was written for scanUser. scanOrder calls it, and the compiler accepts that.
$ 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"
A crossing has two answers.
| Answer | How |
|---|---|
| Keep the boundary | Move the call inside the namespace |
| Share on purpose | Write //declscope:package, which -fix inserts |
Here the helper belongs to neither repository. The repair is a third file, and a stated scope.
// email.go
package database
import "strings"
//declscope:package
func normalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
Tip
-fix always widens, because that is the repair a tool can apply. It writes the directive where the declaration stands, and never moves a declaration to another file. Where the boundary is worth keeping, move the call yourself.
Where declscope sits¶
Three linters draw boundaries in Go, at three scales.

| Linter | Scale | The question it answers |
|---|---|---|
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? |
The three compose. depguard keeps the package graph honest, declscope keeps each package honest inside, and deadcode removes what neither needs to reach.