capabilitylint

package
v0.0.7 Latest Latest
Warning

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

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package capabilitylint implements a narrow static-analysis rule for the typed capability escape hatch (github.com/dayvidpham/pasture/internal/codegen/ir): CapabilityID is a named string type (ir.CapabilityID string), not an opaque struct like ir.SemanticOperationID/ir.SkillID/ir.EffectID, so Go's own untyped-literal-assignability rule lets a raw string literal compile successfully as a DefineCapability/MustDefineCapability identity argument. The ir package's opaque struct-based ID types reject that shape at compile time; CapabilityID cannot, by the accepted contract's own required shape (`const CapabilityRenderDiagram CapabilityID = "..."`, a named string constant, not an opaque type). This package closes that gap with a syntactic rule instead.

Check is deny-by-default: at every call to ir.DefineCapability/ir.MustDefineCapability, the identity argument is rejected unless it is exactly one of three recognized-safe shapes:

  1. An *ast.Ident that resolves — via go/parser's identifier resolution (ast.Ident.Obj), which is scope-aware within the file, so a function-local variable or parameter that merely shares its name with a package-level const is correctly treated as a *different* object, not the constant — to a package-level `const ... CapabilityID = ...` (or `... ir.CapabilityID = ...`) declaration in the same file.

  2. An *ast.Ident that resolves to a *parameter* of the enclosing function or function literal, declared with type CapabilityID (or ir.CapabilityID), and never disqualified anywhere in that function's body (including inside a nested function literal that captures it — go/parser resolves a captured identifier to the SAME *ast.Object as its outer declaration): "forwarded verbatim" is enforced by three checks (see collectReassignedObjs), not merely claimed. A parameter is disqualified — for every use of that identifier in the function, not only uses after the disqualifying statement — the moment it is (a) the target of a plain or compound assignment (`id = ...`, `id += ...`); (b) the Key or Value of a `for ... = range ...` clause (a range clause using `=`, not `:=`, is a distinct *ast.RangeStmt reassignment shape, not an *ast.AssignStmt); or (c) had its address taken at all (`&id`) anywhere — conservative and syntactic: this checker cannot track what a resulting pointer is later written through, so taking the address at all forfeits the verbatim-forwarding claim, whether or not that pointer is ever actually used to mutate the value. A short variable declaration (`:=`, including a range clause's `:=` form) never disqualifies anything: it always introduces a new *ast.Object, separately rejected by rule 1's scope-aware resolution, not a reassignment of the parameter it may shadow.

    A parameter's type already constrains it to CapabilityID at compile time, and forwarding it without disqualification cannot itself introduce a raw-literal/computed bypass at this call site — exactly the accepted contract's own error-returning DefineCapability form, the sanctioned escape valve for "dynamic or user-supplied inputs" (MustDefineCapability's own required implementation, forwarding its id parameter to DefineCapability, is the canonical example of this shape, and — stated precisely — is the ONLY way to express that sanctioned dynamic path as module source this rule accepts cleanly: a bare local variable holding a dynamic value is denied by rule 1).

    Disclosed residual risk (deliberately accepted, not an oversight — and the sole disclosed residual on this rule; the range-clause and address-of disqualification checks above close every other empirically demonstrated in-body mutation shape): this allowance is scoped to ANY function or function literal anywhere in the module with a CapabilityID-typed parameter, not only ir.MustDefineCapability's own definition, and it is one hop only — Check inspects arguments only at call expressions whose callee is literally named DefineCapability/MustDefineCapability (see capabilityConstructorName), so a wrapper function's OWN call sites (e.g. `func Wrap(id CapabilityID, ...) { DefineCapability(id, ...) }; Wrap(CapabilityID("raw-literal"), ...)`) are never inspected — Wrap's body is lint-clean by this allowance, and Wrap("...") is invisible to Check entirely. Nothing in this package's module-wide gate test catches that either, since it is not a call to DefineCapability/ MustDefineCapability by name. This is an accepted trade-off, not a bug: restricting the allowance to only ir.MustDefineCapability's own definition would make the accepted contract's sanctioned dynamic path inexpressible anywhere else in real module source, which is worse. The backstop for this residual is runtime, not static: every identity — laundered through a wrapper or not — still passes DefineCapability's own validateCapabilityID check and the registry's duplicate/changed-contract conflict detection on every code path, and a CapabilityID-typed parameter is itself a reviewable, greppable choke point even when this lint cannot follow its callers. Only a genuinely dynamic, caller-supplied identity should ever take this shape — never wrap an otherwise-static identity in a forwarding function merely to silence rule 1; a static identity belongs in a `const ... CapabilityID = ...` declaration, not behind a parameter.

  3. An *ast.SelectorExpr (pkg.SomeCapabilityID) whose left-hand identifier both (i) matches one of the file's actual imported package names (derived from file.Imports: the import's explicit alias if present, otherwise the import path's last segment) and (ii) is not itself bound to a local declaration in scope at that point (an import qualifier's *ast.Object is nil, or — belt and suspenders — of Kind ast.Pkg; a shadowing local variable, parameter, or struct value always carries its own non-nil, non-Pkg Object) — a conservative allowance for legitimate cross-package reuse of an already-declared, correctly typed constant, which this single-file syntactic check cannot itself verify, made scope-aware the same way rule 1 is so an import name shadowed by an ordinary local (a common, unremarkable pattern for short names like "fmt" or "io") cannot launder an arbitrary struct-field selector as if it were that import.

