Rules are the core building blocks of psl. Each rule declares a structural primitive (its type), scopes itself to part of the tree, and enforces a constraint on the filesystem shape or a module's symbol population. psl never inspects imports or names — only the shape of the tree and how many symbols each module defines.
Structure¶
Every rule has two required fields, one optional field, and a set of type-specific options. Unlike pnl, there is no nested filter/naming block and no apply/modules key — each rule scopes itself with its own within, source, or mirror, and the type-specific options sit flat on the rule mapping:
rules:
- name: my-rule # Unique identifier, referenced in `# psl: ignore`
type: directory-exists # Which primitive to enforce
description: ... # (optional) shown in violation output
within: src/contexts/{ctx} # type-specific options follow, flat
require: [domain, application]
The engine is neutral: your project's taxonomy (layer names, component directories, tree roots) lives entirely in the option values, never in the rule vocabulary.
Fields¶
| Field | Required | Description |
|---|---|---|
name |
Yes | Unique identifier, referenced in # psl: ignore. Must match [a-zA-Z0-9_.-]+ |
type |
Yes | The rule primitive: directory-exists, allowed-children, mirror, or symbol-count |
description |
No | Human-readable description shown in violation output |
| (type-specific options) | Varies | Every other key is an option, validated against that type's schema |
An unknown type, an unknown option, a missing required option, or an invalid enum value is a configuration error and exits 2.
Rule types¶
| Type | Enforces |
|---|---|
directory-exists |
Every scope directory contains the required child directories |
allowed-children |
A scope directory contains only allowed child names |
mirror |
Two trees (e.g. src and tests) mirror each other |
symbol-count |
A module defines a bounded number of top-level symbols |
Path patterns¶
within, and the tree roots in mirror, are project-root-relative paths whose segments may be:
| Token | Matches |
|---|---|
| literal | a segment with that exact name |
* |
exactly one segment |
** |
zero or more segments |
{name} |
one segment, captured as name (behaves like *, aids readability) |
File-targeting rules (symbol-count) match .py files; the others match directories. The top-level include and exclude keys narrow and drop scanned paths across all rules — except mirror, whose two trees are named explicitly.
directory-exists¶
Every directory matching within must contain each of the require directories.
Options¶
| Option | Required | Description |
|---|---|---|
within |
Yes | Path pattern selecting the scope directories |
require |
Yes | List of directory names that must exist directly inside each scope |
Example — every context declares its layers:
rules:
- name: context-layers
type: directory-exists
description: Each context has domain, application, and adapters layers
within: src/contexts/{ctx}
require: [domain, application, adapters]
| Scope directory | Contents | Result |
|---|---|---|
src/contexts/orders |
domain/, application/, adapters/ |
Pass — all required directories present |
src/contexts/orders |
domain/, application/ |
Violation — missing required directory 'adapters' |
src/contexts/orders |
domain/, application/, adapters.py |
Violation — adapters must be a directory, not a file |
$ psl check
src/contexts/orders
[context-layers] Each context has domain, application, and adapters layers
missing required directory 'adapters'
Found 1 violation(s).
One violation is reported per missing directory, anchored to the scope directory.
allowed-children¶
A scope directory may contain only the allowed child names. With group_level: optional, a child directory that is not itself an allowed name is treated as a one-level grouping tier (e.g. a subdomain) and passes only if all of its children are allowed.
Options¶
| Option | Required | Default | Description |
|---|---|---|---|
within |
Yes | — | Path pattern selecting the scope directories |
kind |
No | dir |
Which children to inspect: dir, file, or any |
allow |
No | [] |
Exact child names that are permitted |
allow_patterns |
No | [] |
Glob patterns (fnmatch) of permitted child names |
group_level |
No | none |
none or optional — how an unlisted child directory is treated |
ignore |
No | [] |
Child names to skip entirely |
kind values:
| Value | Inspects |
|---|---|
dir (default) |
Only child directories |
file |
Only child files |
any |
All child entries |
group_level values:
| Value | An inspected child not matched by allow/allow_patterns |
|---|---|
none (default) |
Is an immediate violation (unexpected directory / unexpected file) |
optional |
If it is a directory, is treated as a one-level grouping tier: it passes only if every one of its own children is allowed and it holds at least one inspected child. Grouping does not nest, and an empty / file-only / ignored-only grouping directory is a violation |
Example — the domain layer holds only recognized components, with subdomain grouping:
rules:
- name: domain-components
type: allowed-children
description: The domain layer holds only recognized component directories
within: src/contexts/{ctx}/domain
kind: dir
group_level: optional
allow: [entities, value_objects, events, services, errors]
ignore: [__init__.py]
Child of src/contexts/orders/domain |
Result |
|---|---|
entities/ |
Pass — an allowed component |
value_objects/ |
Pass — an allowed component |
repositories/ |
Violation — unexpected directory 'repositories' (not an allowed component) |
shipping/ holding entities/, errors/ |
Pass — grouping tier, every child allowed |
shipping/ holding entities/, repositories/ |
Violation — unexpected 'repositories' in grouping directory 'shipping' |
shipping/ empty or file-only |
Violation — 'shipping' is neither an allowed component nor a grouping directory (holds no sub-components) |
__init__.py |
Not checked — in ignore (and skipped anyway, since kind: dir) |
$ psl check
src/contexts/orders/domain/repositories
[domain-components] The domain layer holds only recognized component directories
unexpected directory 'repositories' (not an allowed component)
src/contexts/orders/domain/shipping/repositories
[domain-components] The domain layer holds only recognized component directories
unexpected 'repositories' in grouping directory 'shipping'
Found 2 violation(s).
With group_level: none, shipping/ would itself be reported as unexpected directory 'shipping' (not an allowed component) rather than being inspected as a grouping tier.
mirror¶
The source and mirror trees must mirror each other at directory granularity. direction selects which way the requirement runs.
Options¶
| Option | Required | Default | Description |
|---|---|---|---|
source |
Yes | — | Root of the source tree (project-root-relative), e.g. src |
mirror |
Yes | — | Root of the mirror tree, e.g. tests |
direction |
No | mirror-to-source |
Which way the requirement runs (see below) |
granularity |
No | directory |
Comparison granularity (directory is the only supported value) |
ignore |
No | [] |
Path segments skipped on both sides, with their whole subtree, e.g. __pycache__ |
mirror_extra_allowed |
No | [] |
Mirror-side directory names exempt from the mirror-to-source check (test scaffolding) |
source_extra_allowed |
No | [] |
Source-side directory names exempt from the source-to-mirror check |
direction values:
| Value | Requirement | Message on failure |
|---|---|---|
mirror-to-source (default) |
Every mirror directory must have a source counterpart — the mirror tree invents no structure of its own |
no source counterpart 'src/<rel>' |
source-to-mirror |
Every source directory must have a mirror counterpart |
missing mirror of 'src/<rel>' |
both |
Both requirements hold | either, as applicable |
The top-level include does not apply to mirror — the two trees are named explicitly by source and mirror. A missing or mistyped source/mirror root is a configuration error and exits 2.
Example — the tests tree mirrors the src tree:
rules:
- name: tests-mirror-src
type: mirror
description: The tests tree mirrors the src tree
source: src
mirror: tests
direction: mirror-to-source
mirror_extra_allowed: [doubles, fixtures, conftest]
tests directory |
src counterpart |
Result |
|---|---|---|
tests/contexts/orders/domain/entities |
src/contexts/orders/domain/entities exists |
Pass |
tests/contexts/orders/domain/pricing |
no counterpart | Violation — no source counterpart 'src/contexts/orders/domain/pricing' |
tests/contexts/orders/domain/entities/doubles |
no counterpart, doubles in mirror_extra_allowed |
Pass — exempt scaffolding at the orphan boundary |
tests/__pycache__/... |
— | Not checked — __pycache__ in ignore |
$ psl check
tests/contexts/orders/domain/pricing
[tests-mirror-src] The tests tree mirrors the src tree
no source counterpart 'src/contexts/orders/domain/pricing'
Found 1 violation(s).
An extra-allowed name exempts only the orphan itself (a directory with no counterpart whose leaf name is exempt) and its subtree — a same-named directory that does have a counterpart is still fully checked below it.
symbol-count¶
Bound the number of top-level symbols (classes or functions) a module may define. Definitions wrapped in flow-control statements (try/except, if TYPE_CHECKING:, with, for, while, match) count as top level; definitions nested inside another class or function do not.
Options¶
| Option | Required | Default | Description |
|---|---|---|---|
within |
Yes | — | Path pattern selecting the .py files to inspect |
symbol |
No | class |
class or function — which top-level definitions to count |
visibility |
No | any |
any counts all; public counts only names not starting with _ |
max |
No* | — | Maximum permitted count |
min |
No* | — | Minimum permitted count |
ignore_files |
No | [] |
File names to skip, e.g. [__init__.py] |
* At least one of max / min is required, and both must be integers.
symbol values:
| Value | Counts |
|---|---|
class (default) |
Top-level class definitions |
function |
Top-level def / async def definitions |
visibility values:
| Value | Counts |
|---|---|
any (default) |
Every matching definition |
public |
Only names that do not start with _ — constrain a module's public surface while leaving private helpers unrestricted |
Example — one class per domain module:
rules:
- name: one-class-per-domain-module
type: symbol-count
description: Domain modules define a single class
within: src/contexts/{ctx}/domain/**
symbol: class
max: 1
ignore_files: [__init__.py]
| Module | Top-level classes | Result |
|---|---|---|
entities/order.py with class Order |
1 | Pass |
entities/order.py with class Order, class OrderLine |
2 | Violation at the surplus class — 2 top-level class definitions found, at most 1 allowed |
a module with class Order under if TYPE_CHECKING: and class OrderView |
2 | Violation — flow-control-wrapped definitions still count |
a module with class Order and a nested class Meta inside it |
1 | Pass — nested definitions are not top level |
__init__.py |
— | Not checked — in ignore_files |
$ psl check
src/contexts/orders/domain/entities/order.py:24
[one-class-per-domain-module] Domain modules define a single class
2 top-level class definitions found, at most 1 allowed
Found 1 violation(s).
A max violation is anchored to <path>:<line>, where the line is the surplus symbol — the first definition beyond max. A min violation is anchored to the path alone.
Example — a single error class per errors module:
rules:
- name: one-error-per-file
type: symbol-count
description: Each errors module defines a single error class
within: src/contexts/{ctx}/domain/**/errors/**
symbol: class
max: 1
ignore_files: [__init__.py]
So src/contexts/orders/domain/errors/order_not_found_error.py may hold only class OrderNotFoundError; a second error class in the same file is a violation.
Example — exactly one public function per checker module (min + max):
rules:
- name: one-public-function-per-checker
type: symbol-count
description: Each checker exposes a single public entry point
within: src/checks/**
symbol: function
visibility: public
min: 1
max: 1
| Module | Public top-level functions | Result |
|---|---|---|
check.py with def check(...) and def _helper(...) |
1 | Pass — _helper is private, so not counted |
check.py with no public function |
0 | Violation — 0 top-level function definitions found, at least 1 required |
check.py with def check(...) and def report(...) |
2 | Violation — 2 top-level function definitions found, at most 1 allowed |
Inline ignore¶
symbol-count is the only rule type that honors an inline ignore comment. A # psl: ignore marker in a real comment (not inside a string) suppresses rules for that file:
# psl: ignore[one-error-per-file]
| Comment | Effect |
|---|---|
# psl: ignore |
Suppress every rule for this file |
# psl: ignore[one-error-per-file] |
Suppress the named rule only |
# psl: ignore[B26] |
Suppress a rule whose checklist-id prefix is B26 (matches a rule named B26-count) |
The bracketed token matches the full rule name or the bare id prefix (the segment before the first -).
Summary¶
Rule fields¶
| Field | Required | Description |
|---|---|---|
name |
Yes | Unique identifier, referenced in # psl: ignore. Must match [a-zA-Z0-9_.-]+ |
type |
Yes | The rule primitive |
description |
No | Human-readable description shown in violation output |
| (options) | Varies | Type-specific keys, validated per type; a bad key or value exits 2 |
Rule types¶
| Type | What it checks | Key options |
|---|---|---|
directory-exists |
Each within scope contains the required child directories |
within, require |
allowed-children |
A scope directory contains only allowed child names (with optional one-level grouping) | within, kind, allow, allow_patterns, group_level, ignore |
mirror |
Two trees mirror each other at directory granularity | source, mirror, direction, ignore, mirror_extra_allowed, source_extra_allowed |
symbol-count |
The number of top-level classes/functions a module defines | within, symbol, visibility, max, min, ignore_files |
Path pattern tokens¶
| Token | Matches |
|---|---|
| literal | a segment with that exact name |
* |
exactly one segment |
** |
zero or more segments |
{name} |
one segment, captured as name |