Skip to content

Rules

A rule is one check. A rule's name is the diagnostic's category, its baseline key, and what //declscope:ignore targets.

Rule Reports Fix Configured by Default
boundary A declaration used from outside the namespace it is private to Insert //declscope:package rules.boundary on
qualify A name that does not carry its namespace Rename to prefix it rules.naming.* Off
surplus Package scope with no visible use from another namespace Under strict, insert //declscope:private rules.surplus loose
directive A directive that binds nothing, or is malformed None No On
filter A filter.only that an only above it cancels None No On

Every diagnostic carries at most one fix, so -fix never has to choose.

boundary

boundary reports a private declaration used from outside its namespace. For a member, the boundary is the namespace of its type. The example above is this rule.

The message names where the scope came from.

Where the scope came from Message
defaults func normalizeEmail is private to namespace "userRepository", but is used from namespace "orderRepository"
The declaration's own directive func normalizeEmail is declared private by //declscope:private, but is used from namespace "orderRepository"
Its type's directive field Statement.wheres is declared private by //declscope:private on Statement, but is used from namespace "query"
A file-level directive func normalizeEmail is declared private by the file's //declscope:private, but is used from namespace "orderRepository"

Every crossing use site is attached to the diagnostic, and a use from inside the declaration's own namespace is not. The report lands on the declaration, not on the use.

Case Fix
The declaration states its own scope None. The directive and the use are both deliberate, and -fix must not overwrite what the author wrote
The private is inherited from a type or a file Offered. The inserted directive sits on the declaration, which outranks both
A type and its members cross together One directive, on the type. The members are reported without a fix, since a second directive on them would bind nothing

Reach without a boundary

rules.boundary: off switches this rule off. What is left is the naming rule, for a repository that wants the ownership mark in a name without the scope behind it.

rules:
  naming:
    qualify: ondemand
  boundary: off
  surplus: off

Set surplus: off alongside it. surplus audits //declscope:package, which means nothing once reach is not checked.

Tip

This is not how to adopt declscope gradually. A baseline records what a codebase already has and still reports what is new. A switch reports nothing, so you never learn what turning it on later would cost.

The naming rule

Off by default. Turn it on with rules.naming.qualify.

The rule asks a package-level declaration to carry its file's namespace somewhere in its name. The name then says which unit owns it, at the call site and in a stack trace.

// order_repository.go
func scanOrder(rows *sql.Rows) (domain.Order, error) {
    // ...
    o.BuyerEmail = normalizeEmail(email) // the email unit's, and shared
}

The mark grants nothing. Reach is stated by scope alone.

Note

The rule is opt-in because whether a prefix reads well depends on the file name, and no tool can see that. A prefix from comments.go reads as a noun phrase, commentsAttached. A prefix from collect.go reads as a command, collectAddFunc.

When it applies

rules.naming.qualify Effect
never (default) Off
ondemand Required once the package has a second namespace. A file holding only a package clause and comments, as a doc.go usually does, is not counted
always Required in every package, so that gaining a second namespace is not a mass rename
Declarations the rule never reaches
Declaration Reason
A member, or a method written beside its type Already qualified by that type at every use
func main in package main A name the toolchain requires
TestXxx, BenchmarkXxx, FuzzXxx, ExampleXxx in a _test.go file The same. go test finds them by name
A declaration in a namespace that cannot start an identifier, as in 2fa.go The fix prefixes, and no identifier begins with a digit
An exported identifier, unless rules.naming.exported is on How the API is spelled is the author's decision
A declaration in the core namespace The core has no prefix

What carries a namespace

The namespace must appear in the name, ignoring case, starting at a word boundary. The match may end inside a word.

In user_id.go, namespace userID Carries it
userIDCache, userIdCache Yes
loadUserID, parseUserIds Yes. Anywhere in the name, and the right edge may run on
poweruserID No. user does not start a word there
user in user.go Yes. The name is the namespace

The left edge is anchored because the right one is not. Without the anchor, key would be found in monkey.

Two English inflections change the namespace's own spelling. Both are accepted.

Namespace Also carried by
store storing. The final e drops before ing
apply applies, applied. The final y turns to i

Only these whole forms are generated, from the namespace's side. The name is never stemmed, so story and storm do not carry store.

rules.naming.vocabulary lists extra words that carry a namespace. A listed word goes through the same test, so wheelDelta carries mouse and pinwheel does not.

rules:
  naming:
    vocabulary:
      mouse: [wheel]
      index: [indices]

Warning

The vocabulary is for the irregular few. A namespace that needs a long list is a sign that the file declares things it is not about. Splitting the file says more than listing them.

The rename

The fix prefixes, keeping Go's spelling of an initialism and the original exportedness.

Case Example Never
Unexported iduserID userId
Exported, under rules.naming.exported LoadUserLoad userLoad

The suggestion is one answer, not the only one. Any spelling that carries the namespace settles the rule. A rename never changes what a name is visible to.

Note

Sometimes the namespace is what is wrong, not the name. A file name may hold words that name no unit.

