Strata ships a coherent default architecture, but no default fits every project.
Custom rules let you encode your repository’s own conventions as executable checks,
in plain Python, using the same context the built-in rules use. A rule you would
otherwise repeat in every code review becomes something CI enforces.
This is possible because Strata is a Python package: it loads your rule code at
runtime. The check body is ordinary Python, so you can walk the AST, read the
source, or consult other files however you need.
The shape of a rule
A custom rule is a function decorated with @rule. The decorator carries a
mandatory metadata envelope; the function body is unconstrained.
from __future__ import annotations
import ast
from strata import Family, Fault, RuleContext, rule
@rule(
code="XAC001",
family=Family.CUSTOM,
slug="no-acme-global",
message="ACME_GLOBAL hides ownership",
remediation="Move the value to the module that owns and produces it.",
)
def no_acme_global(*, module: ast.Module, ctx: RuleContext) -> list[Fault]:
return [
ctx.fault(node)
for node in ctx.nodes(ast.Name)
if isinstance(node, ast.Name) and node.id == "ACME_GLOBAL"
]
The idea is to constrain the envelope and free the body. You cannot create a
rule without its identity and message, but inside the check you can write any
Python you like.
The public API
Strata exports exactly five names, and that is the whole supported surface:
from strata import rule, Fault, Family, Severity, RuleContext
Anything not exported is internal and may change.
The @rule decorator
def rule(
*,
code: str,
family: Family | str,
slug: str,
message: str,
remediation: str | None = None,
severity: Severity = Severity.ERROR,
enabled_by_default: bool = True,
) -> Callable[[RuleCheck], RuleCheck]: ...
| Field | Required | Purpose |
|---|
code | yes | The rule’s permanent identifier. Must start with X. |
family | yes | A Family member; use Family.CUSTOM for project rules. |
slug | yes | A short kebab-case name. |
message | yes | The one-sentence statement of the violated contract. |
remediation | no | The normal correction, shown as = help:. |
severity | no | Severity.ERROR (default) or Severity.WARNING. |
enabled_by_default | no | Set False for an opt-in rule that runs only when selected by code. |
The code’s namespace is validated when the decorator runs. A custom code that
starts with SF is rejected at load, so a project can never silently redefine what
a core rule means.
The check signature
The signature is frozen. New capabilities arrive as new context methods, never as
signature changes.
def check(*, module: ast.Module, ctx: RuleContext) -> list[Fault]: ...
module is the parsed ast.Module of the file being checked.
ctx is a RuleContext.
- The return value is a list of
Fault objects.
The Fault
A Fault is a frozen dataclass. You usually construct one through the context so
line, column, and code are wired automatically, but the type is public so return
types are nameable.
@dataclass(frozen=True, slots=True)
class Fault:
code: str
path: Path
message: str
line: int | None = None
column: int | None = None
remediation: str | None = None
Family and Severity
class Family(StrEnum):
LAYERS = "layers"; ROLES = "roles"; SHAPE = "shape"
NAMING = "naming"; HYGIENE = "hygiene"; TESTS = "tests"
ANNOTATIONS = "annotations"; CUSTOM = "custom"
class Severity(StrEnum):
ERROR = "error"; WARNING = "warning"
The rule context
RuleContext is a convenience toolbox, not a cage. You may ignore it entirely and
walk module yourself; it exists so you do not have to rewrite AST plumbing. Its
methods fall into a few groups.
Fault construction
ctx.fault(node, *, message=None, remediation=None) # a fault located at a node
ctx.path_fault(*, message=None, remediation=None) # a file-level fault
If you omit message or remediation, the rule’s envelope values are used.
Position and role facts (computed relative to the configured root, not
hardcoded):
ctx.path # the file being checked
ctx.repo_root
ctx.source # raw source text
ctx.relative_parts()
ctx.role_of(path=None)
ctx.in_role("main")
ctx.is_entry_module()
ctx.is_main_module()
ctx.domain()
ctx.subdomain()
ctx.scope()
AST helpers drawn from what the core rules found universally useful:
ctx.nodes(ast.Call) # nodes of a type from the shared single-pass index
ctx.call_name(call)
ctx.base_name(expr)
ctx.top_level_functions(module)
ctx.non_docstring_body(module)
ctx.distinct_callees(fn)
ctx.assigned_locals(fn)
ctx.parameter_names(fn)
ctx.complex_comprehensions()
ctx.inside_loop(node)
Configuration access
ctx.threshold(Threshold.MAX_STATEMENTS) # the value applicable to the current file
ctx.contracts() # configured name-to-behavior contracts
Prefer ctx.nodes(SomeType) over ast.walk(module) for module-wide scans. Every
cooperating rule reads from a single shared traversal of the file, so you do not
pay for a separate walk. Walking the tree yourself is always allowed; it just
costs one extra traversal for that rule.
Where rules live
Keep project rules in a rules/ role module inside your tooling tree. A rules/
module may contain imports, import-only TYPE_CHECKING blocks, and any number of
@rule functions; supporting logic belongs in the other roles.
scripts/strata_rules/rules/
├── boundaries.py
└── naming.py
Loading rules
Point configuration at your rules, and select them:
tooling = ["scripts"]
rule_paths = ["scripts/strata_rules/rules"]
rule_modules = ["company_architecture.rules"]
select = ["SF", "XAC001"]
rule_paths are directories (or files) of .py modules. Each file is loaded
under an isolated synthetic module name, so it cannot pollute or collide with your
import namespace, and only the decorated functions that file defines are picked
up.
rule_modules are importable dotted names, loaded with a normal import.
An import error in a rule file is reported as a clear configuration error naming the
file, not a raw traceback. Every rule code across core and custom must be unique, so
a duplicate code is rejected rather than silently overriding another rule.
You enable custom rules like any other. The X family selector turns on all
default-enabled custom rules; an exact code turns on a single rule (and is the only
way to enable an enabled_by_default=False custom rule).
Selecting and inspecting
Once configured, custom rules are indistinguishable from core rules to the rest of
Strata. They run under strata check, they appear in
strata rule (with their source file shown), and they are included in
the guidance produced by strata skills:
XAC001 no-acme-global
Family: custom
Severity: error
Kind: custom
Enabled by default: yes
Source: scripts/strata_rules/rules/boundaries.py
Message: ACME_GLOBAL hides ownership
Remediation: Move the value to the module that owns and produces it.
Because the message and remediation envelope is the same one core rules use,
your custom rules automatically become part of your repository’s generated agent
guidance. That is the property no separate rules document can offer: the
instructions your agents read are generated from the rules your CI enforces, so
they cannot drift apart.