Skip to content

Rules

Every diagnostic Inwards prints links to its rule's page here: the docs: line of the text output, the docs field of the JSON output, helpUri in SARIF and the code link in the editor. Each page says what the rule flags, why it matters when an AI agent writes the code, a flagged and a fixed example, how to fix a finding, how to configure the rule, and what it doesn't catch yet.

inwards rule INW001 prints a rule's page in the terminal, offline, from the binary you run, and inwards rules lists which rules your config turns on, at what severity, and why (configuration).

Search the rules, filter them by category, status or autofix, sort them by code, name, category or status, and page through them. The address bar keeps the view, so a link opens the same list. A category includes its sub-categories: imports also lists the rules filed under imports › layers.

The rules fall into six categories, described below in this order: imports, layout, roles, integrity, diagrams and FastAPI. Each category also has a page of its own, and the navigation lists its rules under it: imports, layout, roles, integrity, diagrams and FastAPI. A rule is either on by default or opt-in. The presets of inwards init --style keep every rule that is on by default, except that django and fastapi turn INW002 off, and turn some opt-in rules on as warnings: INW014, INW015 and INW020 in clean and hexagonal, INW012, INW013, INW016 and the FAPI rules in fastapi.

Imports

What a module may import: layers point inward, contexts import each other only as declared and only through their public modules, no cycles, no library a layer's config denies, no first-party module that doesn't exist, no dynamic import that hides one of these, and no private module reached from outside its package. Every imports rule is on by default except INW021, which is opt-in.

Code Name What it flags On Severity Turned on by preset Inline suppression
INW001 layer-dependency An import from an inner layer into an outer one by default error all yes
INW002 context-independence An import from one bounded context into another its depends-on doesn't declare by default error all but django, fastapi yes
INW003 public-api-only An import of a context's module that isn't public, from outside the context by default error all yes
INW004 import-cycles Modules, or bounded contexts, that import each other in a cycle (whole-project runs) by default error all no
INW005 pure-domain A library import a layer's config doesn't allow, such as SQLAlchemy in the domain by default error all yes
INW010 unknown-first-party An import of a first-party module that doesn't exist by default error all yes
INW011 dynamic-import A dynamic import that reaches an outer layer, or whose target Inwards can't read by default error all yes
INW021 private-module An import of a module whose name starts with _ from outside the package that holds it opt-in error vertical-slices yes

Layout

Where code lives: every first-party module in a layer, and every package in the shape the config gives it. On by default.

Code Name What it flags On Severity Turned on by preset Inline suppression
INW006 unassigned-module First-party code outside every layer, and layer prefixes that match nothing by default error, some findings warn all yes
INW007 package-shape A package member its shape doesn't allow, or a name outside the packages it belongs in by default error, some findings warn all no
INW008 missing-member A member a package's shape requires is missing by default error all no

Roles

What the code inside a role's modules does: endpoints that hand their work on, no blocking calls in async def, abstract ports, adapters that declare their port, a single composition root, and ORM names. These rules judge code against choices a team makes, so they are opt-in; a template can turn one on for a role, and so do some presets.

Code Name What it flags On Severity Turned on by preset Inline suppression
INW012 thin-endpoint An HTTP endpoint that does the work itself: too many statements, branches or loops, database or HTTP calls of its own, or no call into the layer delegate-to names opt-in warning fastapi yes
INW013 async-blocking A synchronous database, cache or cloud call inside async def: a sync SQLAlchemy Session, redis.Redis, boto3 or a blocking driver opt-in error fastapi yes
INW014 ports-abstract A class in a port module that isn't an ABC or a Protocol, or a port method whose body does work opt-in error clean, hexagonal yes
INW015 construct-only-in A module outside the composition root that imports an outbound adapter, or another guarded role, at runtime, or builds one of its classes opt-in error clean, hexagonal yes
INW016 orm-naming A table name that isn't lower_case_snake and singular, or a datetime or date column without _at or _date; a whole-project run also wants a MetaData naming convention opt-in error fastapi yes
INW020 adapter-conformance A public class in an adapter module with no _: Port = Adapter(...) line under if TYPE_CHECKING:, such a line that runs at import, or one whose annotation names no port opt-in error clean, hexagonal yes

