Skip to content

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.

Add rule files to anvil.yaml:

compiler:
rules:
- tools/anvil/rules/http_write_policy.star
- tools/anvil/rules/public_routes.star

Stages can also add rules:

stages:
production:
rules:
- tools/anvil/rules/no_debug_routes.star

The CLI resolves relative rule paths against the directory containing anvil.yaml.

Rule execution order is deterministic:

  1. Built-in Anvil rules.
  2. compiler.rules in config order.
  3. stages.*.rules, with stage names sorted alphabetically.
  4. Extra --rule <file> flags for anvil lint only.

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.

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[] with name, kind, request, response, and source
  • graphql.endpoints[] with name, path, handler, subscriptionHandler, and source
  • queue.jobs[] with name, queue, handler, and source
  • endpoints[] 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.

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: ERROR or WARNING; defaults to ERROR
  • rule: stable rule identifier; defaults to the rule file path
  • message: required human-readable failure
  • hint: optional fix instruction
  • source.file: optional source file
  • source.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.

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 diagnostics

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 Check path, 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.

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.