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):
[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.
[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.
[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):
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.