DEV Community

mpyw
mpyw

Posted on

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

This is an English version of an article I wrote in Japanese.

The flat package

Opinions differ, but one school in Go says: few packages, large ones. Flat packages. Go against it, and split a package just to get a boundary, and you pay for it.

  • Import cycles, and the interfaces you write only to invert a dependency and break them
  • Every name the two halves share has to be exported. You wanted a boundary between two files, and you published an API. Keeping the exposure down means an internal/ layer at every boundary you draw
  • A wrong boundary costs more. Moving a declaration between files disturbs little. Moving it between packages tends to break every importer

So most Go code is said to be healthier flat. Going flat does not make the problem go away, though. The flat-package school cuts both ways. Go has exactly two levels of visibility.

Level Reach
Exported Every importing package
Unexported Every file in the package

There is no third level, no file scope. In a flat package, the smallest helper is a package-wide name, and any field can be written from anywhere in the package.

This helper belongs to this file, so do not call it from another file.

The convention may well be sound. The only place it exists is in the head of the person who wrote the package. You can restate it in a comment every time, and the compiler still does not know it.

Can an AI agent keep the discipline?

In the age of agentic AI, that unwritten understanding tears easily. An agent finds an unexported helper in scope, so it calls it. It sees an unexported field, so it writes to it. Each choice compiles, and a quick review lets it through. Comments help somewhat, never completely. AI writes fast, so the debt piles up just as fast.

"Just write it in CLAUDE.md," you say? I did. Even then, I could not make it hold. A convention in natural language cannot be checked deterministically, so it can only ever work probabilistically.

So let a machine check the convention instead. The package stays flat, the boundary stays inside it, and a dedicated linter does the checking. When an agent crosses a boundary, it is told what it crossed and how to repair it, and the next agent reads that decision. Run that loop.

So I built it.

https://github.com/mpyw/declscope

mise use -g "github:mpyw/declscope"   # go install and go tool work too
declscope ./...
Enter fullscreen mode Exit fullscreen mode

What it reports

Picture one database package, with a repository implementation per entity. A common enough layout.

user_repository.go

package database

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)
}

// turn a raw result set into a domain entity
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))
}
Enter fullscreen mode Exit fullscreen mode

order_repository.go

package database

type OrderRepository struct{ db *sql.DB }

func (r *OrderRepository) List(ctx context.Context, userID int64) ([]domain.Order, error) {
    // elided: run the query and get rows
    for rows.Next() {
        o, err := scanOrder(rows)
        // elided
    }
    return orders, rows.Err()
}

// turn a raw result set into a domain entity
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
}
Enter fullscreen mode Exit fullscreen mode

The two helpers do the same job. They pack a raw result set into a domain entity, nothing more. Really, both want to be called scan.

But a flat package can declare only one scan. So we write them apart by hand, as scanUser and scanOrder.

Look closely at what just happened. The User and Order in those names state ownership: which repository each helper belongs to. Something we did to dodge a collision has turned into a convention.

And the compiler does not know that convention.

The crossing an AI agent makes

normalizeEmail was written in user_repository.go, for scanUser. Yet scanOrder calls it.

o.BuyerEmail = normalizeEmail(email) // this one belongs to user_repository.go
Enter fullscreen mode Exit fullscreen mode

The compiler accepts it. It is just a call to an unexported function in the same package. A review can easily miss it, and an agent simply uses whatever it finds in scope.

declscope catches it.

$ declscope ./...
user_repository.go:21:6: func normalizeEmail is private to namespace "userRepository", but is used from namespace "orderRepository"
order_repository.go:21:20:      used here, in namespace "orderRepository"
Enter fullscreen mode Exit fullscreen mode

A crossing has two answers

Answer How
Keep the boundary Move the call inside the namespace
Share on purpose Write //declscope:package. -fix inserts it for you

Which one fits here? Normalizing an email address belongs to neither repository. So what we actually want is a third place: create email.go and move it there.

Except boundary does not go quiet on the move alone. Put it in email.go and it is still used from both user and order. You move it out, and then you state that it is shared.

email.go

package database

//declscope:package
func normalizeEmail(s string) string {
    return strings.ToLower(strings.TrimSpace(s))
}
Enter fullscreen mode Exit fullscreen mode

