Skip to content

test(lintrules): refuse writes to pipeline package variables - #1447

Open
fuad-daoud wants to merge 26 commits into
mainfrom
test/pipeline-global-writes
Open

fuad-daoud wants to merge 26 commits into
mainfrom
test/pipeline-global-writes

Conversation

@fuad-daoud

@fuad-daoud fuad-daoud commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

In short: No pipeline stage may keep mutable package-level state (invariant 5), yet a stage writing a package variable passed vet, lint and the tests, and only the race detector could catch it, when a run happened to reach it. This adds a globalwrites analyzer to make vet that refuses such a write in ir/, pass, compilers/ and emitters/ outside init.

Adds the globalwrites analyzer to internal/lintrules, run by make vet through cmd/morphic-vet. In a package under ir/, pass, compilers/ or emitters/, production code may not write a package-level variable, its own or another package's, outside init. A stage holds no mutable state (invariant 5), which engine.Engine's concurrent use rests on, and neither reassign, vet nor the tests saw a counter kept in one. Closes #1446. Related, left open: #1316 (exported mutable keyword slices): the rule refuses a write to them from any package that can import them, but a callee such as slices.Sort still can, and that issue asks that they stop being handed out.

What it refuses, directly or through a local map, slice or pointer that holds the variable (named as the target of :=, =, a var or a range clause, or by a return into a named result): an assignment, a compound assignment, ++ and --, a write to an element or field, taking the address, a method call ((*T).M(v) included) or method value whose receiver is a pointer, map or slice, and delete, clear, copy or append on it. One receiver is listed and stays legal, *encoding/base64.Encoding, which is read-only; an unlisted receiver draws until it is listed, *regexp.Regexp included, since its Longest and UnmarshalText write. At 0d09a96d no production package under the roots imports regexp, and the module's only call sites on the listed receiver are compilers/openapi/internal/schema/enum.go:248 and :278.

Where. The body of every function and method but init. In a declaration at package level or in init, which run once, each outermost function literal, each go statement and each selector not called there, as a method value is, since any of them can outlive initialization. What lies beneath such a selector is checked with it, so a method called there draws though it runs once. A statement written in init itself stays legal, and a stored method value or a go statement does not (storedInit = ledger.Add and go ledger.Add() draw; ledger.Add() and defer ledger.Add() do not, as both run inside init; defer func() { ledger.Add() }() draws, being a function literal, though it too runs inside init). gochecknoinits refuses an init function in production code as such, but not what a statement in one writes, so that verdict is this rule's alone. Aliases are found over a whole declaration, so a local bound in init and written by a literal init stores draws (m := props; hook = func() { m["n"]++ }), and so does go q.Add() through its alias.

The cost. A selector is checked on its syntax, not on whether it runs again, so var v = ledger.Self().n draws although Self runs once, and so does the same expression in init. The remedy is the rule's own: a method with a value receiver, or state kept in a local. A local is read without regard to order or path: one that holds the variable on any path holds it throughout, so if x { return names }; m = map[string]int{}; m["a"] = 1 draws at the last line, and the remedy is a separate local.

Out of reach. The rule reports writes it can see syntactically: a direct write, an increment, a pointer-receiver, map or slice method, and code init stores or starts. Any other way code bound at initialization can run later and write is out of reach by design, and a further form of that kind is a stated limit, not a defect. No issue tracks these. It reads one package's syntax and no callee's body, so:

  • A variable passed to a call or returned to a caller (slices.Sort(v), helper(v)), whose callee may write it. A named result a return binds to the variable is not this case: a write through it in the function's own body draws.
  • A method called at package level or in init that keeps its receiver, as one returning a closure does (var f = ledger.Bind()).
  • A method on a struct or array value or an interface, though it can write through a map, slice or pointer the value holds.
  • An alias held in a struct, array or interface, in an element of a slice or map or behind a pointer (a literal, s[0] = v or *p = v puts it there), or reached through a conversion, a type assertion or a function result; an alias across functions or packages.
  • A write through reflect or unsafe after the address is taken, and a channel send or close.
  • Code outside the roots, test files included.

Before and after

