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:shared rules.boundary on
qualify A name that does not carry its namespace Rename to prefix it rules.naming.* Off
surplus Shared scope with no visible use from another namespace Under strict, insert //declscope:private rules.surplus strict
unused An ignore that silenced nothing, or a scope directive that changes no scope Under strict, delete a redundant scope directive rules.unused strict
directive A directive that is malformed, unknown, conflicting or misplaced None No On
filter A filter.only that an only above it cancels None No On
overexported An exported declaration of an internal/ package that nothing outside its package uses Unexport it Running declscope shrink Not run by the analyzer

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:shared, 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

Members and the core namespace are explained in their own sections.

Declaration Example Reason
A member, or a method written beside its type
// user.go
type user struct{ name string }

func (u user) greet() string { return u.name }
Already qualified by that type at every use
func main in package main
// cmd/app/run.go
package main

func main() {}
A name the toolchain requires
A test function in a _test.go file
// user_test.go
func TestGreet(t *testing.T) {}
func BenchmarkGreet(b *testing.B) {}
func ExampleGreet() {}
The same. go test finds TestXxx, BenchmarkXxx, FuzzXxx and ExampleXxx by name
A declaration in a namespace that cannot start an identifier
// 2fa.go
func verify() bool { return true }
The fix prefixes, and no identifier begins with a digit. A namespace whose first letter has no capital form, such as 用户 or _foo, is skipped too: an exported name could not take it and stay exported
An exported identifier, unless rules.naming.exported is on
// user.go
func Greet() {}
How the API is spelled is the author's decision
A declaration in the core namespace
//declscope:core

package app

func open() {}
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.

The reverse is not generated: in tracing.go, traceValue is reported. The message names the vocabulary key to add.

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]
      tracing: [trace]

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. When the name opens with the namespace's last words, only the missing words are prefixed. When that name is taken, the fix prefixes the whole namespace.

Case Example Never
Unexported id → userID userId
Exported, under rules.naming.exported Load → UserLoad userLoad
The name opens with the namespace's last words FunctionCallExpr in ast_function_call_expr.go → AstFunctionCallExpr AstFunctionCallExprFunctionCallExpr

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

A method, or a type that is embedded, is renamed only when the new name is free on every type that holds it. A method is also not renamed when an interface of the package asks for its old or new name, since a type assertion would change its answer.

A package that holds a non-Go source file, such as assembly (.s) for any architecture, gets no rename fix. Assembly names Go functions by text, which the rename cannot follow, and the build would fail at link time. Rename by hand, or add //declscope:ignore qualify.

surplus

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

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

//declscope:shared
func normalizeEmail(s string) string { return s }
$ declscope ./...
email.go:3:1: //declscope:shared 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.

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:shared
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:6:2: field account.balance takes shared scope from //declscope:shared on account, but no use from another namespace is visible to declscope

-fix changes account.go:

 //declscope:shared
 type account struct {
-   id      int
+   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 the default. loose checks only directives as a whole; select it explicitly if that is the policy your repository wants.

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.