Skip to content

INW007 package-shape

What it does

Checks where code lives, against the shapes in [[tool.inwards.shape]] and the name rules in [[tool.inwards.names]]. It reports:

  • a member of a shaped package that allow doesn't cover (error, or a warning with extra = "warning"), or that forbid names (error);
  • a member whose name matches a [[tool.inwards.names]] pattern, anywhere outside that rule's only-in packages (error);
  • a shape selector that matches no package (warning, on pyproject.toml).

A finding sits on line 1 of the member's file. A shape covers a package's direct members: modules, stubs and subpackages. The message names the member and the package, never the allowed list, so a baseline entry survives a change to allow; the fix lists the allowed members and names the likely target. INW008 reports the other direction, a required member that is missing.

Why is this bad

Import rules can't see where code lives. An agent that needs a small function adds helpers.py next to service.py, a services/ package beside service.py, or a test_orders.py inside the app. Each one passes the layer rules, and each one drifts the codebase away from the layout the team agreed on, one edit at a time. INW007 blocks the edit that creates the file and says where the code should go.

Example

The layers from INW001, with a shape for the application package and a rule that keeps tests under tests/:

pyproject.toml
[tool.inwards]
ignore = ["tests"]
layers = [
  { name = "domain",         modules = ["shop.domain"] },
  { name = "application",    modules = ["shop.application"] },
  { name = "infrastructure", modules = ["shop.infrastructure"] },
]

[[tool.inwards.shape]]
packages = ["shop.application"]
allow = ["place", "cancel", "utils"]

[[tool.inwards.names]]
pattern = "test_*"
only-in = ["tests", "tests.**"]

An agent adds a helper module:

shop/application/helpers.py
def cents(amount: float) -> int:
    return round(amount * 100)
inwards check
shop/application/helpers.py:1:1: INW007 "helpers.py" is not an allowed member of package "shop.application".
  fix: Move the code in "helpers.py" into utils.py.
    1. Move the code into shop/application/utils.py and delete helpers.py.
    2. Package "shop.application" may hold: place, cancel, utils.
    3. Don't edit [tool.inwards] yourself. If the package really needs this member, ask the user to change its [[tool.inwards.shape]].
  docs: https://sircypkowskyy.github.io/inwards/rules/INW007/

Fixed: the code goes where the shape says.

mv shop/application/helpers.py shop/application/utils.py
inwards check

A test file inside the app breaks the name rule:

shop/application/test_place.py
def test_place() -> None:
    assert True
inwards check
shop/application/test_place.py:1:1: INW007 "test_place.py" matches the name pattern "test_*", which doesn't belong in package "shop.application".
  fix: Move "test_place.py" under tests/.
    1. Move it into a package matching tests, tests.**, for example tests/.
    2. Don't edit [tool.inwards] yourself. If the package really needs this member, ask the user to change [[tool.inwards.names]].
  docs: https://sircypkowskyy.github.io/inwards/rules/INW007/
mkdir -p tests
mv shop/application/test_place.py tests/
inwards check

How to fix

  1. Move the code into the member the fix names. It is found through built-in synonyms (helpers → utils, services → service, test_* → tests), a suffix match (order_service → service) or a small edit distance (rooter → router).
  2. Delete the file that isn't allowed.
  3. If the package really needs the member, the user changes its [[tool.inwards.shape]] or [[tool.inwards.names]]. An agent asks; the config guard stops it from editing the table.

Fix safety

No automatic fix (autofix: false). Moving code between modules changes its import path, so every importer has to follow, and a person should check the result.

Configuration

Severity error; extra = "warning" on a shape makes members allow doesn't cover a warning. Package shape documents the selectors, member patterns and name rules. [tool.inwards.rules] can turn the rule off or make it a warning (Configure rules). INW007 can't be suppressed inline: it is about the package tree, and the shape config is the place to change it (ADR-028).

In the Claude Code hooks, INW007 blocks the edit when the file is new this session. For a file that existed when the session started it is context only, so legacy layout never blocks an unrelated edit, and the Stop gate doesn't block on it either.

A Write that would create a disallowed .py or .pyi file is denied before the file exists (the shape guard, #96), with this message and fix as the reason, so the agent writes the code into the right member instead of creating the file, then moving it and deleting it through Bash. It works the same in the OpenCode plugin, whose write goes through the same hook. Each denial counts toward escalate-after like the INW007 it prevents, and the one that reaches the limit tells the agent to ask the user. Edits of existing files and files created through Bash are checked after the fact, by the per-edit hook and the Stop gate. If the guard itself fails, the Write goes ahead and those checks catch the file.

Known limitations

  • Named templates, role layering and inwards init --style fastapi are open in #97.
  • Only a Write that creates a file is denied before the file exists. A file created through Bash, or a disallowed member reached by an edit, is reported after the fact, by the per-edit hook and the Stop gate.
  • Only Python files and packages count, and only where they are, not what they contain.

References