That one line stays in the source as a statement that this is shared. The next person to open the file reads it first, and so does the next agent.

-fix can insert //declscope:package for you, but not in this case. All -fix can do is widen a declaration where it stands. It cannot move one across files. Leave it to the tool and normalizeEmail becomes shared while sitting in user_repository.go.

-fix always widens, because that is the repair a tool can apply mechanically. Deciding where a declaration should live is exactly the judgment you want an AI agent to make.

Only two things to learn

The file name is the namespace

A namespace is the unit within which a private declaration may be used. By default each file is its own namespace, named after the file in lowerCamelCase (user_repository.go becomes userRepository).

Suffixes that follow Go's own conventions are dropped: *_test.go, *_windows.go, *_darwin.go and the like.

When one unit spans several files, write a directive before the package clause to join a shared namespace.

user_repository.go

//declscope:namespace user

package database
Enter fullscreen mode Exit fullscreen mode

The core namespace

There is one more: a namespace with no name, one per package.

client.go

//declscope:core

package transport

func doSomething() int { return 1 }
Enter fullscreen mode Exit fullscreen mode

Several files may carry //declscope:core, and they share the one core namespace.

To boundary, the core is just one more unnamed namespace. Nothing is special about it. A core declaration is private to the core by default, and reaching it from outside is reported like any other crossing.

client.go:4:6: func doSomething is private to the core namespace, but is used from namespace "other"
Enter fullscreen mode Exit fullscreen mode

Where it matters is qualify, below. The core has no name, so qualify asks nothing of its declarations. It is the escape hatch for the file that carries the package's own subject, and for utilities used from everywhere, so that neither is forced to wear a meaningless prefix.

Files in the core share one namespace, so they can reach each other's private declarations. Throw everything into the core and that part is a flat package again. Use it sparingly.

There are only two scopes: package and private

Scope Meaning Rust equivalent
package Usable anywhere in the package pub(super)
private Usable only inside its own namespace No modifier

There is no public. Go already spells that with a capital letter, and a use beyond the package edge is outside what declscope examines.

By default an exported declaration is package, and everything else is private.

Four rules

Rule The question it asks Default
boundary May this file touch that declaration? Always on
qualify Reading this name on its own, can you tell which concern owns it? Off
surplus Is that shared declaration really needed? On
directive Does that directive decide anything? Always on

The bottom two need little explanation. surplus reports a //declscope:package with no visible use from another namespace. directive reports a directive that binds nothing, or is malformed. Both audit whether what you wrote is still earning its keep.

The boundary rule

This is what we saw at the top. It reports a private declaration used from outside its namespace.

It covers package-level declarations and a type's members, which are a struct's fields and an interface's method names. And the namespace has no exceptions to how it is decided. It is the file the declaration is physically written in, and nothing else.

  • A member is written inside its type's declaration, so it belongs to the file the type is in
  • A method with a receiver belongs to the file it is written in, wherever its type lives

Splitting a type's methods across files is ordinary Go. Let us add a small query builder to the same database package.

statement.go

package database

// Statement carries the state of a query under construction
type Statement struct {
    Table  string
    wheres []string
    args   []any
}

func (s *Statement) Build() (string, []any) {
    q := "SELECT * FROM " + s.Table
    if len(s.wheres) > 0 {
        q += " WHERE " + strings.Join(s.wheres, " AND ")
    }
    return q, s.args
}
Enter fullscreen mode Exit fullscreen mode

query.go

package database

// the DSL that stacks conditions. Methods on Statement, gathered here
func (s *Statement) Where(cond string, args ...any) *Statement {
    s.wheres = append(s.wheres, cond)
    s.args = append(s.args, args...)
    return s
}
Enter fullscreen mode Exit fullscreen mode

The type goes in statement.go, and the DSL that stacks conditions goes in query.go. This shape turns up in well-known Go libraries.

$ declscope ./...
statement.go:6:5: field Statement.wheres is private to namespace "statement", but is used from namespace "query"
query.go:5:5:   used here, in namespace "query"
query.go:5:23:  used here, in namespace "query"
statement.go:7:5: field Statement.args is private to namespace "statement", but is used from namespace "query"
query.go:6:5:   used here, in namespace "query"
query.go:6:21:  used here, in namespace "query"
Enter fullscreen mode Exit fullscreen mode

