molint

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 6 Imported by: 0

README ΒΆ

molint

CI Codecov Go Reference

Go linter that enforces samber/mo: absence is mo.Option, not a nil pointer or a trailing bool.

[!NOTE] The rules are strict on purpose. They are meant for applications that choose samber/mo, not for libraries.

Overview

func Find(name string) *User {
	u, ok := users[name]
	if !ok {
		return nil
	}
	return u
}

func Lookup(name string) (*User, bool) {
	u, ok := users[name]
	return u, ok
}

func Guest(o mo.Option[*User]) *User {
	return o.OrEmpty()
}
$ molint ./...
user.go:12:3: Find returns a nil *User; return mo.Option[*User] instead [return-nil]
user.go:17:6: Lookup reports absence with a trailing bool; return mo.Option[*User] instead [return-bool]
user.go:23:18: OrEmpty on mo.Option[*User] gives nil when it is empty; use Get and check ok, or OrElse with a non-nil value [unwrap-nil]

Every report ends with the name of its rule. That name is what //molint:ignore takes.

molint reads the shape of signatures, and follows values only inside one function. It does not look for nil panics. Use it beside uber-go/nilaway:

Tool Job Reads
uber-go/nilaway Finds possible nil panics The flow of values across functions
molint Enforces the use of mo.Option and mo.Result The shape of signatures and code

Install

Method Command Needs
mise (recommended) mise use "github:mpyw/molint@0.2.0" Nothing. Installs the prebuilt binary
go tool go get -tool github.com/mpyw/molint/cmd/molint@latest Go 1.27+
go install go install github.com/mpyw/molint/cmd/molint@latest Go 1.27+
Release archive See below Nothing
molint ./...
molint -V=full   # the release this binary was built from
Pin a version, run through go vet, or install from an archive

mise use pins the version in the project's mise.toml, so every checkout and CI run the same one. Add -g to install it for every project on your machine instead.

[tools]
"github:mpyw/molint" = "0.2.0"

As a tool dependency in go.mod:

go get -tool github.com/mpyw/molint/cmd/molint@latest
go tool molint ./...

Through go vet, which runs it with the same package loading as the rest of your vet checks, and caches it:

go vet -vettool=$(which molint) ./...

Without installing anything:

go run github.com/mpyw/molint/cmd/molint@latest ./...

From a release archive, verified against the published checksums:

VERSION=0.2.0
curl -LO "https://github.com/mpyw/molint/releases/download/v${VERSION}/molint_${VERSION}_darwin_arm64.tar.gz"
curl -LO "https://github.com/mpyw/molint/releases/download/v${VERSION}/checksums.txt"
shasum -a 256 -c checksums.txt --ignore-missing
tar xzf "molint_${VERSION}_darwin_arm64.tar.gz"

Rules

Rule Default Reports
return-nil 🟒 On A nil pointer result
β†’ Use mo.Option instead
return-bool 🟒 On A signature that ends in a bool after other results
β†’ Use mo.Option instead
return-error πŸ”΄ Off A signature that ends in an error after other results
β†’ Use mo.Result instead
field-nil-store πŸ”΄ Off nil stored into a pointer field
β†’ Make the field mo.Option
field-nil-compare πŸ”΄ Off A pointer field compared with nil
β†’ Make the field mo.Option
wrap-nil 🟒 On nil given to mo.Some, mo.Ok, or mo.Err
β†’ Use mo.None, or pass a non-nil value
result-zero 🟒 On A zero mo.Result
β†’ Build it with mo.Ok or mo.Err
unwrap-nil 🟒 On OrEmpty, or OrElse(nil), where the nil breaks on use:
pointer, interface, map, func or chan
β†’ Use Get and check ok, or give OrElse a non-nil value
unwrap-discard 🟒 On Get with its ok or its error discarded
β†’ Check it, or use OrElse

The line between them: absence must not be dropped silently. A trailing bool and a discarded ok drop it silently, whatever the type. OrEmpty and OrElse choose a default in plain sight. That is fine, unless the default is nil.

