psl has no separate apply block. Each rule scopes itself: directory-exists, allowed-children, and symbol-count name their target with within; mirror names two tree roots with source and mirror. A path pattern is project-root-relative and /-separated, and it selects the files or directories a rule runs against. A rule with a pattern that matches nothing simply reports no violations.
Where Patterns Appear¶
| Rule type | Scoping option | Selects |
|---|---|---|
directory-exists |
within |
directories |
allowed-children |
within |
directories |
symbol-count |
within |
.py files |
mirror |
source, mirror |
tree roots (literal, see below) |
Patterns are evaluated against the paths that survive the top-level include / exclude filters, so a pattern never re-includes something exclude dropped.
Tokens¶
A within pattern is a /-separated sequence of segment tokens:
| Token | Matches |
|---|---|
literal (e.g. domain) |
a segment with that exact name |
* |
exactly one segment |
** |
zero or more segments |
{name} |
one segment, captured as name (behaves like *) |
Note that ** matches zero or more segments — unlike a middle wildcard that would require at least one. So a/**/b matches a/b (the ** consumes nothing) as well as a/x/y/b.
Directory Rules vs. File Rules¶
The rule type decides what a pattern is matched against:
directory-existsandallowed-childrenmatch directory paths.symbol-countmatches.pyfile paths — the filename is the last segment.
Because a file's own name is a segment, a file pattern almost always ends in ** or * to reach past the directory tier into the module. A pattern ending in a literal only matches a file whose whole last segment (extension included) equals that literal.
Directory Pattern — src/contexts/{ctx}¶
Used by directory-exists/allowed-children; matched against directory paths.
Matches:
src/contexts/orders(ctx=orders)src/contexts/billing(ctx=billing)
Does not match:
src/contexts— too few segmentssrc/contexts/orders/domain— too many segments (no**to absorb the extra tier)
File Pattern — src/contexts/{ctx}/domain/**¶
Used by symbol-count; matched against .py file paths, filename included.
Matches:
src/contexts/orders/domain/entities/order.py(ctx=orders,**=entities/order.py)src/contexts/orders/domain/order_service.py(**=order_service.py)src/contexts/orders/domain/errors/order_not_found_error.py
Does not match:
src/contexts/orders/application/create_order.py— the segmentapplicationis not the literaldomainsrc/contexts/orders/domain.py—{ctx}bindsorders, then the literaldomainwould have to equal the filenamedomain.py, which it does not
Here the trailing ** always consumes at least the filename, so a file rule ending in ** never matches zero segments — the module name fills it.
Nested ** — src/contexts/{ctx}/domain/**/errors/**¶
A ** in the middle is where zero-or-more matters. This pattern reaches every module under any errors/ directory, whether it sits directly under domain or under an intervening subdomain tier:
src/contexts/orders/domain/errors/order_not_found_error.py— the first**matches zero segmentssrc/contexts/orders/domain/shipping/errors/address_error.py— the first**matchesshipping
Captures — {name}¶
{name} matches exactly one segment, the same as *. It is readability sugar: src/contexts/{ctx}/domain documents intent better than src/contexts/*/domain while behaving identically.
The one behavioral consequence is reuse. If the same capture name appears twice in one pattern, both segments must be equal:
within: src/{layer}/shared/{layer}
This matches src/domain/shared/domain but not src/domain/shared/application — the second {layer} must repeat the value bound by the first.
source & mirror Roots¶
The mirror rule does not use the token grammar. Its source and mirror options name two tree roots literally — no *, no **, no captures. Each is joined to the project root as written, so it may be a top-level directory (src, tests) or a nested path (src/app):
- name: tests-mirror-src
type: mirror
source: src
mirror: tests
direction: mirror-to-source
The check then compares the two trees directory-by-directory. Two rules follow from the roots being literal:
- The top-level
includedoes not apply tomirror; the trees are named explicitly. - A missing or mistyped
sourceormirrorroot is a config error (exit code2), not a silent pass — a typo can never quietly disable the rule.
Summary¶
| Concept | Syntax | Description |
|---|---|---|
| Scoping | within (or source/mirror) |
Each rule targets its own paths; there is no shared apply block |
| Literal | domain |
Matches a segment with that exact name |
| Single-segment wildcard | * |
Matches exactly one segment |
| Multi-segment wildcard | ** |
Matches zero or more segments |
| Named capture | {name} |
One segment, same as *; documents intent and must repeat if reused |
| Directory rules | directory-exists, allowed-children |
within matched against directory paths |
| File rule | symbol-count |
within matched against .py file paths; end it in **/* |
| Mirror roots | source, mirror |
Literal tree roots (no wildcards); a bad root is a config error (exit 2) |