Integrity

Rules that keep the check itself honest: a file whose declared encoding could hide an import, and an inline suppression that hides nothing. On by default; INW000 can't be turned off.

Code Name What it flags On Severity Turned on by preset Inline suppression
INW000 unsupported-encoding A declared source encoding under which a comment can be a real import by default error all no
INW009 suppression-comment An inline suppression that hides nothing, or matches no finding by default error, unused ones warn all no

Diagrams

Architecture diagrams in the docs, checked against [tool.inwards] (ADR-045). Opt-in, and warnings, so docs drift doesn't block an agent.

Code Name What it flags On Severity Turned on by preset Inline suppression
INW017 diagram-unknown-name A node in a marked Mermaid or PlantUML architecture diagram that names no declared layer or context, or whose quoted label (a PlantUML alias's module) matches no module opt-in warning none no
INW018 diagram-forbidden-edge A solid arrow in a marked Mermaid or PlantUML architecture diagram that draws an import the config forbids: outward or between sibling layers, between contexts without depends-on, or into a module that isn't public opt-in warning none no
INW019 diagram-missing-dependency An import in the code between two layers or contexts a marked Mermaid or PlantUML architecture diagram draws, with no arrow (for layers, no path of arrows) for it (whole-project runs) opt-in warning none no

FastAPI rules

The FAPI family checks FastAPI applications across files: which router the app includes, which error codes the OpenAPI schema declares (ADR-037). Every FAPI rule is opt-in, and none reports what Ruff's FAST rules already do. On a new project, inwards init --style fastapi turns them all on as warnings.

Code Name What it flags On Severity Turned on by preset Inline suppression
FAPI001 endpoint-metadata A path operation without the OpenAPI metadata the project requires (summary, response model, status code) opt-in error fastapi yes
FAPI002 undocumented-error-response A path operation that can produce an error status code (raised directly, in a helper or dependency, or through an app's exception handler) its responses= doesn't declare opt-in error fastapi yes
FAPI003 router-wiring An APIRouter with routes that no app includes, routers that include each other in a cycle, or an include_router above the included router's routes opt-in error fastapi yes
FAPI005 route-shadowing A path operation that an earlier one with the same method already answers: /users/{id} above /users/me, or the same method and path twice opt-in error fastapi yes
FAPI006 lifespan-events A deprecated on_event handler or on_startup= (a warning), and one next to a lifespan= that makes FastAPI ignore it (an error) opt-in error fastapi yes
FAPI007 yield-dependency-swallows A dependency with yield whose except clause around it doesn't re-raise, so the endpoint's error is hidden opt-in error fastapi yes
FAPI008 duplicate-operation-id Two path operations of one app with the same literal operation_id opt-in error fastapi yes
FAPI009 depends-called Depends(get_db()): the dependency is called at import time instead of being passed opt-in error fastapi yes

One code is reserved and not registered (an unknown code is still a config error):

Code Name Issue Status
FAPI004 unhandled-exception #185 unused: the spike's corpus run said no-go (3% precision for declared exception classes, 4 true findings in 25 apps for raised ones)

The rule catalogue in chapter 3 lists every rule with the rest of the design.

Configure rules

A [tool.inwards.rules] table in pyproject.toml says which rules report and how loudly (ADR-027):

pyproject.toml
[tool.inwards.rules]
ignore = ["INW007", "INW008"]      # these rules never report
severity = { INW005 = "warning" }  # reported, but doesn't fail a check or block the agent
# extend-select = [...]           # turn opt-in rules on, next to the defaults
# select = ["INW001", "INW011"]    # or: only these rules report (default: every rule that is on by default)

Codes are exact, not prefixes, and an unknown code is a config error (exit 2). ignore wins over select and extend-select. A rule set to "warning" shows up in every format but doesn't change the exit code, block the hooks or go into the baseline. INW000 can't be ignored or lowered. The table is part of [tool.inwards], so the config guard stops an agent from editing it.

Opt-in rules and rule options

A rule whose On column says "opt-in" doesn't report until you turn it on: list its code in extend-select, which keeps every other rule as it is, or in select. The INW rules are on by default, apart from INW012, INW013, INW014, INW015, INW016, INW017, INW018, INW019, INW020 and INW021; the opt-in ones are the rules that judge code against thresholds a team picks, such as INW012, the rules that judge the content of a role's modules, such as INW013, INW015, INW016 and INW020, the rules that check docs against the config and the code, INW017, INW018 and INW019, a convention only some projects follow, INW021, and framework families such as FastAPI. SARIF lists an opt-in rule with defaultConfiguration.enabled set to false.

A rule's options live in a table named after the rule, [tool.inwards.rules.<rule-name>]. Some rules take options of their own, listed on their pages (INW012, INW013, INW014, INW015, INW016, INW020, INW021, FAPI001, FAPI002). Every rule takes modules, a list of module prefixes or selectors written as in a layer's modules (shop.domain covers that package and everything below it, shop.*.api uses wildcards): the rule then reports only in the modules they match. An unknown key or a wrong type is a config error that names the key.

pyproject.toml
[tool.inwards.rules.pure-domain]
modules = ["shop.domain"]  # INW005 reports only in shop.domain and below

The opt-in content rules, INW012 to INW016, INW020 and the FAPI rules, and INW021 skip test modules: a module whose last name segment is test_* or *_test, or that has a tests, test or conftest segment, such as app/tests/test_orders.py, tests/conftest.py or app/orders_test.py. A test builds a throwaway app or imports an adapter on purpose, so a finding there is rarely worth fixing (ADR-037). check-tests = true in the rule's table brings them back for that rule. The other rules check test modules like any other module, and the top-level ignore doesn't scope the content rules.

pyproject.toml
[tool.inwards.rules.async-blocking]
check-tests = true  # INW013 also reports in tests/ and test_*.py

A template can turn an opt-in rule on for one of its roles, such as router = { async-blocking = true }; it expands into extend-select and the rule's options table, with the role's modules in modules.

An options table doesn't turn a rule on. A table for a rule that is off (opt-in and not selected, or listed in ignore) does nothing, so inwards check reports a warning at the table in pyproject.toml, under the rule's code; that lets a team stage a rule's options before turning it on.

To accept one finding for good, put a suppression on the line it points at, with the rule's code and a reason (ADR-028):

shop/domain/order.py
from shop.infrastructure.legacy import LegacyClient  # inwards: ignore[INW001] reason="old billing adapter, removed in #210"

Rules that point at a line of Python can be suppressed this way: INW001, INW002, INW003, INW005, INW006, INW010, INW011, INW012, INW013, INW014, INW015, INW016, INW020, INW021 and the FAPI rules. A suppression without a reason, with an unknown code or with a code that can't be suppressed hides nothing and is reported as INW009. The Claude Code hooks ignore a suppression the agent added during the session unless agent-suppressions = "allow".

Page format

Each rule page starts with front matter in the OKF format: type: rule, title, description, resource (the rule's source file), tags (the category, parent first), timestamp (when the rule shipped), status, and the Inwards fields code, name, severity, suppressible, autofix and related_issues. Every page also has the sections What it does, Why is this bad, Example (a flagged and a fixed code block) and Fix safety, plus a row in a table on this page and in its category's page (rules/<category>/), and an entry in its category's group of the navigation, in docs/zensical.toml and docs/zensical.pl.toml. uv run scripts/check-rule-pages.py checks all of that in both languages, in CI too, and writes the rules.json the list above reads; after changing a rule page or a row here, run it with --write. A test checks that every registered rule has a page and every page names a registered rule. The flagged and fixed examples run as tests too, against the output shown on the page.