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-exists and allowed-children match directory paths.
  • symbol-count matches .py file 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 segments
  • src/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 segment application is not the literal domain
  • src/contexts/orders/domain.py{ctx} binds orders, then the literal domain would have to equal the filename domain.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 segments
  • src/contexts/orders/domain/shipping/errors/address_error.py — the first ** matches shipping

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 include does not apply to mirror; the trees are named explicitly.
  • A missing or mistyped source or mirror root is a config error (exit code 2), 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)