Every other shape — a raw string literal (parenthesized or not), string concatenation or any other binary expression, an explicit CapabilityID(...) conversion, the result of an arbitrary function call, a struct-field or other selector that is not a genuine, unshadowed import qualifier, a non-parameter local variable (typed or not), a disqualified parameter (reassigned, ranged over with `=`, or address-taken), or any other expression — is a Finding, even when Go's own assignability rule for a named string type lets every one of those shapes compile successfully on its own.

Recognition boundary (separate from the argument-shape rules above): Check only recognizes a call expression as a lint target when its callee resolves, syntactically, to the literal name DefineCapability or MustDefineCapability (through parentheses and an explicit generic instantiation — see capabilityConstructorName). Function-value aliasing (`f := ir.MustDefineCapability[In, Out]; f("raw-literal", ...)`) is a known, deliberately out-of-scope evasion of this recognition boundary: it would require dataflow tracking of function values, not a syntactic check, and is explicitly not part of the accepted contract for this rule. The same runtime backstop (validateCapabilityID, registry conflict detection) still applies to it.

Check's rule itself is intentionally syntactic — go/ast/go/parser/go/token only, no go/types — and this package is also available as a standalone golang.org/x/tools/go/analysis.Analyzer (see Analyzer below) for use with go/analysis-based drivers (go vet -vettool, unitchecker, analysistest, etc.), which is how its own fixture corpus is now exercised (see analysistest_test.go). Its scope-aware resolution relies on go/parser's legacy (but still functional, and not removed) identifier resolution — the same resolution go/packages performs when it parses files for golang.org/x/tools/go/analysis drivers (see collectTypedConstObjs, collectCapabilityIDParamObjs, and collectImportNames for the precise mechanism and its limits), so Check's logic is unchanged: only the entry point (a *ast.File already parsed and supplied by a driver, via Analyzer's Run, rather than one CheckFile parses itself) is new.

Index

Constants

This section is empty.

Variables

View Source
var Analyzer = &analysis.Analyzer{
	Name: "capabilitylint",
	Doc: "reports ir.DefineCapability/ir.MustDefineCapability calls whose identity " +
		"argument does not resolve to a package-level typed const, a verbatim-forwarded " +
		"CapabilityID-typed parameter, or a qualified cross-package const reference; see " +
		"the capabilitylint package doc comment for the full deny-by-default rule.",
	Run:              run,
	RunDespiteErrors: false,
}

Analyzer is capabilitylint packaged as a golang.org/x/tools/go/analysis analyzer: it applies exactly the same deny-by-default rule as Check (see the package doc comment) to every file in the analyzed package, reporting one diagnostic per Finding via pass.Reportf. It requires no other analyzer's facts or results and performs no type-checking of its own — consistent with Check's syntactic, go/types-free design — so it can run standalone (analysistest, a single-analyzer unitchecker) or alongside any other analyzer set.

Functions

This section is empty.

Types

type Finding

type Finding struct {
	Pos     token.Pos
	Message string
}

Finding is one reported violation, with enough source position information to point a caller at the exact identity argument.

func Check

func Check(file *ast.File) []Finding

Check inspects one already-parsed file (see CheckFile's note on required parser.Mode) and returns every canonical-definition-site violation; see the package doc comment for the exact deny-by-default rule.

func CheckFile

func CheckFile(fset *token.FileSet, filename string, src any) ([]Finding, error)

CheckFile parses filename (src follows go/parser.ParseFile's src semantics: nil reads the file from disk; otherwise it may be a string, []byte, or io.Reader) and returns every canonical-definition-site violation Check finds.

It deliberately does not pass parser.SkipObjectResolution: Check's scope-aware resolution (see collectTypedConstObjs, collectCapabilityIDParamObjs, and the scope-aware branch of rule 3 in checkIdentityArgument) depends on go/parser populating ast.Ident.Obj.

Jump to

Keyboard shortcuts

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