Skip to content
Back to skills

Test Architecture Rule

ASecurity

Use when forbidding X in layer Y with a static grep firewall — an architecture test, its path constants, a rule fragment, a bounded allow-list. Owns `tests/unit/test_architecture.py` in a standalone distributable and `tests/test_architecture.py` at the root of a repository of several members. Runtime behaviour belongs in an ordinary test; the constitution is `test-principles`.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 14, 2026
developmentpythongonodetestinggitapi

Works with

  • api

Security analysis

A100/100

Scanned October 14, 2026

npx -y skills add wntic/pyhouse --skill test-architecture-rule --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Test Architecture Rule?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Test Architecture Rule
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/wntic-test-architecture-rule/badge)](https://www.skillsdirectory.com/skills/wntic-test-architecture-rule)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: test-architecture-rule
description: Use when forbidding X in layer Y with a static grep firewall — an architecture test, its path constants, a rule fragment, a bounded allow-list. Owns `tests/unit/test_architecture.py` in a standalone distributable and `tests/test_architecture.py` at the root of a repository of several members. Runtime behaviour belongs in an ordinary test; the constitution is `test-principles`.
---

# Test — Architectural Firewall Rule

Each function greps the source tree for a forbidden pattern and asserts the result is empty. What the file holds is wider than its name: every rule here is a property of the *source text* that no runtime test can reach, and most but not all of those are architectural — a ban on exiting the process outside the entry point is a rule about the process, enforced the same way. The name is the established one for this artifact and is worth keeping; read it as "the static source rules", not as a promise that every rule in the file is about layering. Where the file sits follows the shape of the tree, not the architecture family: one distributable puts it at `tests/unit/test_architecture.py`; a repository holding several members puts it at `tests/test_architecture.py` beside them, because its subject is the repository rather than anything in it. The firewall must stay collectable when the tree is broken.

## When to use vs. neighbours

- A static, absolute "layer Y must not import X" invariant the codebase needs enforced → this skill.
- Shared testing constitution → `test-principles`. That is prose the whole suite obeys; this is one
  grep that reddens a build.
- Runtime domain behavior → `hex-test-domain`, in the `pyhouse-hex` plugin.
- Runtime storage behavior → `flat-test-persistence`, in the `pyhouse-flat` plugin. Greps enforce
  static structure; runtime tests enforce dynamic behavior.
- Hex layer boundaries → `hex-architecture`, in the `pyhouse-hex` plugin.
- Flat repository boundaries → `flat-layered`, in the `pyhouse-flat` plugin.
- A type-correctness rule → `mypy` / `pyright` enforce it; do not duplicate as a grep test.
- A style or formatting rule → `ruff` enforces it; do not duplicate.
- A "should usually" rule with material exceptions → not a firewall candidate; document it in the
  skill that owns the layer instead. Firewalls are absolutes that accumulate exceptions and stop
  paying for themselves.
- An intent-based rule ("do not use `Any` *unless* at a true external boundary") → not a firewall
  candidate; greps either hit or do not, with no intent inspection.

## Template(s) — `grep` through `subprocess`, one plain pytest function per rule

### File scaffold (once per repository)

The standalone form follows — one distributable, one `src/`, one `tests/`. A repository of several
members replaces the path-constant block with the multi-member fragment below; the imports, the
`_grep` helper and every rule in this skill are identical in both.

```python
import subprocess
from pathlib import Path

_ROOT = Path(__file__).resolve().parents[2]
_SRC = str(_ROOT / "src" / "myapp")
_TESTS = str(_ROOT / "tests")
_UNIT_TESTS = str(_ROOT / "tests" / "unit")


def _grep(pattern: str, *paths: str) -> list[str]:
    if not paths:  # a glob that matched nothing must not leave grep reading stdin
        return []
    result = subprocess.run(
        [
            "grep",
            "-rnE",
            "--include=*.py",
            "--exclude=test_architecture.py",
            pattern,
            *paths,
        ],
        capture_output=True,
        text=True,
    )
    return [line for line in result.stdout.splitlines() if line]
```

Patterns run under `-E`: basic `grep`'s `\|` alternation is a GNU extension that BSD `grep` does not
reliably carry, while `-E` behaves the same on both. `grep` exits 1 when it finds nothing, which is the
ordinary case, so the helper must not check the return code — it reads `stdout` and lets an empty result
be an empty list. `parents[2]` walks up from `tests/unit/`; a firewall placed directly under `tests/`
uses `parents[1]`, and that constant is the only line the move changes.

### Multi-member path constants

For a repository of several members with the firewall at `tests/test_architecture.py`, replace the
scaffold's constants with the block below. The member directory names are a constant so that a
repository grouping its members differently overrides one tuple and every glob follows. `_TESTS` is a
list here, so a rule splats it: `_grep(pattern, *_TESTS)`.

```python
_ROOT = Path(__file__).resolve().parents[1]

# The directories under which this repository's members live, as it declared them.
_MEMBER_DIRECTORIES = ("packages", "services")
# Source trees only: a member's tests/ legitimately names what its src/ may not.
_SRC_DIRECTORIES = [str(path) for directory in _MEMBER_DIRECTORIES for path in _ROOT.glob(f"{directory}/*/src")]

# Tests live beside the member they cover, plus the root's own.
_TESTS = [str(path) for directory in _MEMBER_DIRECTORIES for path in _ROOT.glob(f"{directory}/*/tests")] + [
    str(_ROOT / "tests")
]
_UNIT_TESTS = [str(path) for directory in _MEMBER_DIRECTORIES for path in _ROOT.glob(f"{directory}/*/tests/unit")]
```

### Standard rule (no allow-list)

```python
def test_no_<rule_name>() -> None:
    hits = _grep("<pattern>", <paths>)
    assert hits == [], "<message>:\n" + "\n".join(hits)
```

Concrete, in the standalone form:

```python
def test_no_future_annotations_anywhere() -> None:
    hits = _grep(r"^from __future__ import annotations\b", _SRC, _TESTS)
    assert hits == [], "from __future__ import annotations found:\n" + "\n".join(hits)
```

The multi-member form is the same function over `*_SRC_DIRECTORIES, *_TESTS`.

### Rule with an in-test allow-list

The one allow-listed path is a constant at the top of the file, beside the others (rule 4). The example
is `exception-catalog` rule 13 enforced: the entry point is the one scope that renders a caught error and
maps it to an exit code, so a process exit anywhere else is a second rendering:

```python
_ENTRYPOINT = str(_ROOT / "src" / "myapp" / "__main__.py")


def test_no_process_exit_outside_the_entrypoint() -> None:
    all_hits = _grep(r"\bsys\.exit\(", _SRC)
    forbidden = [hit for hit in all_hits if not hit.startswith(_ENTRYPOINT)]
    assert forbidden == [], "sys.exit() outside the entry point:\n" + "\n".join(forbidden)
```

The pattern stays simple; the exception is explicit and visible to whoever reads the failure. A
library has no entry point, so there the rule is a standard one with no allow-list.

### Adding a new path constant (when a new scope is needed)

Append at the top of the file, next to the existing constants:

```python
_<SCOPE> = str(_ROOT / "src" / "myapp" / "<package>")
```

## Other bindings

- **Import-linter contracts**, declared in config and run as their own command. Forbidden
  relationships become layer and forbidden-module contracts instead of patterns, and because it
  resolves real imports there are no word-boundary or docstring false positives. One rule per
  contract, the named allow-list, the failure naming the offending module and the never-ship-it-red
  rule are unchanged; the firewall stops being a test, so collectability moves to the linter's run.
- **A linter's banned-API rule** (`flake8-tidy-imports`' `banned-api` under ruff). Cheapest — it runs
  in a pass the project already has, scoped per directory. It reaches import rules only, so every
  non-import invariant (a process exit, a sleep, a module-level engine) stays here and most projects carry
  both, and the hard stop on restating what the linter enforces decides which file a rule goes in.
