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/ Violationmissing required directory 'adapters'
src/contexts/orders domain/, application/, adapters.py Violationadapters 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/ Violationunexpected directory 'repositories' (not an allowed component)
shipping/ holding entities/, errors/ Pass — grouping tier, every child allowed
shipping/ holding entities/, repositories/ Violationunexpected '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 Violationno 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 Violation0 top-level function definitions found, at least 1 required
check.py with def check(...) and def report(...) 2 Violation2 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