Starlark Rules
Starlark rules let a project enforce its own compiler policy without forking Anvil.
Rules live in the application repository. Anvil only loads and executes them
during compiler commands such as generate and lint.
anvil manifest, anvil openapi, and anvil testbed build from the compiler
manifest without running Starlark rule files. Use anvil lint when CI needs
policy checks without writing generated code.
Registering Rules
Section titled “Registering Rules”Add rule files to anvil.yaml:
compiler: rules: - tools/anvil/rules/http_write_policy.star - tools/anvil/rules/public_routes.starStages can also add rules:
stages: production: rules: - tools/anvil/rules/no_debug_routes.starThe CLI resolves relative rule paths against the directory containing
anvil.yaml.
Rule execution order is deterministic:
- Built-in Anvil rules.
compiler.rulesin config order.stages.*.rules, with stage names sorted alphabetically.- Extra
--rule <file>flags foranvil lintonly.
Entry Point
Section titled “Entry Point”Each file defines:
def check(manifest): return []manifest is a Starlark dictionary converted from the deterministic
anvil.manifest.v1 JSON structure. The function returns an iterable of
diagnostic dictionaries.
Anvil does not inject custom helper functions into the script. The manifest is the rule input. Use normal Starlark values and return diagnostics when the developer needs feedback.
Rule files are loaded as standalone scripts. Top-level statements run when
Anvil loads the file, then check(manifest) runs when the rule set checks the
compiled manifest. Keep top-level code for constants and helper functions.
Anvil does not wire a Starlark load() resolver, so split reusable policy
carefully or keep shared helpers in each rule file.
The conversion preserves JSON numbers, booleans, strings, lists, dictionaries,
and None. Dictionary keys are sorted before conversion so scripts see stable
iteration order. Whole-number JSON values become Starlark integers when they
fit; other JSON numbers become Starlark floats.
Manifest Data Available To Rules
Section titled “Manifest Data Available To Rules”The manifest has this top-level shape:
manifest = { "schema": "anvil.manifest.v1", "endpoints": [...], "http": {"groups": [...], "routes": [...]}, "grpc": {"services": [...]}, "graphql": {"endpoints": [...]}, "queue": {"jobs": [...]},}HTTP route entries expose:
route = { "method": "GET", "webSocket": False, "path": "/projects/:projectId", "controller": "Projects", "endpoint": "Get", "operationId": "Projects.Get", "request": {"package": "example.com/portal/project", "name": "GetProjectRequest"}, "response": {"package": "example.com/portal/project", "name": "ProjectDTO"}, "validateRequest": True, "params": [{"name": "projectId"}], "bindings": [{"field": "ProjectID", "source": "param", "key": "projectId"}], "fields": [{"field": "ProjectID", "source": "param", "key": "projectId", "schema": "string"}], "validations": [{"field": "ProjectID", "key": "projectId", "kind": "string", "rule": "uuid"}], "middleware": [{"package": "example.com/portal/auth", "name": "WritePolicy"}], "source": {"file": "api/projects.go", "line": 19},}HTTP group entries expose:
group = { "name": "Admin", "type": {"package": "example.com/portal/api", "name": "Admin"}, "path": "/admin", "fullPath": "/api/admin", "middleware": [{"package": "example.com/portal/auth", "name": "AdminOnly"}], "graphqlMiddleware": [{"package": "example.com/portal/auth", "name": "GraphQLTrace"}], "grpcMiddleware": [{"package": "example.com/portal/auth", "name": "RPCTrace"}], "queueMiddleware": [{"package": "example.com/portal/auth", "name": "QueueTrace"}], "children": [{"package": "example.com/portal/api", "name": "Projects"}], "source": {"file": "api/admin.go", "line": 8},}Other protocol metadata is exposed when present:
grpc.services[].methods[]withname,kind,request,response, andsourcegraphql.endpoints[]withname,path,handler,subscriptionHandler, andsourcequeue.jobs[]withname,queue,handler, andsourceendpoints[]with the raw marker-discovered endpoint list
type references use package, name, pointer, and provider when those
fields are known. provider is the named provider value from an inject tag,
not a separate source tag.
Returning Diagnostics
Section titled “Returning Diagnostics”Diagnostics use this dictionary shape:
{ "level": "ERROR", "rule": "company.http.write_policy", "message": "write routes must use WritePolicy", "hint": "use sdk.POSTWith[WritePolicy] or add the policy at group level", "source": {"file": "api/projects.go", "line": 19},}Fields:
level:ERRORorWARNING; defaults toERRORrule: stable rule identifier; defaults to the rule file pathmessage: required human-readable failurehint: optional fix instructionsource.file: optional source filesource.line: optional one-based source line
Use ERROR and WARNING exactly. The compiler treats exact ERROR values as
errors. Any other level value is reported as a warning, so a typo weakens the
policy instead of strengthening it.
If message is empty, the rule itself fails and Anvil emits a compiler error.
Each item returned by check must be a dictionary. Returning a non-iterable, a
list containing non-dictionary items, or a diagnostic without a message fails
the rule.
If source is omitted or is not a dictionary, the diagnostic has no source
location. If source.file is not a string or source.line is not an integer,
that field falls back to an empty file or line 0.
Example: Require Write Policy
Section titled “Example: Require Write Policy”WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"]
def middleware_names(route): return [item.get("name", "") for item in route.get("middleware", [])]
def check(manifest): diagnostics = [] for route in manifest["http"]["routes"]: if route["method"] not in WRITE_METHODS: continue if "WritePolicy" in middleware_names(route): continue
diagnostics.append({ "level": "ERROR", "rule": "company.http.write_policy", "message": "write routes must use WritePolicy", "hint": "use sdk.POSTWith[WritePolicy] or attach WritePolicy to the parent group", "source": route["source"], }) return diagnosticsSafety
Section titled “Safety”Starlark execution is intentionally small and manifest-driven. Anvil gives the
script one input, the manifest, and expects one output, diagnostics. The CLI
does not expose filesystem helpers, network helpers, shell execution, or a
custom load() resolver to rules.
Execution is bounded by the Starlark step counter:
- Default max execution steps:
200000. - The same step limit applies while the file is loaded and while
check(manifest)runs. - Script load failures stop the command before the rule can run.
- Runtime failures are converted into one compiler diagnostic with the Starlark backtrace in the message.
- Returned diagnostics are sorted deterministically by file, line, and rule.
- The Go API supports context cancellation through
CheckContext. - The CLI uses the bounded
Checkpath, so the execution step limit is the CLI safety boundary.
The CLI does not add a separate wall-clock timeout around rule execution. A rule that loops burns execution steps until the Starlark thread is canceled by the step limit.
print() can be captured by embedded Go callers through the rule print
callback. The CLI discards printed output; return diagnostics when a rule needs
to communicate with the developer.
Rules are deterministic. Treat the manifest as the entire rule input, and keep rule behavior independent from time, random data, network calls, and local machine state.
generate Versus lint
Section titled “generate Versus lint”anvil generate runs built-in manifest rules and configured Starlark rules. It
fails only on ERROR diagnostics. Warnings are reported by lint.
anvil lint runs built-in manifest rules, configured Starlark rules, and any
extra --rule arguments. It prints both warnings and errors, then exits
non-zero if at least one error exists.
anvil manifest, anvil openapi, and anvil testbed skip configured
Starlark rules and built-in manifest rules. They still fail on parser and
compiler errors because those commands need a valid manifest before producing
output.