- **An `ast` walk over the tree.** Distinguishes an import from the same word in a docstring, and a
  module-level call from one nested in a function. Only `_grep` is replaced — every rule below holds,
  and rule 8's escaping advice becomes a node test instead.

## Rules

Consult `test-principles` for the testing constitution. Where this skill contradicts
`test-principles`, the constitution wins. The *words inside* a rule's name — which layer, which
thing, which verb — are `naming`'s decision; the **patterns those words go into are rule 2 here**.

1. **One test function per rule, with nothing around it.** No fixtures, no parametrization, no async —
   `def test_*() -> None` under the pytest binding — and no `try/except` or conditional skip around the
   search, which would break the unconditional property that makes a firewall worth having. The test name **is** the rule, and the file's test
   list reads as the workspace's structural constitution.
2. **A rule's name states its scope and what is absent, in the form its meaning takes.** Do not
   pluralize, do not add qualifiers.
   - **A scope has no X:** `test_<scope>_has_no_<thing>`, e.g. `test_domain_has_no_pydantic`.
   - **X only inside a scope:** `test_no_<thing>_outside_<scope>`, e.g.
     `test_no_statement_outside_the_data_access_package`.
   - **X nowhere:** `test_no_<thing>`, e.g. `test_no_future_annotations_anywhere`.
   A name that does not say where the rule looks sends a reader to the pattern to find out, and the
   file stops being readable as a constitution.
