Build1 publisher3 min readPublished
declscope makes an unexported Go helper private to the namespace that declared it
Go stops at exported and package-wide, so a helper meant for one file can be called from every file in the package. mpyw's linter checks that boundary and reports the crossing at both the declaration and the call.
The Engineer · Build desk

What happened
- mpyw has published declscope, a Go linter that treats an unexported declaration as private to the file it was written in and reports uses of it from other files in the same package.
- The linter labels each repository file with a namespace name and flags normalizeEmail as private to userRepository while it is used from orderRepository.
- Installation is a mise use -g against github:mpyw/declscope, with go install and go tool also working, and the tool is run across a tree as declscope ./...
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision A team that stayed flat to avoid exported names and internal/ layers now has a middle option for a boundary it used to enforce by hand, and the reviewer stops being the only check.
- capability A crossed boundary becomes machine output with file and line, so an agent editing the package can be told to repair the call it just wrote.
- constraint A build that does not run the linter compiles the violation clean, so the boundary holds only in repositories that run it in CI.
- precedent A visibility level supplied by a third-party linter is per-repository policy, so code copied out of a package that enforces namespaces lands somewhere the rule does not exist.
Counting what the linter adds, a flat package gets three levels of visibility: exported, unexported, and private to a namespace [18]. The first two come from the language, which has exactly two and no file scope [1].
Both files in the worked example sit in package `database` [12]. The compiler takes the cross-file call without complaint, because it is a call to an unexported function in the same package [15]. declscope runs as a separate pass over the package and prints the crossing at both ends, the declaration at `user_repository.go:21:6` and then the use: `order_repository.go:21:20: used here, in namespace "orderRepository"` [16][17].
The flat school has reasons for putting itself in this position. Split a package to get a boundary and you take on import cycles, plus the interfaces you write only to invert a dependency and break them [4]. Every name the two halves share has to be exported, so a boundary between two files becomes a published API unless you add an `internal/` layer at each boundary you draw [5]. A wrong boundary is expensive too: moving a declaration between files disturbs little, while moving it between packages tends to break every importer [6]. Stay flat and the smallest helper is a package-wide name, and any field can be written from anywhere in the package [2].
So the ownership rule migrates into the identifiers. Both helpers in the example pack a raw result set into a domain entity, both want to be called `scan`, and a flat package can declare only one `scan`, so they are written apart by hand as `scanUser` and `scanOrder` [13]. The `User` and `Order` state which repository each helper belongs to [14]. `normalizeEmail` was written in `user_repository.go` for `scanUser`, and `scanOrder` calls it anyway [15].
An agent finds an unexported helper in scope and calls it, or sees an unexported field and writes to it. Both compile, and a quick review lets them through [10]. mpyw wrote that he put the convention in CLAUDE.md and could not make it hold [9]. "A convention in natural language cannot be checked deterministically, so it can only ever work probabilistically," he wrote [8]. The stated design goal is that a crossing tells the agent what it crossed and how to repair it, and the next agent reads that decision [19].
Whether the result transfers depends on how a namespace gets declared. The published evidence is one two-file example built to show the failure [12]. In a package of eighty files, the boundaries have to be written somewhere a reviewer can audit and a newcomer can find, and the post does not show how a namespace is declared [11][20].
In my view this is the right trade in packages an agent edits, because the review that would have caught the crossing is the review being skipped [10]. In a package only people touch, the comment has mostly held, and the convention was already legible in the names [14].
What to watch
- Whether declscope gains a way to grandfather existing cross-file calls, which decides what a retrofit costs on a mature flat package.
- Whether it turns up as a golangci-lint plugin, since that is where most Go teams already run their checks.
- Whether the flat-package school adopts namespace privacy or treats file scope as something Go left out on purpose.