declscope: a linter that brings file-scoped private to Go's flat packages
Go’s flat packages lack a third visibility level, so developers rely on conventions to keep helpers private to a file. Declscope automates this convention, detecting and fixing cross‑file private usage. The tool helps AI agents and teams maintain clean boundaries without manual review.
Go offers only two visibility levels: exported names that any importing package can see, and unexported names that are visible to every file in the same package. The language intentionally omits a file‑scoped private level, which means that if a helper function or field is only meant for a single source file, the compiler treats it as package‑wide. Developers traditionally rely on a convention—"keep private helpers in the file that uses them"—to avoid accidental exposure. However, this convention is invisible to the compiler and can be easily violated by automated tools or human error.
Why File‑Scoped Privacy Matters
When a helper is inadvertently used from another file, it becomes part of the package’s public contract, even though the name is unexported. This can lead to subtle bugs: an AI agent or a new developer might call the helper, assuming it is safe to use, while the helper’s implementation may rely on state that only exists in the original file. The consequence is a fragile dependency that is hard to refactor and can break imports if the helper is moved or renamed.
Declscope: The Tool That Enforces the Convention
Declscope is a static analysis linter that checks Go source files for violations of the file‑scoped private convention. It works by treating each file as a separate namespace and reporting any unexported declaration that is referenced from a different namespace. The linter can also suggest or automatically apply fixes, such as widening a declaration’s visibility or moving a function into a shared namespace.
- Namespace Definition: By default, a file’s namespace is derived from its name (e.g.,
user_repository.gobecomesuserRepository). Suffixes like_test.goare ignored. - Core Namespace: A package can declare a shared namespace using the directive
//declscope:core. All files with this directive share the same private scope, allowing helpers that truly belong to the package as a whole. - Boundary Rule: The linter reports any unexported name that is used outside its defining file. This includes functions, variables, struct fields, and interface methods.
- Qualify Rule: When enabled, the linter checks that a package‑level declaration’s name contains the namespace it belongs to. This encourages self‑documenting code and makes it easier for reviewers to see ownership at a glance.
Practical Example: Normalizing Email Addresses
Consider two repository files in a database package: user_repository.go and order_repository.go. Both need to normalize email addresses, so they each contain a private normalizeEmail function. Declscope flags this as a crossing because the function is defined in one file but called from the other. The tool offers several remedies:
- Move
normalizeEmailinto a new fileemail.goand add the comment//declscope:packageto mark it as a shared helper. - Keep the function in its original file but add a comment
//declscope:sharedto indicate intentional sharing. - Use the automatic
//declscope:fixdirective to widen the function’s visibility within the package.
Choosing the first option keeps the codebase clean and respects the original file‑scoped intent. Declscope’s reporting makes the decision explicit, reducing the chance that future changes will re‑introduce the same violation.
Integrating Declscope into Your Workflow
Adding declscope to a Go project is straightforward. Install the binary with go install github.com/mpyw/declscope@latest and run it against the module root: declscope .. The linter outputs a list of violations with file and line numbers, making it easy to address each issue. Because the tool can automatically insert fix comments, it can be run as part of a CI pipeline, ensuring that every commit respects the file‑scoped privacy convention.
For teams that employ AI code generators, declscope acts as a safety net. The AI can still call unexported helpers, but the linter will catch any accidental cross‑file usage and prompt the developer to correct it before the code is merged.
Conclusion
Go’s lack of a third visibility level forces developers to rely on conventions to keep helpers private to a file. Declscope automates this convention, providing clear diagnostics and automated fixes. By enforcing file‑scoped privacy, the tool helps maintain clean, maintainable Go code and reduces the risk of subtle bugs introduced by accidental cross‑file references.
Why it matters
Maintaining strict boundaries between files prevents accidental dependencies that can break refactors and complicate AI‑driven development. Declscope turns a convention into a verifiable rule, keeping codebases robust and easier to understand.
Key points
- Go has only exported and unexported visibility; no file‑scoped private level.
- Declscope treats each file as a namespace and reports cross‑file private usage.
- The linter can suggest or auto‑apply fixes, such as moving helpers to a shared namespace.
- Enabling the qualify rule encourages self‑documenting names that reveal ownership.
- Declscope is lightweight and integrates into CI, protecting code from accidental cross‑file calls.
- It is especially useful when AI agents generate code, ensuring they respect file‑scoped boundaries.
Frequently asked questions
What is the difference between the core namespace and a regular file namespace?
The core namespace, declared with <code>//declscope:core</code>, is shared by all files in the package that include the directive. It allows helpers that belong to the entire package to be private to the package rather than to a single file.
Can Declscope automatically move a function to a shared namespace?
No, Declscope can only widen a declaration’s visibility or insert a <code>//declscope:fix</code> comment. Moving a function across files must be done manually by the developer.
Does Declscope affect exported names?
No. Declscope only checks unexported (lowercase) declarations. Exported names are already visible to all packages and are outside the linter’s scope.
How does the qualify rule help developers?
When enabled, it forces package‑level names to contain the namespace they belong to, making ownership obvious at a glance and reducing the chance of accidental cross‑file usage.