flowchart LR
  subgraph before["Before"]
    direction TB
    b1["a counter in pass, written from a stage"] --> b2["make vet, lint and go test: clean"]
    b1 --> b3["race detector: only if two tests<br/>overlap on that path in that run"]
  end
  subgraph after["After"]
    direction TB
    a1["a counter in pass, written from a stage"] --> a2["make vet: globalwrites refuses it<br/>on the pull request, every time"]
    a3["make coverage's race detector"] -. "still races what this cannot see,<br/>if a test overlaps it" .-> a2
  end
  before ~~~ after
Loading

What it brings: invariant 5 is enforced at the line that breaks it, on every pull request, without running a test. This is the static half of the narrower race run #1311 proposes, and it holds whether or not that lands: the common way to break the invariant no longer depends on a race detector run reaching that path.

Test plan

  • go test ./internal/lintrules ./cmd/morphic-vet at 100.0% coverage. The plants under testdata/mod hold the verdicts, the scope edges and the legal reads of the rule's forms, and these of the forms its comments state it cannot see: a channel send or close, a conversion, a type assertion, a callee given the variable by a call, a function's result, a method at package level that keeps its receiver, a method on a struct value holding a map, and an alias held in a literal, in an element, in a copied struct or array or behind a pointer. Widening the rule past one of those, as its plant spells it, reddens TestAnalyzers_DrawExactlyThePlantedFindings. Not every stated limit or legal form has a plant: a method on an array value holding a map, a variable passed to a go statement or to reflection, and &, delete and the other writing builtins written in init itself all draw nothing at f21dc849, and a change that made one of them draw would leave that test green. Of 120 single-site mutants of globals.go on 8d083c61, planted one at a time, 4 did not compile, 5 panicked the run, 102 failed that test and 9 survived. One survivor is a fixed cap of four passes on the fixed point, the hops ChainAlone plants: a five-hop chain tells it apart, and a plant that long would only let a cap of five survive in its place. The others are the classes no input tells apart: one alias map shared across declarations, whose locals are each one declaration's; a function literal walked from its parent, whose nodes are the same; the guards on := targets; a nil-package guard the type checker leaves no input for; the fixed point's early exit; a target beyond a comma-ok pair; and ranging over a basic type, whose key and value hold no reference. A mutant outside those 120, among them one that only moves where a finding lands, since the test reads a finding's line and not its column, is not covered by this count.
  • go vet -vettool=<tool> -globalwrites ./ir/... ./pass/... ./compilers/... prints nothing: the module is clean.
  • lint: refuse writes to package-level variables in pipeline packages #1446's probe (var danglingProps int and danglingProps++ in checkPropIDRefs's loop) fails make vet; with new(atomic.Int64) and .Add(1), with a counter written by a literal an init stores, with a local bound in init that such a literal writes through, with a method value init stores (danglingBump = danglingCount.Add, a new(atomic.Int64)) that the loop calls, or with a method started by go in init, it fails too.
  • CI on this head, main at 2a0996a6 merged in, runs make gate. On f21dc849, go build ./..., go test on internal/lintrules, cmd/morphic-vet and internal/archtest, make vet, make lint, make lint-planted and skills_lint.py each pass.

Add the globalwrites analyzer, run by make vet through cmd/morphic-vet. In
a package under ir, pass, compilers or emitters, production code may not
write a package-level variable, its own or another package's, outside an
init function. A stage holds no mutable state (invariant 5), which
engine.Engine's concurrent use rests on, and neither golangci-lint's
reassign, vet nor the tests saw a counter kept in one.

It refuses an assignment, a compound assignment, ++ and --, a write to an
element or field, taking the address, calling a pointer-receiver method on
a value, and delete, clear, copy or append on the variable, directly or
through a local map, slice or pointer that holds it. A function literal in
a package-level declaration is checked, as it can run after
initialization. Reads stay legal: error sentinels, lookup tables, a
pointer variable's methods.

Out of reach, as the rule reads one package's syntax: a variable passed to
a call or returned, an alias held in a struct, array or interface or
carried across functions, a write through reflect or unsafe, and a
channel send. Plants under testdata/mod hold each verdict, each scope edge
and what the rule leaves alone.
Every write through a bare local alias now draws. Taking the address of
an alias (`_ = &m`) and calling a pointer method on one (`l.Push(1)`,
with l a local holding a named slice) were skipped as rebindings, but
each hands out the local's value, which is the variable's. Only
assigning to the alias stays a rebinding, since it replaces the local
and writes nothing the variable holds.

A binding whose target is parenthesised, `(m) = names`, now makes the
alias too.

The pointer-method limit is stated rather than changed. The first
commit's body lists a pointer variable's methods among the reads that
stay legal. That is wrong: such a method, and a value-receiver method
on a named map or slice, is out of reach and can write, so a counter
behind new(atomic.Int64) passes. pointerMethod says so, and Methods
plants the named-map and pointer cases with no finding marked.

Four distinctions no plant held now have one: a pointer-typed range key,
the first target of a multi-assignment, a received channel value that
must not alias, and a method declared on an alias of a pointer type.

The limits the code states grow: a channel send or close, and a value
returned or passed to a call or a goroutine, are named in check, and
the analyzer's comment lists check and addressOf with the other
functions that own a limit. The default arm of rooted is written out.

docs/architecture.md no longer says every package-level write is held:
only one in a form the package's own syntax shows.
A method call or method value on an operand rooted at a package-level
variable now draws when the method's receiver is a pointer, map or
slice, through which it can write what the variable holds. A counter
kept as var n = new(atomic.Int64) with n.Add(1), and a named map's
Set, are refused as var n atomic.Int64 already was. The two receivers
known to be read-only, *regexp.Regexp and *encoding/base64.Encoding,
stay legal; an unlisted one draws until it is listed. A method on a
struct value or an interface is out of reach, though it can write
through a map, slice or pointer the value holds. The verb reads "calls
a method that can write".

The body of init stays exempt, but a function literal in it is now
checked like one in a package-level declaration. init can store it in
a variable that a later call runs, so a write in it can race.

Three distinctions that no plant held now have one: a range over a
slice of slices, the first target of a multi-assignment, and the first
of two bindings of one local winning. The plant for a multi-assignment
now writes through both targets, as the earlier commit's body claims.

The alias limit also names a slice or map literal holding the variable.
globalwrites found a function's aliases only under each root it
checked, so a local bound in init and written by a function literal init
stores was never seen as an alias. Aliases are now found over a whole
declaration, so that write draws, and its root checks against them.

A method value is a write that outlives initialization, as a function
literal is: `hook = ledger.Add` in init, or `var f = ledger.Add`, can be
called per request. Each selector not called in a package-level
declaration or in init is now checked, so a stored method value draws,
and so does what lies beneath it, a method called there included, though
it runs once. A call written in init itself stays legal.

A named slice's value-receiver method, a receiver the rule reads as
writing through the slice, had no plant; it has one now.

pointerMethod's comment states that regexp's Longest and UnmarshalText
write and stay legal, since *regexp.Regexp is listed whole and no
production file uses it. Its other stale and unwrapped comments are
corrected.
A method or function started by a go statement in init runs after
initialization, as a function literal or method value init stores does,
so `go ledger.Add()` in init races with a later write and now draws,
through the declaration's aliases as any root does. defer and a call
written in init itself stay legal, since they run inside init.

*regexp.Regexp is no longer a read-only receiver: Longest and
UnmarshalText write it. No production package under ir, pass or
compilers imports regexp, so nothing in the module moves; only
*encoding/base64.Encoding stays listed. A regexp method on a package-level
variable draws.

A parenthesised call in init, `(ledger.Add)()`, now has a plant, which
the Unparen in afterInit was missing.

The limits are stated together under one heading on globalWrites: code
init stores or starts is reached, and any other way code bound at
initialization runs later and writes is out of reach, a callback handed
to another package, a value kept in a struct field and called elsewhere,
and reflection among them. afterInit adds that a method called at
package level or in init that keeps its receiver, as one returning a
closure does, is out of reach too.
The globalwrites plants did not hold several distinctions the rule
makes, so each could change unseen. They now do:

- a go statement's arguments are checked, not only its callee
  (`go func(f func()) { f() }(ledger.Add)` draws);
- a parameter named like a builtin (`clear`) is no write, since the
  builtin is found by what the identifier resolves to;
- defer in init runs there and stays legal;
- a pointer method of a type named Encoding in another package draws,
  as only *encoding/base64.Encoding is listed;
- a local named like an import is an alias, not a package qualifier.

globalWrites' comment names engine.Engine's concurrent use again, the
guarantee invariant 5 serves.
A cap on the alias fixed point's passes below the three hops Chain
now binds in reverse order went unseen: only two hops were planted.

Kinds of local, receiver and range the rule leaves out were unplanted
too, so a widening of aliasable, ranged or the receiver check passed
every test. Planted, none drawing a finding:

- a read of the error sentinel's method, and the address of copies of
  a func, a chan, an error and an int local;
- a range over a channel handing out slices;
- methods with value receivers on a named array, int, chan and func
  type, called on package-level variables of them.
Three comments in the pass plant said "every" where the plants hold a
named few: the named types that read only, the paths by which Elements
writes, and the kinds ReadsNamedKinds calls on. Each now names what the
plants hold. Each edit keeps its line count, so no marker line moves and
no finding with it.
@fuad-daoud
fuad-daoud marked this pull request as draft November 5, 2026 20:30
@fuad-daoud
fuad-daoud removed the request for review from OmarAlJarrah November 5, 2026 20:30
The drawn-findings set folds repeats, which a package's test variants
need, so a rule walking one node twice drew the same finding twice and
the plants could not tell. Count each finding by column within one
analyzer run, and plant the chained field selection that reaches the
selector walk.
A call's Fun is an IndexExpr or IndexListExpr when the method is
instantiated, so afterInit never saw the selector as called and drew a
call in init as a stored method value. Strip the type arguments where a
call is recorded, and plant the three spellings beside a stored
instantiation, which must still draw.
Rename globalScope to globalCheck, as its siblings are named, with the
receiver c and a comment that says what it holds. A method value nothing
calls drew "calls a method", so the message says "uses", and
pointerMethod, which also judges map and slice receivers, becomes
writingMethod. Find a builtin through typeutil.Callee as the registry
and service-tree rules do.
A method expression called as (*T).M(v) is v.M() with the receiver as its first argument, and drew nothing where the selector spelling drew. The receiver is the first argument, so check it as the operand of the selector. A method expression not called is a function value, as any other is.
A callback handed to another package and a function value stored in a struct field are reached when the code they hold writes. What stays out of reach is a closure a method returns, and reflection.
The globals rule reads the callee of a method expression through
calleeOf, which strips parentheses and type arguments. No plant held
either spelling, so replacing the call with call.Fun, with
ast.Unparen(call.Fun), or with a strip of type arguments alone left
TestAnalyzers_DrawExactlyThePlantedFindings green.

Plant (*T).Gen[int](v), (*T).Two[int, string](v) and ((*T).Add)(v), each
writing a package-level variable through its receiver argument.
globalwrites found a local that holds a package-level variable by :=, =,
a var or a range clause, and not by a return into a named result. A
deferred literal writing that result wrote the variable and drew
nothing. Each named result now receives what a return in its own body
gives it; a bare return binds nothing, and a literal's returns bind the
literal's results.

Like every binding, it is read without order: a result that holds the
variable on one path holds it throughout, and the remedy is a separate
local.
Five edits to the rule left TestAnalyzers green though real input
tells each apart: the method-expression guard, the alias test on a
method expression's receiver, ranging over a named slice and over a
named pointer to an array, and a function named like init. Each now
has a plant beside the others.
The lint holds a write its own package's syntax shows, but it does not
follow a conversion, a type assertion or a function literal called on the
variable. The sentence now says direct or through a local alias, bar the
forms the rule states out of reach.
A range clause's =, a comma-ok binding by = and by var, a compound write, clear, copy and append through an alias, copy of a slice of a variable, clear of an element and a range over an array of slices were each drawn by the rule and held by no plant, so a mutant that stopped drawing any of them left the rule's test green.
A channel send or close, a conversion, a type assertion, and an alias held in a literal, an element or behind a pointer are stated out of the rule's reach and drew nothing, with no plant to fail if the rule widened past its comments.
The rule's comments state six limits that no plant held: a callee given
the variable (slices.Sort, a function here), a method called at package
level that returns a closure keeping its receiver, a function's result,
a method on a value holding a map, and an alias held in a copied struct
or array. Widening the rule past any of them left
TestAnalyzers_DrawExactlyThePlantedFindings green.

Plant each as a line that must draw nothing, so widening past one
reddens that test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint: refuse writes to package-level variables in pipeline packages

1 participant