Each rule has a flag of its name:

molint -return-error -field-nil-store -field-nil-compare ./...  # every rule
molint -return-bool=false ./...                                 # every rule on by default, except return-bool

Nothing is reported in a generated file. Test files are checked like any other file.

return-nil

A return must not give a nil pointer. Only pointers are checked: a slice, a map, a func, a channel or an interface may still be returned as nil.

CodeValid?Reason
func F() *T {
	return nil
}
❌ The result is a nil pointer
func F() *T {
	var p *T
	return p
}
❌ p is never set, so it is nil
func F() (*T, error) {
	return nil, nil
}
❌ Both the pointer and the error are nil
func F() (*T, error) {
	return nil, ErrNotFound
}
βœ… The error is not nil
func F() (*T, bool) {
	return nil, false
}
⚠️ Not reported by this rule. return-bool reports the signature
func F() (*T, bool) {
	return nil, true
}
❌ ok says the value is there, but it is nil
func F() (T, bool) {
	return T{}, false
}
⚠️ Not a pointer, so not reported by this rule. return-bool reports the signature
func F() (T, error) {
	return T{}, nil
}
βœ… Not a pointer, so this rule does not apply
func F() []T {
	return nil
}
βœ… A nil slice is Go's empty slice, and nothing claims that a value is present

A nil is followed through branches and loops in the function. A nil check on the way stops it:

var p *T
if c {
	p = get()
}
if p == nil {
	p = def
}
return p // not reported

A function literal is exempt. So is a method that implements an interface, since the interface fixes its signature.

Fix
func F() mo.Option[*T] {
	if !found {
		return mo.None[*T]()
	}
	return mo.Some(&T{})
}

return-bool

A signature must not end in a bool after at least one other result.

SignatureValid?Reason
func Find() (User, bool)
❌ Absence is a trailing bool, whatever type comes before it
func Cut() (string, string, bool)
❌ Absence is a trailing bool, after several values
func IsAdmin() bool
βœ… Nothing comes before the bool

Functions, methods, methods of named interfaces, and named function types are checked. A function literal is exempt. So is a method that implements an interface: the interface's own declaration is reported instead, when it is in the package.

Fix
func Find() mo.Option[User]
func Cut() mo.Option[lo.Tuple2[string, string]]

For several values, use a struct, or a tuple of samber/lo such as lo.Tuple2.

[!NOTE] A method is exempt only when molint sees the interface. It must be declared in the package, or in a package that the package imports directly. MarshalJSON in a package that does not import encoding/json is not exempt.

return-error

The same as return-bool, with error in place of bool. It is off unless -return-error is set.

SignatureValid?Reason
func Find() (User, error)
❌ Failure is a trailing error
func Load() (Config, Meta, error)
❌ Failure is a trailing error, after several values
func Close() error
βœ… Nothing comes before the error
Fix
func Find() mo.Result[User]
func Load() mo.Result[lo.Tuple2[Config, Meta]]

field-nil-store

A nil must not be stored into a pointer field. It is off unless -field-nil-store is set.

[!IMPORTANT] Turn it on together with exhaustruct. A field left out of a composite literal is not seen by molint. exhaustruct makes you write it, as in F: nil, and then molint reports it.

CodeValid?Reason
u := User{Name: n, Manager: nil}
❌ The field holds nil, so it may be absent
u.Manager = nil
❌ The field holds nil, so it may be absent
var m *User
if ok {
	m = x
}
u.Manager = m
❌ m is nil when ok is false
u.Manager = find(id)
⚠️ Not reported by this rule, since a call's result is not followed. If find returns nil, return-nil reports it there

Only fields declared in the package count. An embedded field and a field in a generated file do not.

Fix
type User struct {
	Name    string
	Manager mo.Option[*User]
}

u := User{Name: n, Manager: mo.None[*User]()}

