INW006 unassigned-module¶
What it does¶
Inwards only checks code that belongs to a layer. INW006 makes the rest visible:
| Finding | Where | Severity |
|---|---|---|
A file in a layer imports first-party code that belongs to no layer, static or dynamic. That includes the package above the layers: from shop import x runs shop/__init__.py, which no layer owns |
the import | error |
A package outside every layer and outside ignore |
its __init__.py, or its first file |
warning |
| A layer prefix or selector that matches no module | pyproject.toml |
warning |
| A layer none of whose entries matches a module | pyproject.toml |
error |
A nested project under root: a directory with its own pyproject.toml whose code sits in a src/ folder, such as a uv workspace member |
pyproject.toml, at root |
warning |
A symlink inside a layer (to a directory, or to a .py or .pyi file) whose real target lies outside root, in another layer, or above the layers. Python imports the code behind it under the link's name, but no rule reads a target outside root, and INW001 takes code from another layer for the link's own layer. Links are followed while they stay in root, so a chain through code outside every layer counts, and so does a link above the layers that leaves root where a layer could match (shop/payments under shop.*.domain) (#83, #84) |
the link | error |
| During an agent session: a prefix that matched modules at session start and matches none now, a selector's slice that held modules and holds none now, layer code moved out of every layer, or a symlink like the one above that is new or points elsewhere since the session started | the Stop gate | error |
With selectors, a package counts as holding a layer only when the project has a module below it that the selector matches: with shop.*.domain, shop/util.py still gets the warning, and so does shop/orders/core/ under shop.**.domain. A selector that matches no module is dead whatever the precedence, and its finding points at the selector's text.
A symlink within its own layer is fine, and so is one into code outside every layer: that code is checked under the link's name, as the layer's code, and gets the per-package warning under its own. For example, with the layers from INW001, ln -s ../infrastructure shop/domain/infra_alias gives:
shop/domain/infra_alias:1:1: INW006 shop/domain/infra_alias is a symlink to "shop.infrastructure", in layer "infrastructure": an import of "shop.domain.infra_alias" (layer "domain") loads that code, and no layer rule sees the dependency.
During an agent session, a top-level package that appears can turn an old import into first-party code: requests/__init__.py added under import requests in a layer. The Stop gate checks every file that spells the new name as a top-level name, against a session-start check that doesn't see the package, so the INW006 error blocks even though the importing file didn't change (#86).
ignore entries in [tool.inwards] match whole name segments anywhere, so ignore = ["migrations"] covers shop.orders.migrations; an entry that starts with / matches top-level names only, so /tests leaves shop/tests/ warned about (ignore). Unknown keys and a prefix listed in two layers are config errors (exit 2).
Why is this bad¶
When an import into an outer layer is blocked, an agent can create a new package that no layer claims, shop/persistence/ or shop/common/, and import that instead. Nothing checks what the new package imports, so it can reach the database freely. Moving a layer away (git mv shop/domain shop/core) empties its prefix and turns the whole layer off. INW006 closes both routes, and a dead prefix in a hand-written config (a typo, a renamed package) gets the same attention.
Example¶
With the layers from INW001, an agent puts a value type in a new shop.common package and imports it from the domain:
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
cents: int
from shop.common.money import Money
def total(prices: list[Money]) -> Money:
return Money(sum(p.cents for p in prices))
shop/common/__init__.py:1:1: INW006 (warning) "shop.common" belongs to no layer, so no layer rule checks its imports.
fix: Put the code under a layer's package, or ask the user to assign "shop.common".
1. If this code is part of the application, move it under the package of the layer it belongs to.
2. Otherwise ask the user to add "shop.common" to a layer, or to `ignore` in [tool.inwards] if it is tooling. Don't edit [tool.inwards] yourself.
docs: https://sircypkowskyy.github.io/inwards/rules/INW006/
shop/domain/order.py:1:31: INW006 Layer "domain" imports "shop.common.money.Money", which belongs to no layer, so no layer rule checks what "shop.common" imports.
fix: Move the code into a layer, or ask the user which layer "shop.common" belongs to.
1. If the code belongs to "domain" or an inner layer, move it under that layer's package and import it from there.
2. Otherwise ask the user to add "shop.common" to a layer in [tool.inwards]. Don't edit [tool.inwards] yourself.
docs: https://sircypkowskyy.github.io/inwards/rules/INW006/
Fixed: Money is a domain value, so it moves into the domain.
from shop.domain.money import Money
def total(prices: list[Money]) -> Money:
return Money(sum(p.cents for p in prices))
A prefix that matches nothing is a warning on pyproject.toml:
[tool.inwards]
layers = [
{ name = "domain", modules = ["shop.domain"] },
{ name = "application", modules = ["shop.application"] },
{ name = "infrastructure", modules = ["shop.infrastructure", "shop.adapters"] },
]
pyproject.toml:5:64: INW006 (warning) "shop.adapters" (layer "infrastructure") matches no module.
fix: Ask the user to fix or remove "shop.adapters" in [tool.inwards].
1. Check the prefix for a typo: a prefix that matches nothing leaves the code it was meant for unchecked.
2. Don't edit [tool.inwards] yourself; tell the user.
docs: https://sircypkowskyy.github.io/inwards/rules/INW006/
How to fix¶
- An import into unassigned code: if the code belongs to the importing layer or an inner one, move it under that layer's package and import it from there. Otherwise the user decides which layer it belongs to and adds its prefix to
[tool.inwards]. - A package outside every layer: move it under a layer, or have the user assign it to a layer, or add it to
ignorewhen it is tooling (tests, scripts, migrations). - A dead prefix: fix the typo or remove the prefix. An agent tells the user; it doesn't edit the config.
- A layer or slice moved away during a session: move it back. Renaming or deleting a whole slice needs the user, as renaming a layer's package does.
- A symlink inside a layer: delete the link and import the code by its real name, so INW001 checks the dependency. Code that lives outside
rootmoves under it, becomes an installed package, or gets its own[tool.inwards]. - A nested project: give each workspace member its own
[tool.inwards].inwards checkat the workspace root then checks every member with its own config and leaves the member's files out of the root's config (Monorepos and uv workspaces).
Fix safety¶
No automatic fix (autofix: false). Where code belongs is a design decision, and every config change is the user's.
Configuration¶
Severity error, with the warnings in the table above. [tool.inwards.rules] can turn the rule off or set every finding's level: with severity = { INW006 = "error" }, the per-package warning becomes an error too (Configure rules). The Stop gate's session layout check (a prefix emptied, layer code moved away or a symlink made in a layer since the session started) ignores the table, so even ignore = ["INW006"] doesn't let an agent move a layer away (ADR-027). An import finding can be suppressed on its line with # inwards: ignore[INW006] reason="...".
In the Claude Code hooks, an edit with only INW006 warnings stands, and the warnings go to the agent as context.
Known limitations¶
- The imports inside an unassigned package aren't checked against the layers until the user assigns it; INW006 only makes it visible. Once contexts are declared, INW002 and INW003 still check its imports.
inwards checkwith path arguments, andinwards serveras you type, check files one by one and don't report dead prefixes, nested projects or symlinks in layers. Only a whole-project run does (the server's whole pass on start and on save is one), and the Stop gate for a symlink made during the session.inwards check --helpsays so (#86): a check of a few files can't tell whether a prefix matches nothing elsewhere.- Every link out of
rootinside a layer is reported, including one to a directory of data files: Inwards doesn't look behind a link it can't check. Keep such a directory outside the layer's package, or accept the finding in the baseline. - A nested project is a warning, not an error. Its package gets a path-derived name (
src/packages/core/src/coreispackages.core.src.coreunderroot = "src"), while other members import it ascore, which matches no module. Such an import is taken for a third-party one and passes, so a layer violation between members isn't reported. The warning is how that becomes visible (#201). Run without--config,inwards checkleaves a member with its own[tool.inwards]to that config, so only members without one are warned about. - A compiled or sourceless module (
shop/persistence.cpython-313-x86_64-linux-gnu.so,.pyd,.pyc, or Cython's.pyx) is first-party code, so an import of it from a layer is an error when it lies outside every layer (#86). Inwards can't read what such a module imports: it gets no per-package warning, and a layer prefix that only such files match counts as dead. - A layer module renamed, edited and moved to a new top-level module that nothing imports passes the Stop gate with the per-package warning only. The gate catches a move into a package that holds layers, and a move elsewhere that keeps the file name or the content. Rename detection by similarity was left out on purpose (#86):
git diff -Mruns git's diff machinery, which reads attributes and config the agent can write, on the agent's repository, and scoring similarity against the session-start content would read every vanished and every new file on each Stop and still be a guess with a threshold to tune. While nothing imports the moved code, it can't carry a dependency into a layer, and the first import of it from a layer is an INW006 error. - An import INW010 reports as a missing module gets no INW006 as well.