Putting Where in query.go is not itself held against you. What got reported is wheres and args, and they were recorded against statement.go, where they are declared.

These two files are really one concern split in two, so the thing to do is merge the namespaces.

query.go

//declscope:namespace statement

package database
Enter fullscreen mode Exit fullscreen mode

Now query.go joins the statement namespace, and the errors are gone.

The qualify rule

It is off by default, but I recommend turning it on. It asks that a package-level declaration carry its file's namespace somewhere in its name. Where boundary asks "may this file touch that declaration?", qualify asks "reading this name on its own, can you tell which concern owns it?"

It is not a demand for a prefix. Forcing a prefix often produces unnatural names, so the check is deliberately flexible about where the namespace appears.

This naming rule has no effect whatsoever on the boundary rule.

Also, struct fields and methods in the same namespace as their type are out of scope.

Take the query builder from above, where the two sit side by side. Build is in statement.go with its type, and Where is in query.go.

statement.go

type Statement struct {
    Table  string
    wheres []string // out of scope
}

func (s *Statement) Build() (string, []any) { /* ... */ }   // out of scope
Enter fullscreen mode Exit fullscreen mode

query.go

func (s *Statement) Where(cond string) *Statement { /* ... */ }   // in scope
Enter fullscreen mode Exit fullscreen mode
$ declscope ./...
query.go:4:21: method Where does not carry namespace "query" anywhere in its name;
               rename it to QueryWhere, or to another name that carries "query"
Enter fullscreen mode Exit fullscreen mode

Nothing is asked of Build, and only Where is reported. Two methods on the same type, in the same shape, treated differently.

What divides them is whether the method is foreign.

  • A method in the same namespace as its struct is already identified well enough by the struct's own naming. It is not made to carry the convention a second time in its own name.
  • A method in a different namespace from its struct is treated as a foreign method, and is asked to carry that namespace's name.

Of course QueryWhere, the name the tool suggests, is plainly not the answer. One of these would be better:

  • Merge the namespaces into one statement
  • Rename query.go to the narrower topic it actually covers, such as where.go

For a second example, go back to user_repository.go from the top of this article. scanUser is declared there, on line 11. Turn this rule on, and the first thing you hear is that scanUser is up for renaming.

$ declscope ./...
user_repository.go:11:6: func scanUser does not carry namespace "userRepository" anywhere in its name;
                         rename it to userRepositoryScanUser, or to another name that carries "userRepository"
Enter fullscreen mode Exit fullscreen mode

userRepositoryScanUser is dreadful, obviously. But the report itself is right, and what is wrong is the rename it suggests.

Think about it. Is the concern this file covers userRepository? It is not. It is user. Repository is in the file name and names no concern.

user_repository.go

//declscope:namespace user

package database
Enter fullscreen mode Exit fullscreen mode

Now the namespace is user, scanUser carries it, and the report goes quiet. The same goes for order_repository.go.

That leaves normalizeEmail. It cannot carry user. And it cannot because it does not belong to user.

email.go

package database

//declscope:package
func normalizeEmail(s string) string {
    return strings.ToLower(strings.TrimSpace(s))
}
Enter fullscreen mode Exit fullscreen mode

Move it to email.go and the namespace becomes email, which normalizeEmail carries, so the error goes away.

That clears every error in the package. What qualify asks is not "change the name" but "do this name and the place it sits agree with each other?" The answer may be a new name, or a new namespace, or a new file.

The settings I recommend

The same ones declscope holds itself to.

.declscope.yaml

rules:
  naming:
    qualify: ondemand   # required once a package has a second namespace
    exported: true      # reach exported declarations too
Enter fullscreen mode Exit fullscreen mode

qualify: ondemand switches on for a package with two or more namespaces, and stays off for a package with one. A prefix repeated across everything distinguishes nothing, so it would be pointless there. This is usually the setting you want.

exported: true touches the public API, so adopting it will not always go smoothly. In a repository built mainly around internal, it may go in with little disruption. On a new project I would take it every time.

Note that a rename is never suggested for an exported declaration. It is only reported. Uses outside the package are invisible to declscope, so it cannot judge whether rewriting is safe. Running -fix will never change your public API behind your back.