[!NOTE] A struct filled after it is built is reported too. u := User{Name: n, Manager: nil}; u.Manager = m reports the literal. Compute m first, then build User with it.

field-nil-compare

A pointer field must not be compared with nil. A nil check says the field may be absent. It is off unless -field-nil-compare is set.

CodeValid?Reason
if req.Name != nil {
	user.Name = *req.Name
}
❌ The field may be absent
switch u.Manager {
case nil:
	return guest
}
❌ The field may be absent
if c.client == nil {
	c.client = dial()
}
❌ The field is absent until its first use
if u.Manager == boss {
	notify(u)
}
βœ… It is not compared with nil

It does not need exhaustruct. It also finds a field that only a decoder such as json.Unmarshal leaves nil. The fields that count are the same as for field-nil-store.

Fix
type UpdateRequest struct {
	Name mo.Option[string] `json:"name"`
}

if name, ok := req.Name.Get(); ok {
	user.Name = name
}

For a field set on first use, use sync.OnceValue, or make it an mo.Option.

wrap-nil

CallValid?Reason
mo.Some[*T](nil)
❌ The option is present and holds nil
mo.Ok[*T](nil)
❌ The result is Ok and holds nil
mo.Err[T](nil)
❌ The result is an error, and its error is nil
mo.Some[map[string]int](nil)
❌ The option is present, and writing to its map panics. A nil func panics when called, and a nil channel blocks forever
mo.Some[[]int](nil)
❌ Some claims that a value is present, but it holds nil. It then encodes in JSON as null, as mo.None does
mo.Some([]int{})
βœ… An empty slice is not nil, and encodes as []
Fix
mo.None[*T]()          // the value is absent
mo.Some(&T{})          // the value is there, and not nil
mo.Err[T](ErrNotFound) // a failure, with a non-nil error

result-zero

A zero mo.Result is Ok, holding the zero value of its type. It must not be used.

func Load() mo.Result[Config] {
	var r mo.Result[Config]
	return r // reported
}

A return, an argument, a store, a send, and a method call are uses. A comparison is not. A zero mo.Option is None, which is fine, so it is not reported.

Fix
func Load() mo.Result[Config] {
	cfg, err := read()
	if err != nil {
		return mo.Err[Config](err)
	}
	return mo.Ok(cfg)
}

unwrap-nil

CallValid?Reason
var o mo.Option[*User]
o.OrEmpty()
❌ It gives nil when the option is empty
var o mo.Option[*User]
o.OrElse(nil)
❌ The fallback is nil
var o mo.Option[*User]
o.OrElse(&guest)
βœ… The fallback is not nil
var o mo.Option[*User]
o.MustGet()
βœ… It panics rather than give nil
var n mo.Option[int]
n.OrEmpty()
βœ… The zero value works, and is chosen in plain sight
var m mo.Option[map[string]int]
m.OrEmpty()
❌ It gives a nil map when the option is empty, and writing to it panics
Fix
if u, ok := o.Get(); ok {
	use(u)
}

// Or, with a fallback that is not nil:
u := o.OrElse(&guest)

unwrap-discard

CodeValid?Reason
v, _ := o.Get()
use(v)
❌ ok is discarded, whatever the type of v
var r mo.Result[int]
v, _ := r.Get()
use(v)
❌ The error is discarded
if v, ok := o.Get(); ok {
	use(v)
}
βœ… ok is checked
o.Get()
βœ… Nothing is used
Fix
if v, ok := o.Get(); ok {
	use(v)
}

// Or, for a mo.Result:
v, err := r.Get()
if err != nil {
	return err
}

// Or, with a fallback:
v := o.OrElse(10)

Agent skill

skills/molint-authoring is a skill for an AI agent writing code under molint. It says how to fix each rule with samber/mo, which fixes only hide a problem, and when an ignore is right. The binary carries it:

molint skill install                                # the agents already set up in this project
molint skill install --agent claude-code --scope user
molint skill list                                   # where it is, and whether it is current

