> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stratalint.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Rules

> Write your own architecture rules in Python using the same rule context the core rules use.

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.

```python theme={null}
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:

```python theme={null}
from strata import rule, Fault, Family, Severity, RuleContext
```

Anything not exported is internal and may change.

### The `@rule` decorator

```python theme={null}
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.

```python theme={null}
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-rule-context).
* The return value is a list of [`Fault`](#the-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.

```python theme={null}
@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`

```python theme={null}
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**

```python theme={null}
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):

```python theme={null}
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:

```python theme={null}
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**

```python theme={null}
ctx.threshold(Threshold.MAX_STATEMENTS)   # the value applicable to the current file
ctx.contracts()                            # configured name-to-behavior contracts
```

<Tip>
  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.
</Tip>

## 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.

```text theme={null}
scripts/strata_rules/rules/
├── boundaries.py
└── naming.py
```

## Loading rules

Point configuration at your rules, and select them:

```toml theme={null}
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`](/cli/rule) (with their source file shown), and they are included in
the guidance produced by [`strata skills`](/cli/skills):

```bash theme={null}
strata rule XAC001
```

```text theme={null}
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.