user_repository.go:18:6: func scanUser does not carry namespace "userRepository" anywhere in its name; rename it to userRepositoryScanUser, or to another name that carries "userRepository"

The unit here is user, not userRepository. //declscope:namespace user on the file settles the rule and leaves every name alone.

Withheld renames

A rename is offered only when it provably changes nothing but the spelling. The violation is reported either way. A doubt withholds the fix, never the diagnostic.

Caution

Go resolves a name from the inside out, so a new name that is free at package level can still be bound at a use site. The wrong rename compiles and computes something else.

var count = 10
func Add(fooCount int) int { return fooCount + count } // Add(1) == 11
// after a careless rename of count to fooCount
func Add(fooCount int) int { return fooCount + fooCount } // Add(1) == 2

The four reasons a fix is withheld
Reason When
The rename could not be completed The declaration is exported, a use sits in a generated, filtered-out or build-excluded file, or a //go:linkname or //export names it as text
The new name is taken It is already declared in the package, predeclared like len, imported by some file, or claimed by another fix in the same run
The new name would resolve elsewhere At some use it is bound by a local, parameter, result or type parameter
This pass does not read every file The package has _test.go files this variant cannot see. The test variant sees them all and decides for both

surplus

surplus is the converse of boundary. It reports package scope that no visible use from another namespace needs.

rules.surplus Reports Fix
off Nothing
loose (default) A //declscope:package that nothing it reaches needs None
strict What loose reports, plus each declaration a directive in use widens for nothing Insert //declscope:private
// email.go
package database

//declscope:package
func normalizeEmail(s string) string { return s }
$ declscope ./...
email.go:3:1: //declscope:package on normalizeEmail: no use from another namespace is visible to declscope

One comment gets one report, however many declarations take their scope from it. A declaration that states its own scope does not depend on the comment, so it neither keeps it alive nor appears under it.

Important

The rule concludes from an absence. A directive can hold up something declscope cannot see, so the rule stays quiet on any doubt. For the same reason loose offers no fix: its advice is to delete a directive.

What keeps a directive alive, and when the rule stands down

The whole comment stays quiet when any declaration it reaches may be needed.

Kept alive by Why the rule cannot rule it out
A use from another namespace The directive is doing its job
An exported name in the comment's reach Importers reach it, which one package never sees
A method in an interface contract of the package An interface value reaches the method without spelling it
An unexported method carried by an exported type An importer can embed the type and complete a satisfaction
A struct conversion involving the field's type The conversion pairs every field by name and spells none
//go:linkname or //export naming the declaration The directive names it as text

The rule switches off for a whole package when some reference site was never read.

Switched off by What was not read
A generated, filtered-out, cgo or assembly source Those files are never read as reference sites
A build-excluded file of the package It may hold the one use
In-package _test.go files this variant does not see The test variant sees every file and decides

Reach that spells no name, such as reflection, is invisible here as everywhere.

strict

Under loose, one used declaration keeps its whole directive quiet. The rest of what the directive reaches may still be wider than it needs. strict reports each of those, and -fix narrows it.

// account.go
package bank

//declscope:package
type account struct {
    id      int
    balance int
}

func accountDeposit(a *account, n int) { a.balance += n }
// ledger.go
package bank

func ledgerKey(a account) int { return a.id }
$ declscope ./...
account.go:7:2: field account.balance takes package scope from //declscope:package on account, but no use from another namespace is visible to declscope

After -fix:

//declscope:package
type account struct {
    id int
    //declscope:private
    balance int
}

Every enclosing directive is judged the same way.

The directive is on Judged one by one
A struct or interface type Each field, or each method name
A var, const or type block Each spec
The file Each declaration in the file, and each member of a type that states no scope

strict is opt-in. A new release must not add reports to a repository whose config did not change.

Tip

Convention puts private fields last, after the fields other namespaces read. The fix never reorders fields: order is observable through unkeyed composite literals, positional encodings, unsafe offsets and 64-bit atomic alignment. Move them yourself where none of those apply.

What strict leaves alone, and how its fix is placed

A declaration is reported only when the enclosing directive is what widened it. strict reads the same evidence as loose, and stays quiet wherever loose would.

Stays quiet on Why
An exported declaration It is package-scoped by exportedness alone
A declaration that states its own scope It answers for itself. A redundant //declscope:package there is a directive report
An embedded field It has no name of its own
Anything under defaults.unexported: package It would be package-scoped with no directive at all
Anything under a directive loose reports Deleting that directive is the advice already
One name of a, b int or var x, y when the other is used outside One directive would narrow both. Splitting the line is your call
A type with a member that is exported or used outside Narrowing the type would narrow that member too
A member of a type strict already reports The type's fix narrows it
Shape Fix
A declaration with a doc comment The directive goes under it, after a bare // line
a, b int or var x, y One directive, on the first name's diagnostic
A field that shares its line with another, as in a single-line struct The field is broken onto its own line first
Everything the directive reaches would be narrowed Withheld. The directive would bind nothing, and deleting it is the edit to make

A boundary fix on a type widens its members too. Under strict, the same fix narrows each member no other namespace uses, so one -fix run leaves nothing for strict to report.