Without the binary, gh skill install mpyw/molint molint-authoring --agent claude-code writes to the same directories. The installer is go-skill-embed.

Ignoring a report

Write //molint:ignore with the rules to silence and a reason after //.

//molint:ignore return-bool // callers rely on the comma-ok form
func Lookup(name string) (*User, bool) {
Placement Silences
On a line of its own The line below
After code Its own line
Directive Result
//molint:ignore return-nil, wrap-nil // reason Silences both rules
//molint:ignore // reason Silences every rule
No reason Reported, and silences nothing
An unknown rule name Reported
An ignore that silences nothing Reported as unused, unless every rule it names is turned off

Limits

These are not checked:

Case Why
A nil from a parameter, a field, or a call Values are followed only inside one function. uber-go/nilaway follows them further
mo.TupleToOption, mo.TupleToResult, mo.EmptyableToOption They check their arguments at run time
A field left out of a composite literal, as in Holder{} SSA has no store for it. Run exhaustruct to make every field written
A struct made zero by var s S, new(S), or a decoder Fields are not followed. field-nil-compare still finds a field checked for nil
A field of a struct declared in another package molint cannot tell whether that package's file is generated
The initializer of a package-level variable No rule reads the package's initializer
A constructor or method of mo passed as a function value Calls through function values are not followed

These are reported although no run returns nil, since branches are taken as independent:

Case Instead
if c { p = x }; if c { return p } Keep the value and its condition together, as in mo.Option
The pointer and the error set on separate branches, then if err != nil { return nil, err }; return u, nil Return from each branch
A retry loop that ends with return nil, err Start the error at a sentinel, so that no round leaves it nil

The full specification, with every limit, is design/rules.md.

License

MIT

Documentation ΒΆ

Overview ΒΆ

Package molint enforces the use of github.com/samber/mo.

Absence is mo.Option, and failure may be mo.Result, rather than a nil pointer, a trailing bool, or a trailing error. An Option or a Result must not hold or give a nil it should not. The rules are listed in design/rules.md, and each has a flag of its name.

Index ΒΆ

Constants ΒΆ

This section is empty.

Variables ΒΆ

View Source
var Analyzer = &analysis.Analyzer{
	Name:     "molint",
	Doc:      "enforces the use of github.com/samber/mo",
	URL:      "https://github.com/mpyw/molint",
	Requires: []*analysis.Analyzer{buildssa.Analyzer},
	Run:      runAnalyzer,
}

Analyzer enforces the use of github.com/samber/mo.

View Source
var Skills = skillembed.MustSkillsFromFS(skillsFS, "skills")

Skills are the skills this module carries: molint-authoring, for writing code under molint.

It is declared here rather than beside the command because a //go:embed path cannot leave its own directory, and skills/ sits at the repository root. It sits there because that is where `gh skill install` looks, which is the other way to reach it.

Functions ΒΆ

This section is empty.

Types ΒΆ

This section is empty.

Directories ΒΆ

Path Synopsis
cmd
molint command
Command molint enforces the use of github.com/samber/mo.
Command molint enforces the use of github.com/samber/mo.
Package internal implements the molint rules.
Package internal implements the molint rules.
directive
Package directive reads the //molint: comments in a package.
Package directive reads the //molint: comments in a package.
flow
Package flow follows a value back through one function, to tell whether it is a constant that a rule looks for: a nil pointer, a nil error, the constant true, or a zero mo.Result.
Package flow follows a value back through one function, to tell whether it is a constant that a rule looks for: a nil pointer, a nil error, the constant true, or a zero mo.Result.
nilcheck
Package nilcheck reads what the branches above a point say about a value being nil.
Package nilcheck reads what the branches above a point say about a value being nil.
rule
Package rule names the rules of molint.
Package rule names the rules of molint.
typeutil
Package typeutil holds the type questions every rule asks, and the way a diagnostic spells types and declarations.
Package typeutil holds the type questions every rule asks, and the way a diagnostic spells types and declarations.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL