This page describes how the vow analyzer implements the
contracts the DSL reference declares. It is
aimed at contributors and at integrators who want to understand the
guarantees the analyzer makes (and the ones it intentionally does
not).
The analyzer runs once per package and walks the source files in a fixed order:
- Preset discovery.
runFunciterates the configured presets. Each preset names a subject scheme and an obligation type. - Subject selection.
findSubjectswalks the package and returns the set oftypes.Objects that satisfy the subject pattern. The current implementation recognises theannotation:scheme on package-levelvardeclarations. - Obligation dispatch. For each obligation, the matching rule
engine runs.
must-consumedispatches toreportMustConsumefollowed byreportSSALeaks. - Condition enforcement.
checkReturnAnnotationsruns once per package, irrespective of the preset list — everyvow:condline is parsed into a unifiedConditionAST (parameter requirement + return requirement) and the matcher dispatches per case. The contract is intrinsic to a function rather than tied to a particular subject set. The condition AST also represents logical-arrow rules (=>and<=>) so authors spell relational constraints with identifier-named references and postfix steps;checkLogicalRuleReturnsevaluates each logical rule at every return statement of the declaring function, andvalidateCallerLogicalCondsevaluates the same rules at every call site by binding operands to the call's arguments and receiver. The evaluator commits operands through three layers: a literal-shape match inevaluateNilCheck/evaluateComparison; an SSA-driven refinement inssaNarrowedNilCheckthat consultsresolveArgNilnessfor alias / parameter / field nilness and walks dominatingif param != nilguards throughnarrowedByDominatingGuard; and a derived-rules layer (groupRulesWithContrapositives) that pairs every implication with its contrapositive (contrapositiveOf) and expands a depth-bounded chain closure (expandTransitives) that joins implications whose intermediate operand matches. An original-first walk keeps the closure and the contrapositive silent whenever the source rule already committed, so the same offending claim never reports twice. AconditionLogicalFactcarries each function-doc rule across the package boundary so an importing call site enforces the same contract as the same-package path.validateSubjectScopedLogicalCondsandvalidateSubjectScopedEmitscover the[subject]scope: a logical rule retargeted at a callback parameter validates its references against the callback's own signature scope, and an emission scoped to a callback validates that the subject is function-typed.validateCondAnnotationResolutionguards the step before all of these: the parse path drops a line it cannot turn into a condition, so a@alias.Rulereference whose lookup fails would otherwise disappear without a diagnostic. Only lookup failures report, which leaves the prose-marker shape (a syntax failure) as silent as it was.
The analyzer requires inspect.Analyzer (for AST walks) and
buildssa.Analyzer (for the SSA fallback) via Requires.
classifyReference decides the fate of every subject reference
from the AST stack — the inspector's stack from the file root
(index 0) down to the identifier itself. The classifier is
ordered:
isObserveUsechecks for observation patterns inside a conditional context: observer call (errors.Is(err, X)),==/!=against the subject, or acase X:in aswitch.isChainUsechecks for authorised propagation: the reference must sit inside aReturnStmtwhose enclosingFuncDecl(orFuncLitplus enclosingFuncDecl) carries a trivial-parameter-requirement case (avow:condline, equivalently avow:cond * -> ...) whose return requirement lists the subject. Cases with a non-trivial parameter requirement stay conservatively gated for chain authorisation — their narrowed scope is not threaded through the classifier.- Anything else is
classLeakand reported.
Observation wins over chain by design: if a reference is observed, the chain check is not consulted.
Two complementary passes detect leaks at different shapes:
For every subject identifier reference in the package, the walk
classifies the reference via the AST stack. isAliasBindingRHS
suppresses the RHS of := defines so a sentinel introduced by an
alias is not double-counted. The walk handles direct references
(return ErrFoo) and one-hop aliases (x := ErrFoo; return x)
through resolveSubject, which delegates to the
stack-independent resolveSubjectInFunc.
For each return <ident> site whose AST resolution does not name a
subject, the SSA pass locates the matching *ssa.Return by source
position, walks the result value backward through phi nodes and
trivial loads (UnOp(MUL)), and reports any reachable
*ssa.Global that lies in the subject set.
The SSA pass adds coverage for shapes the AST walk cannot reach:
- Multi-hop alias chains.
x := A; y := x; return y— the one-hop AST resolver stops atx; SSA walks the chain toA. - Reassignment after a non-subject init.
e := f(); e = A; return e— the single:=define has a non-Ident RHS, so the AST resolver gives up; SSA carries the subject forward. - Branch merges.
var e; if c { e = A } else { e = B }; return e— the AST resolver finds no:=define fore; the SSA phi flattens to{A, B}.
The SSA pass intentionally consults functionAuthorizesPropagation
before reporting so a vow:cond signature that lists the
subject discharges the diagnostic even when SSA detected the flow. Position-based
*ssa.Return mapping (r.Pos() == astRet.Pos()) works without
ssa.GlobalDebug.
exprShape.matchesTermName compares an annotation Term against a
return expression on these axes, in order:
- Syntactic identifier (
Foo,pkg.Foo). - Fully qualified name from
types.Info(module/path.Foo). - Named type as
types.Type.Stringprints it. Surfaces true aliases (type X = errormakes typeNameerror). - Package-relative type, which is how an annotation inside
the package under analysis spells it (
*Foo, not*pkg.Foo). - Package-name-qualified type, which is how an annotation
spells a type its file had to import (
*pkg.Foo). The named type renders the import path instead, so an imported type reaches the match only here. - Underlying type after one
Underlying()step. Reaches the structural form (type Bytes []byte→ underlying[]byte).
Earlier axes win first, so short-form annotations continue to match through axis 1 even after types-aware matching is enabled.
A return expression that names no identifier — &T{...}, a
constructor call — carries only a type, so shapeOf returns it as
shapeTyped and the comparison narrows to the type axes. Tuple
elements additionally consult the declared type of the return
position, which is what lets nonzero error cover a helper that
hands back a concrete error type. Matching on a type alone would
admit a nil of that type, so a nonzero element runs the static
nil recogniser before it passes.
vow:import <alias> "<path>" on the package doc registers an alias
bound to a preset's rule namespace. When a vow:cond line
contains @<alias>.<rule>[args], the parser resolves the alias to
the preset, looks up the rule by name, and expands the rule's body
with the textual arguments substituted for the parameters. The
expanded payload is fed back through ParseReturnAnnotation, so
the expansion must obey the same DSL.
Parse failures during expansion are skipped silently — the "last valid parse wins" policy means a malformed line never silently shadows an earlier valid one.
passState holds per-pass caches: parsed vow:cond
annotations, file-scope rule alias maps from vow:import, and a
shared workspace for the zero-value classifier. passState is
created once per Run and threaded through every rule engine so
the cost of parsing each annotation is paid at most once.
fileScope(pass, file) returns the dsl.RuleScope for a file's
imported rule namespaces. The chain authorisation path consults
this scope so chain authority expressed through @rule resolves
correctly.
classifyZero reports whether an expression is the zero value of
its type using three layers of evidence:
pass.TypesInfo.Types[expr].Valuefor constant folding of primitives, named-primitive const refs, and similar constant expressions.- The bare
nilidentifier (Go's zero for interfaces, pointers, slices, maps, channels, and function values). - Composite literals — both the empty form
T{}and partial formsT{Field: zero}whose every element evaluates to zero. The recursion inspects nested composites to arbitrary depth.
Composite literal zero classification is restricted to struct and
array types (*types.Struct, *types.Array). Slice and map
literals like []int{} produce non-nil empty values that are
distinct from the type's zero, so the classifier treats them as
non-zero by design.
The nil-safety surface (declared through vow:nil; see the
DSL reference)
runs as a separate pipeline alongside the must-consume engine.
Discovery, fact export, caller-side checks, and callee-side
self-validation share the same parsed signature so the marker
is the single authoring point for nil-safety contracts.
nil_decl.go walks every FuncDecl in pass.Files and parses
the doc-comment payload through dsl.ParseNilSignature,
returning a map[types.Object]*dsl.NilSignature. A second pass
(field_nil.go) walks every top-level struct type and parses
the per-field marker through dsl.ParseFieldNilDecl, returning
a map[types.Object]*dsl.PositionDecl keyed on the field's
type-checker Object. Both maps live on passState and are
built lazily on first access; same-package callers consult the
maps directly instead of going through the cache.
Nullness tokens carry two interchangeable surfaces: the symbol
form (! / ?) and the keyword form (nonnil / nil). The
parser accepts either at any slot, so an author can mix forms
within a single payload — vow:nil (nonnil,?) is well-formed.
The canonical renderer emits the symbol form because the
signature-mirror payload sits in a dense, position-aligned shape
where every parameter slot already carries a token and the
punctuation reads tersely; the keyword alias is available so an
author can spell the constraint as a word when the symbol reads
less clearly at the authoring site.
The choice of symbol-canonical here is a frequency-driven design
choice and stands in contrast to vow:cond (see the DSL
reference), whose canonical
surface is the keyword (nonnil x -> nonnil result). The
vow:cond payload reads as a sentence with sparse, named
predicates, so the word form dominates that surface; the
vow:nil payload reads as a signature mirror with one token per
position, so the symbol dominates this surface. Both markers
admit the other form as an alias to give the author a per-call
escape hatch.
Two analysis.Fact types ride on the Go analysis Facts
mechanism so caller-side checks running in importing packages
read the contract through pass.ImportObjectFact:
signatureNilFactcarries the parsed*dsl.NilSignature(receiver, parameters, returns) at the function's type-checker Object.exportSignatureNilFactspublishes one fact per same-package function whose discovery produced a signature;nilDeclForFuncis the symmetric reader that consults the in-memory map first and falls back to the imported fact for cross-package callees.fieldNilFactcarries the parsed*dsl.PositionDeclat the field's type-checker Object.exportFieldNilFactsandfieldNilForFieldfollow the same shape as the signature helpers above.
Both facts encode their payload through gob, so the
dsl.NilSignature and dsl.PositionDecl types stay restricted
to exported fields. The Go analysis Facts mechanism silently
drops unexported objects, so unexported callees and unexported
fields stay recognised only within their declaring package.
validateCallerArgNilSafety walks every CallExpr in
pass.Files and inspects each call's callee through
nilDeclForFunc. Parameter positions the signature pins as
! drive nonNilRequirementsForCallee; the helper builds a
list of nonNilArgRequirement entries keyed by argument
index, and the loop reports one diagnostic per statically-nil
argument that lands on one of those positions. The
nil-ability judgement (argumentIsStaticallyNil,
argumentIsDeclaredNillableField) is conservative — only
proof-by-construction shapes (literal nil, typed-nil
conversion, ?-declared field read) qualify.
validateCallerReceiverNilSafety mirrors the argument check
on the receiver axis. receiverDeclMode classifies the
callee into three branches: receiverModeStrict (a !.()
recv decl on the signature), receiverModeSilent (a ?.()
decl, an omitted receiver layer on a vow:nil signature, or
a same-package callee that carries vow:cond but no
vow:nil signature), and receiverModeUnannotated (no vow
declaration at all). The strict branch surfaces a diagnostic
that quotes the contract; the unannotated branch surfaces a
default-warn diagnostic because nil-receiver method calls are
Go's most common runtime panic source; the silent branch
emits nothing.
validateCallerDeadGuard covers the third caller-side shape: a
nil guard standing on a local the callee's return declaration
already proves non-nil. The pass walks each *ast.BlockStmt
statement list once, carrying a callerNonNilBinding per local
bound to a ! return slot. Three steps run per statement and the
order matters — the guard check reads the bindings earlier
statements established, the invalidation step retires anything
this statement rewrites, and the recording step admits the
bindings this statement creates — so x, err := New() retires a
stale x and re-admits the fresh one while visiting the same
statement.
The pass stays AST-only and block-local for the same reason the
callee-side dead-guard recogniser does. The diagnostic stays
unemitted on a reassignment, an address-of hand-off, or an
increment of the bound local (each puts the value outside the
contract's description), on a function literal in the statement
(a closure rewrites a captured local out of syntactic view), on a
guard body that falls through rather than short-circuiting, on an
if carrying an init clause (the clause can rebind the
identifier the condition reads), and on a guard nested in an
inner block (that block's own walk carries no outer binding).
Values whose provenance needs flow analysis fall through
unreported rather than risking a report on a value that did
change.
Four independent passes validate the callee's body against its declaration, so a single function can surface multiple diagnostics when its body violates several axes.
nil_check_callee_self_decl.go(validateNilDeclCalleeSelf) groups three AST-level sub-checks on every function whose signature carries a!parameter, receiver, or return slot: the reassign check fires when a!-pinned position is overwritten with the bare nil literal (or a typed-nil conversion); the return-nil check fires when areturnstatement supplies the same literal at a!-pinned return slot; the dead-guard check fires when a body-leadingif x == nil { return ... }short-circuit pre-establishes non-nil at entry on a position the signature already proves non-nil. The same dead-guard recogniser runs over receiver positions throughnonNilParamAndRecvNames, so a!.()- pinned receiver behind a body-leading guard reaches the same diagnostic. All three sub-checks stay syntactic; transitive flow is left to the SSA pass.nil_flow_ssa.go(validateNilDeclCalleeFlow) runs over SSA so it picks up shapes the AST pass cannot recognise: alias chains ending in a nil literal, phi merges of nillable branches, and field reads of?-declared fields flowing into!-required positions.- The same SSA pass handles the nest-element flow case:
slice indexes and map lookups whose container field carries
a nest decl (
![]?,![!]?) lift the element load into a nillable proof, and the resulting value flowing into a!slot surfaces a diagnostic on the use site. nil_check_callee_self_decl_recv.go(validateNilDeclReceiverGuard) handles the receiver-guard case: a?.()-declared receiver dereferenced without a leading nil guard surfaces a diagnostic at the first deref site. The body of a nillable-receiver method is expected to guard before the first use; an unguarded deref is the typical nil-receiver panic source. The guard recogniser also accepts a check that leads a logical expression (recv == nil || …,recv != nil && …), since Go's short-circuit evaluation keeps every operand to its right behind it; a receiver check that sits anywhere else in the chain does not qualify, because the operands before it are evaluated unguarded.
The body-leading dead-guard recogniser lives separately in
prereq_dead_guard.go. It reads vow:cond prereqs
(nonnil x) and surfaces a diagnostic when a body-leading
if x == nil { return ... } guard pre-establishes a
condition the prereq already proves at entry — the
sentinel-narrowing surface keeps the diagnostic alongside
the nil-safety pipeline so the same dead-guard recogniser
covers both axes.
Every nil-safety diagnostic flows through vowReportNilSafetyf,
which routes the report through the same filter chain
vow:suppress uses for the must-consume surface. A
function-level suppress drops every nil-safety diagnostic
inside the function range; a line-level suppress drops the
diagnostic at the named line.
closable.go and caller_emit.go track Closable resources from
acquisition to discharge. The pipeline runs in three steps:
- Type discovery.
closableTypeSetprimes a per-pass registry of Closable type names. The registry merges two sources: type declarations carrying// vow:define @Closable(walked bydiscoverClosableTypeNamesover the package'sTypeSpecnodes) and thebuilt_in_closableslist from the closable preset (*os.File,*net.TCPConn,*bufio.Writer,io.Closer). - Caller-side
vow:emitdiscovery.discoverCallerEmitwalks every comment group for statement-scope// vow:emit Xmarkers. Each marker anchors to a statement line through the sharedresolveSuppressLineresolver.inferEmitDestinationthen classifies the site as Goroutine, Callback, Storage, or Channel by checking the anchored statement and a parent map built on demand; sites that match no recognised shape report the analyzer's "destination could not be inferred" warning. - Lifetime check.
validateClosableLifetimewalks eachFuncDeclfor:=declarations whose LHS resolves to a Closable type, collects the deferred Close receivers (collectDeferredCloseReceivers) and the statement-scopevow:emitdischarges (emitDischargesForFunction), and reports an acquisition that reaches the end of the function without a recognised discharge.checkUpcastInvariantruns in the same walk and reports the three upcast sites (return statement, call argument, assignment LHS) where a Closable value flows into a non-Closable interface.
isClosableType resolves the type-axis match across four shapes:
the fully-qualified type string, a single pointer indirection, the
underlying alias / embedded chain, and — for generic type
parameters — the parameter's interface constraint when the
constraint embeds (or names) a registered Closable interface. The
constraint walker (typeParamConstraintIsClosable) credits a
parameter declared as [T io.Closer] the same way the analyzer
credits a concrete *os.File, so the acquisition and upcast
checks fire on generic function bodies without per-instantiation
discovery.
The pass executes after the concept-define info-level diagnostic so the type registry can rely on the same vow:define discovery walk that surfaces forward-compat concept notes.
cond_lifecycle_pair.go recognises the Closer / paired-resource
shape of a logical-arrow vow:cond rule. isLifecyclePairRule
consults the signature of the declaring function, the rule's
operands, and the Closable registry (closableTypeSet): when one
operand resolves to a Closable signature position and the other
resolves to a distinct nil-bearing reference position (pointer,
interface, map, channel, slice, or function), the rule expresses a
lifecycle pair invariant.
The call-site evaluator in cond_caller.go queries the predicate
when it commits a violation and routes the diagnostic through
describeLifecyclePairCallViolation, which inserts a lifecycle pair qualifier before the rule rendering. Rules outside the pair
shape — every operand combination that misses the Closer leg, that
walks through a postfix path, that names the same position on both
sides, or whose direction is the structural -> arrow — report
through the generic logical-arrow diagnostic path unchanged.
The specialisation stays additive on top of the Closable
acquisition tracking documented above: a function may declare a
lifecycle-pair rule and a statement-scope vow:emit transfer in
tandem, and the two passes report independently.
- Subjects. The current
findSubjectsis per-package; a sentinel declared in one package is recognised as a subject when its declaration site is part of the analysis target. Inter-procedural subject tracking is not supported. - Qualified references. The four-axis Term comparison handles
cross-package references through the
fullNameaxis. Avow:cond pkg.ErrFoo | nilannotation matches areturn pkg.ErrFooregardless of import aliasing oncetypes.Inforesolves both sides. - Generics. The SSA pass's
findSSAFunctionmaps an*ast.FuncDeclto its*ssa.FunctionbySyntax()pointer equality. Under the defaultbuildssa.BuilderMode(0), a generic origin and its instantiations share the sameSyntaxpointer, so leaks inside a generic body are reported at the origin's return.
Every function-doc marker family accepts an optional [<subject>]
qualifier that names a receiver, a regular parameter, or a named
return of the enclosing function. The pipeline keeps the qualifier
shape consistent across families by routing through a single
lexer helper, stripMarkerScope, that peels the brackets and
exposes the subject token to the per-family parser.
A shared resolver, resolveSignatureSubject, walks the function
declaration in priority order — receiver, then parameters, then
named returns — and reports the first slot whose declared
identifier matches the subject. The resolver is purely
syntactic: it inspects field names rather than types so a
signature without named results never participates and the same
helper covers every marker family without per-family branching.
When a marker carries a scope, the per-family validator skips the
function-level enforcement the same declaration would otherwise
trigger on the enclosing function. The skip avoids misrouting the
contract; higher-order semantics are not covered. The
resolver's reportSubjectUnresolved diagnostic surfaces every
authoring typo at parse time so authors never see a silent loss
of contract. The diagnostic category matches the marker family —
vow[nil-safety] for vow:nil, vow[sentinel-error] for the
function-doc forms of the other families, and vow[closable] for
caller-side statement-scope vow:emit.
After the per-site evaluator runs each logical vow:cond rule
at return statements and at call expressions, the analyzer
applies a rule-set inspection layer that reports three classes
of inconsistency the per-site walk would not surface on its
own. Every diagnostic in this layer anchors at the function
declaration because the offence belongs to the rule set or to
the rule set combined with the signature, not to any single
evaluation point. The layer reads the per-function rule set
through logicalRulesSet and the per-function signature
through nilDeclForFunc, both of which the per-site evaluator
and the signature pass populate before this layer runs.
The three detection paths share a common entries helper that
materialises the rule pool a check inspects. The helper builds
on the contrapositive derivation pass and the transitive
closure expansion, then tags each entry with one of three
kinds — entryOriginal for an author-written rule,
entryContrapositive for a derived contrapositive, and
entryTransitive for a chain closure — so each check filters
the pool to the entries its detection semantic admits.
checkLogicalRuleSetInconsistencies walks every function's
rule pool together with the derived contrapositives and
transitive chain closures, then reports every pair whose two
operands the structural expressionsEqual and
negateExpression helpers identify as a left-hand-side match
with right-hand-side negation. The dedup key combines the
rendered descriptions so the diagnostic never repeats the same
pair regardless of which order the outer loops visit it.
Pairs whose two sides are both derived contrapositives are
dropped because the contrapositive of a rule is logically
equivalent to the rule itself and a contradiction whose two
sides are pure contrapositives reduces to a contradiction on
the source rules (already caught) or to a derived
unsatisfiable-LHS by-product rather than a true rule-set
offence. The filter applies to the entryContrapositive pair
shape and leaves every other pair in the walk.
checkLogicalRuleNilDeclConsistency evaluates each rule's
operands against the function's vow:nil signature. A
signatureNameContext bundle, built once per function from the
*types.Signature and reused for every operand, names the
receiver, the regular parameters, and the named returns so the
operand resolver matches a reference by identifier name. The
evaluateOperandFromNilDecl helper reads NullnessNonNil
slots into a fixed truth — p == nil decides as false,
p != nil decides as true — and leaves nullable and platform
slots undecided so the check stays sound on every shape the
signature does not pin.
The walk reads the original rules and the transitive chain
closures but drops the derived contrapositives because a
contrapositive contradicts the signature exactly when its
source rule contradicts the signature, so emitting both would
surface the same logical offence twice. The collection helper
collectNilDeclCheckEntries filters the shared entries pool to
the kinds the cross-check admits.
reportNilOnlyInductionHints tallies, per signature position,
the count of author-written => rules whose right-hand side
narrows the position to nil (p == nil) and the count of
rules that narrow it to non-nil (p != nil). A position whose
nil-only count reaches the threshold and whose non-nil count
stays at zero, and whose signature does not already pin a
nullness on that slot, surfaces a hint suggesting the author
declare the position as nullable. The walk skips derived
contrapositives and chain closures because the hint reflects
author intent rather than chain-derived narrowing.
The referenceNamesSignaturePosition helper gates the hint to
references that bind to a receiver, a regular parameter, or a
named return so the suggested declaration always points at a
slot the marker can constrain.
Production code, testdata fixtures, and the docs/ set describe
what the code currently is — its capabilities and its
limitations. Comments and docs stay focused on present-tense
facts; they do not narrate project history and they do not
forecast project direction. The conventions below name the
patterns the codebase avoids and the substitutes the codebase
uses so reviewers consult them as a shared checklist.
- Past-tense or transition narrative.
previously,historically,now that,was changed,has been added,is now,formerly,prior. These attach a project history to a code element whose current role is what matters. Use a present-tense statement of what the element currently is. - Forward-looking narrative.
in a separate change,a later change,a later wave,follow-up wave,follow-up change,next wave,future wave,forthcoming,subsequent,progressive,target state,converges on. These point at a project direction the comment cannot guarantee. Use a present-tense statement of what is and is not covered now (the analyzer does not recognise this case,this surface is not enforced). legacyas an adjective.the legacy positional grammar,the legacy textual-expansion path,the legacy single-rule shapeattach a deprecation tone to a shape that still carries its role. Use a descriptor that names the shape directly (the structural positional grammar,the textual- expansion path,the back-compat field). A bare identifier reference (a struct-field name in source such asConseq) preserves the identifier and is kept as-is.retired,migrated,replaced byin narrative. These framings describe a transition rather than the present state. Use a present-tense description of what the code currently does. User-facing error messages keep these spellings because the test surface requires the literal text.
- Present-tense state describers.
carries,holds,represents,decides,pairs,routes,gates,currently accepts,is not enforced. - Direct capability or limitation statements.
the analyzer parses the suffix but does not enforce its value-level content,the recogniser does not cover postfix-chained references. - Identifier preservation under backticks. Identifiers that
exist in the source (
Conseq,*ReturnAnnotation,nonzero) stay as-is so readers can grep from documentation to implementation.
The conventions extend to commit messages and PR descriptions that ship alongside the code change: the audit-friendly stance is to describe what the code currently does and does not do, not the path the project took to arrive at the change and not the path the project intends to take next.
These conventions apply to comments and docs in this repository.
- DSL reference for the surface syntax.
- Annotations for marker placement rules.
- Presets for the rule engine dispatch model.