Adopting it on an existing codebase

For a codebase that already has crossings in bulk, use a baseline.

$ declscope baseline ./...       # writes .declscope-baseline.yaml
Enter fullscreen mode Exit fullscreen mode

Today's violations are recorded, and from then on only new ones are reported. An entry is keyed by package, rule, namespace and declaration name, never by position, so it survives code moving within a file.

And a baseline suppresses without endorsing. Nothing is written into the source, so the rules still apply to every new declaration, and an entry disappears only when the violation is fixed. What you cleaned up shows up in git diff.

Using it with an AI agent

Back to the motivation. declscope runs wherever an agent's edits are checked: in CI, and inside the agent's own build loop.

On an existing codebase, take a baseline once at the start. The agent is then shown only the boundaries its own edits crossed.

Two things separate this from a convention that is merely written down.

Property Effect on the agent
The diagnostic names the namespace that was crossed It learns why the use is wrong, and the repair is mechanical
A directive is a durable record of intent The next agent inherits the decision instead of re-deriving it

The second one is what I was really after. "This helper may be shared" used to be a decision that died in a review comment, or in somebody's memory. With declscope it stays in the source as one line of //declscope:package, and it is the first thing the next agent reads when it opens the file.

The adoption know-how ships as a skill too

A README is where you write what declscope is. But what an agent trips over when introducing it to an existing repository turned out to be entirely different knowledge. That part ships as a skill.

gh skill install mpyw/declscope
Enter fullscreen mode Exit fullscreen mode

Here is the sort of thing it says.

  • A failed build reports zero diagnostics. And that is indistinguishable from success. Get go build ./... passing before you read any count
  • A baseline turns every count into zero. Move it aside before you measure, or the codebase will look clean
  • Zero from qualify does not mean clean. It is off by default, so never report "no problems" from a zero without checking the config. The config is looked up from the analyzed package upwards, so a repository can hold several. Find them all
  • //declscope:namespace goes before the package clause. After it, the directive is silently inert. When nothing you do moves the diagnostics, suspect the placement first
  • The configuration is the repository owner's decision, not the agent's. Ask before writing a config file, and wait for the answer

It also gives an order of work. Clear boundary first, and clear it by moving the boundary rather than widening everything. Then measure again. While you clear boundary, two namespaces may merge into one, which takes ondemand out of force, and qualify reports can vanish in a chain. Do it the other way round and you spend the day renaming things that were about to disappear.

Limits

declscope reads one package at a time, and counts a use only where a name is written. So the following are invisible to it.

  • Operations on a struct's whole value (copying, comparing and zeroing name no field)
  • Reflection, //go:linkname, generated code
  • A declaration nobody uses. boundary works by finding uses, so unused code produces nothing

For the last one, pair it with a linter for unused code, such as deadcode. To draw boundaries between packages, that is depguard. depguard keeps the package graph honest, declscope keeps each package honest inside, and deadcode strips what neither needs to reach.

A Go program drawn as nested frames. Between the api and database packages, depguard asks whether one package may import another. A green arrow runs from api to database, and a red one back from database to api is crossed out. Inside database, between user_repository.go and order_repository.go, declscope asks whether one file may reach another's declaration. A red arrow from scanOrder to normalizeEmail is crossed out. At the edge of the program, deadcode asks whether anything is reachable at all. The mail package sits greyed out with no arrow entering it, captioned unreachable.

Wrapping up

  • Go has two levels of visibility, and no file-scoped private. Stay flat, and "this one belongs to this file" exists only in somebody's head
  • An AI agent does not know that convention, and breaks it far faster than people used to. A convention in natural language cannot be checked deterministically, so it only ever works probabilistically
  • declscope checks that convention deterministically, with the package still flat. A crossing always has two answers: keep the boundary, or state that the sharing is deliberate
  • Turn qualify on as well. Its reports do not say "change the name". They ask whether this file is a sensible place to declare this
  • A directive is a durable record of a decision somebody made. The next agent inherits it instead of re-deriving it
  • The adoption procedure ships as a skill. An author can now write and distribute the instructions an agent is meant to read

https://github.com/mpyw/declscope

If anything is unclear, or a rule feels too strict, please tell me in an issue or in the comments.

Top comments (0)