3. **The failure names every offending location, never a count or a boolean.** Assert the collected
   hits against an empty collection so the runner prints the actual list beside the expected one —
   `assert hits == []` under pytest. `assert not hits` and `assert len(hits) == 0` say the firewall is
   red and nothing about where.
4. **Every scope a rule names is a named constant at the top of the file.** Add a new `_<NAME>`
   constant when a new scope is needed; a path written inside a test drifts silently when the tree
   moves, and the rule keeps passing over a directory that no longer exists.
5. **Run the rule against the current tree before committing.** Confirm zero unexpected hits, and
   never ship a firewall that is already red — narrow it or fix the hits. A rule that arrives red
   teaches everyone to skip it. A sweep widened over a directory holding a sanctioned exception lands
   with that exception's allow-list entry, in the same change.
6. **Exceptions are allow-listed inside the test, by name, and cap at three.** Filter the result
   against named paths — `startswith(...)` against a path constant under the grep binding — rather
   than weakening the pattern, so the pattern stays readable and the exception is visible to whoever
   reads the failure. A fourth entry means the rule has too many exceptions to be a firewall: split it
   into something more specific, or demote it to prose.
7. **A rule never imports what it forbids, or anything from the tree it polices.** Importing it
   defeats the firewall, and the file must stay collectable when the tree is broken — a broken import
   turns "the rule failed" into "the rule could not be collected", which reads as green in some
   reports.
8. **A pattern matches the whole token it names and nothing that merely contains it.** Under
   `grep -E` that is raw strings wherever a backslash appears, `\b` at both ends of a bare word, and
   plain `|` for alternation. A pattern that also catches a longer identifier, a comment or a
   docstring makes the rule's own name a lie, and the first false hit is what gets it deleted.
9. **An exemption for a package because of the role it holds reads the role's name from a constant
   the project declared, never from a directory name** — a hardcoded name is green on every project
   that named that package something else, and checks nothing. One constant per declared name; a
   per-member override table is a second allow-list with no cap (rule 6).

Each architecture family lists the invariants worth a firewall in its own architecture skill
(`hex-architecture`, in `pyhouse-hex`; `flat-layered`, in `pyhouse-flat`).

## Inlined typing / import rules

- `subprocess` and `pathlib.Path` only at the file level (already present).
- In a grep rule, never import from `myapp` — importing what you're trying to forbid defeats the firewall.
- Tests are sync `def test_*() -> None`.

## Hard stops

- The rule depends on intent or on runtime state → stop, this is not a grep-firewall rule; runtime
  behaviour is an ordinary test, and an intent-based rule is prose in the skill that owns the layer.
- The rule restates something the linter or type checker already enforces → stop, two enforcers for one
  rule means two places to change it.
- A "no `Protocol` in services" rule is proposed → stop unless the repository genuinely has none: a
  style that permits a port the moment a second implementation exists turns this into an allow-list
  that outgrows three entries, which rule 6 forbids. It is prose, not a firewall — the flat family
  already carries it as prose in `flat-layered`, in the `pyhouse-flat` plugin.
- Asked for the testing constitution the whole suite obeys → stop, use `test-principles`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.