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.
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.
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 | id → userID |
userId |
Exported, under rules.naming.exported |
Load → UserLoad |
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.
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 |
$ 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 }
$ 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:
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.