Skip to main content
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]: ...
FieldRequiredPurpose
codeyesThe rule’s permanent identifier. Must start with X.
familyyesA Family member; use Family.CUSTOM for project rules.
slugyesA short kebab-case name.
messageyesThe one-sentence statement of the violated contract.
remediationnoThe normal correction, shown as = help:.
severitynoSeverity.ERROR (default) or Severity.WARNING.
enabled_by_defaultnoSet 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:
strata rule XAC001
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.