# `crawk check` — Architectural Rule Checking
## Overview
`crawk check` enforces an **architectural contract** on a crate's internal
module dependencies. The contract is declared in a config file using four rule
kinds — named *layer groups* (`[[check.layers]]`: ordered stacks of modules
where a lower layer must not depend on a higher one), *deny rules*
(`[[check.deny]]`: explicit bans on a specific `from -> to` edge), *restrict
rules* (`[[check.restrict]]`: allow-lists that close a module scope down to a
named set of targets), and the *cycle ban* (`deny-cycles`: no dependency loops,
with an allowlist for the ones a crate already has) — and `check` verifies
every inter-module dependency edge against all of them in one pass. It is built for CI: a clean crate exits `0`, a
contract breach exits `1`, and an operational problem exits `2`.
The config file is **required**, by design. A linter without a contract has
nothing to enforce, so a *missing* config is an operational error (exit `2`),
not a silent pass — that way a typo'd path or a forgotten file fails the build
rather than quietly reporting "clean". An *empty* `[check]` table, by contrast,
is perfectly valid: zero rules means every edge is allowed, so the crate always
passes.
A violation is **data**, not a crash: `check` reports each broken rule on its
own line and sets the exit code. Only operational problems (missing or malformed
config, a rule that names a module that does not exist, an uncovered module under
`strict-layers`) surface as errors.
## Config File Location & `--init`
`check` resolves its config in one of two ways:
- **Explicit** — with `-c <FILE>` / `--config <FILE>`, that path is used
verbatim. If the file does not exist, `check` fails with exit `2`
(`config file does not exist`).
- **Discovered** — without `--config`, the crate root is searched for
`crawk.toml` first, then `.crawk.toml`. The plain name wins when both exist
(with a warning). If neither is found, `check` fails with exit `2`; the error
points you at the fix:
```
no crawk.toml or .crawk.toml found in <crate root>; run `crawk check --init` to generate a starter config
```
### `crawk check --init`
`crawk check --init` scaffolds a starter `crawk.toml` in the crate root and
exits. It writes a single `[[check.layers]]` group named after the crate, listing
the crate's top-level modules alphabetically, with a comment reminding you to
reorder them (highest layer first). It deliberately does **not** guess the
hierarchy — layer ordering encodes design intent the source cannot reveal.
Cycles are the opposite case, and `--init` does fill them in: it sets
`deny-cycles = true` and freezes every loop the crate already has as an
`[[check.allow-cycle]]` entry (containment loops excluded — they are exempt
anyway). The cycle rule therefore starts **green** and ratchets from the first
run, instead of burying a new adopter under pre-existing tangles. The next steps
are spelled out on completion:
```
Scaffolded <crate root>/crawk.toml.
Reorder the modules: highest-level layer first, lowest last.
A lower layer must never depend on a higher one.
Froze 2 existing dependency loops as `allow-cycle`; untangle them and delete the entries.
Then run `crawk check`.
```
(The `Froze …` line appears only when there was something to freeze.)
`--init` refuses to clobber an existing config: if a `crawk.toml` or
`.crawk.toml` is already present (or, with `--config`, the explicit target
exists), it errors with `config already exists; edit it directly or remove it
first` rather than overwriting your work.
## `[check]` Schema Reference
The config is a single `[check]` table. All keys are **kebab-case**, and unknown
keys are rejected (so `layerz` or a misspelling fails loudly instead of being
ignored).
| `layers` | array of layer groups | `[]` | The `[[check.layers]]` groups to enforce (see below). |
| `deny` | array of deny rules | `[]` | The `[[check.deny]]` edge bans to enforce (see below). |
| `restrict` | array of restrict rules | `[]` | The `[[check.restrict]]` target allow-lists to enforce (see below). |
| `allow-cycle` | array of cycle allowlist | `[]` | Loops that pass despite `deny-cycles` (see below). |
| `strict-layers` | bool | `false` | Require every module in the crate to belong to at least one group. |
| `deny-same-layer` | bool | `false` | **Default** same-layer policy for all groups; each group may override it. |
| `deny-cycles` | bool | `false` | Report dependency loops between modules. |
| `deny-parent-child-cycles` | bool | `false` | Also report loops between a module and its own submodules. |
An empty `[check]` table (no keys at all) is valid and yields zero rules — a
clean pass.
### `[[check.layers]]` sub-table
Each `[[check.layers]]` entry defines one layer group:
| `name` | string | Group name. Must be **unique** across all groups. Appears in violation messages. |
| `order` | array of strings | Module patterns, **highest layer first**. Each pattern covers a module and its entire subtree. |
| `deny-same-layer` | bool (optional) | Override the same-layer policy for this group. When omitted, inherits the `[check]`-level value. |
Notes on `order`:
- A pattern is a module path. It matches that module **and its subtree** —
`"graph"` covers `graph::edges`, `graph::cycles`, and so on. Membership uses
**longest-prefix match**, so a more specific pattern wins over a broader one.
- Every pattern must name a **real module** in the crate. A pattern that matches
nothing fails the load with exit `2` (`UnknownRuleModule`), catching typos.
- A duplicate `name` across groups is an operational error, reported with its
source line: `duplicate layer group name '<name>' (line N)`.
### `[[check.deny]]` sub-table
Each `[[check.deny]]` entry bans one dependency edge:
| `from` | string | Pattern for the **source** module of the banned edge. |
| `to` | string | Pattern for the **target** module of the banned edge. |
Both keys are required; any other key is rejected. Pattern semantics differ
from `layers` — see [Deny Rules](#deny-rules--checkdeny) below.
### `[[check.restrict]]` sub-table
Each `[[check.restrict]]` entry allow-lists the dependency targets of one
module scope:
| `from` | string | Pattern for the scope whose outbound edges are restricted. |
| `to` | array of strings | Patterns for the allowed targets. **May be empty** (`to = []`). |
Both keys are required — an empty allowance must be spelled `to = []`
explicitly; a missing `to` is a parse error, so a forgotten key cannot silently
become the strictest possible rule. Pattern semantics match `deny`; see
[Restrict Rules](#restrict-rules--checkrestrict) below.
### `[[check.allow-cycle]]` sub-table
Each `[[check.allow-cycle]]` entry grandfathers one known dependency loop:
| `modules` | array of strings | Exact module paths of the tolerated loop. **No patterns** — `::*` is not one. |
| `reason` | string (optional) | Why the loop is tolerated. Quoted back when the entry goes stale. |
Entries only matter with `deny-cycles = true`; loading an allowlist without it
warns that it has no effect. See [Cycle Rules](#cycle-rules--deny-cycles).
## How Layering Works
Layers are listed **highest first**: `order[0]` is the top layer, and each later
entry sits below it. The single rule is **depend downward only** — a module may
depend on layers below it, never above.
For each dependency edge `source -> target`, `check` looks at every group that
contains **both** endpoints and compares their positions in that group's `order`:
- **target is LOWER** (later in `order`) → allowed. This is a downward
dependency.
- **target is HIGHER** (earlier in `order`, i.e. "upward") → **violation**.
- **same layer** (same `order` index) → allowed by default; a violation only
when `deny-same-layer = true`.
An edge whose endpoints fall in *different* groups, or in *no* group, is
**unconstrained** — there is no cross-group ordering. Layering only ever
compares two modules that share a group.
### `strict-layers`
- **What it does:** requires every module in the crate to belong to at least one
layer group. An uncovered module is an operational error (exit `2`):
`strict-layers: module '<module>' is not assigned to any layer`.
- **Default:** `false` — modules not named in any group are simply
unconstrained.
- **Turn it on when** you want the architecture gate to catch *new* modules that
slip in without being placed in the hierarchy.
### `deny-same-layer`
- **What it does:** turns a dependency between two modules in the same layer
(same `order` index, including two modules under the same subtree pattern) into
a violation.
- **Default:** `false` — same-layer dependencies are allowed.
- **Turn it on when** you want sibling modules within a layer to stay
independent of each other.
`deny-same-layer` is a **per-group** policy with a crate-wide default. The key in
`[check]` sets the default for every group; a `deny-same-layer` inside a single
`[[check.layers]]` group overrides that default for that group only. This lets
one group forbid sibling coupling while another — whose siblings collaborate by
design — allows it.
```toml
[check]
deny-same-layer = false # default for every group
# Plugins must stay independent of one another: override to true.
[[check.layers]]
name = "plugins"
order = ["plugins"]
deny-same-layer = true
# Parser internals collaborate freely; omit the key to inherit the false default.
[[check.layers]]
name = "parser-internal"
order = ["parser", "parser::visitor"]
```
How two same-layer edges resolve under this config:
- `plugins::pdf -> plugins::csv` — both sit in the single `plugins` layer.
That group overrides `deny-same-layer = true`, so the edge is a **violation**:
```
crawk check: 1 violation
LAYER plugins::pdf -> plugins::csv (rule: layer 'plugins' forbids same-layer dependency (plugins::pdf -> plugins::csv))
```
**The fix:** route the shared code through a lower layer both plugins depend
on, rather than one plugin reaching into the other.
- `parser -> parser::visitor` sits in `parser-internal`, which omits the key and
inherits the `false` default — **allowed**.
Flip the `[check]` default to `true` and the inheritance reverses: every group
denies same-layer edges unless it sets `deny-same-layer = false` for itself.
## Overlapping Groups
Groups **may overlap**: a single module can appear in several groups. Each group
is checked **independently**, so one edge can produce **one violation per group**
that forbids it, and each violation message names the offending group.
For example, given:
```toml
[[check.layers]]
name = "left"
order = ["top", "mid"]
[[check.layers]]
name = "right"
order = ["top", "mid"]
```
the edge `mid -> top` is upward in *both* `left` and `right`, so `check` reports
two violations — one attributed to `left`, one to `right`. Conversely, if an
edge is downward (or unconstrained) in a given group, that group contributes
nothing. Overlap lets you express several independent orderings over the same
modules without them interfering.
## Deny Rules — `[[check.deny]]`
A deny rule is an **explicit edge ban**: no module matching `from` may depend
on a module matching `to`. Where layering derives violations from an ordering,
`deny` names the forbidden edge directly — use it for point rules that don't
fit a stack, like "the CLI must never touch the web subsystem".
Deny rules are evaluated **independently of layers** (and of each other): every
dependency edge is tested against every deny rule, and each rule that matches
yields its own violation. In the report, `DENY` rows sort first — before
`RESTRICT` and `LAYER` rows.
### Pattern semantics
Deny patterns are stricter than `layers` patterns — subtree matching is
**opt-in**, not implicit:
- A bare path matches **exactly** that module: `from = "cli"` covers `cli` but
*not* `cli::validation`.
- An explicit `::*` suffix matches the module **and its subtree**:
`to = "web::*"` covers `web`, `web::api`, `web::repo`, `web::service`.
- A lone `"*"` is a wildcard matching **every** module: `from = "*"` bans all
edges into the `to` pattern, wherever they come from.
This contrast with `layers` (where `"graph"` implicitly covers `graph::edges`)
is deliberate: a ban should say exactly what it bans.
### Validation
As with layer patterns, both `from` and `to` must reference a **real module**
in the crate — a pattern that matches nothing fails the load with exit `2`,
catching typos before they silently ban nothing:
```
Error: Rule references unknown module 'clii' (in rule 'deny clii -> web')
```
### Example
```toml
# Layers govern the top-to-bottom stack; the deny rule catches a cross-group
# edge that layering deliberately leaves unconstrained.
[[check.layers]]
name = "app"
order = ["cli", "analyzer", "parser", "discover"]
[[check.deny]]
from = "cli"
to = "web::*"
```
If `cli` depends on `web::repo`, the run exits `1` and reports:
```
crawk check: 1 violation
DENY cli -> web::repo (rule: deny cli -> web::*)
```
The violation quotes the rule **as written**, `::*` suffix included, and names
the concrete edge that tripped it. `-a` / `--show-apis` annotates the offending
symbols, same as for layer violations:
```
DENY cli -> web::repo [RepoType] (rule: deny cli -> web::*)
```
## Restrict Rules — `[[check.restrict]]`
A restrict rule is an **allow-list of dependency targets**: a module matching
`from` may depend only on modules matching one of the `to` patterns. It is the
complement of `deny` — deny blacklists specific edges and leaves everything
else open; restrict closes everything and opens only what is listed. Use it
where a blacklist cannot keep up: a scope whose legal targets are few and
stable while the rest of the crate keeps growing. A module added tomorrow is
outside the allowance **by default** — the same ratchet direction as the cycle
allowlist.
Restrict rules are evaluated independently of every other rule kind: each
dependency edge is tested against each restrict rule whose `from` matches the
edge's source, and every rule whose allowance misses the target yields its own
violation. In the report, `RESTRICT` rows sort after `DENY` and before `LAYER`.
### Pattern semantics
Same as `deny` — subtree matching is **opt-in** via an explicit `::*` suffix,
a bare path matches exactly one module, and a lone `"*"` matches every module.
**Mind the exact-match trap in `to`.** `to = ["graph"]` allows exactly the
module `graph` and nothing under it — the day `graph` is split into
submodules, an edge to `graph::edges` starts failing the gate even though
nothing architecturally changed. A target that should survive such a split
belongs in the list as `"graph::*"`. Bare names in `to` are for genuine
single-module targets (a facade like `lib`, a leaf like `version`).
### Edges inside the scope are exempt
A restrict rule guards the **boundary** of its scope, not its inside. Two kinds
of edges are exempt from every restrict rule:
- an edge whose target also matches the rule's `from` — sibling modules of one
subsystem talking to each other (`web::api -> web::service` under
`from = "web::*"`);
- an edge between a module and its own descendant, in either direction
(`cli -> cli::validation` under the exact `from = "cli"`).
So a rule never has to list its own subtree in `to`. If the *internal*
structure of the scope needs policing, that is a job for a `layers` group or a
`deny` rule over the same modules, not for restrict.
### The empty allowance — `to = []`
An empty list is legal and means "**nothing beyond my own scope**": every
outbound edge is a violation, while the scope-internal edges above stay
exempt. It is the strongest form of the rule — a subsystem sealed off from the
rest of the crate — and deliberately explicit: `to = []` must be written out,
a missing `to` key is a parse error.
### Overlapping rules intersect
Two restrict rules covering the same module **intersect** their allowances —
an edge must satisfy every rule that matches its source, and each rule it
breaks reports its own row. The narrower rule does not override the broader
one. (The "most specific pattern wins" behavior exists only *inside* a single
layer group's `order`; it does not apply here.)
### Composing with `deny` — carving a hole
Restrict and deny only ever **add** violations; neither can wave an edge
through the other's check. That means a deny rule can carve a hole in a
restrict allowance without any precedence rules:
```toml
[[check.restrict]]
from = "rules::*"
to = ["graph::*", "module_path", "error"]
[[check.deny]]
from = "rules::*"
to = "graph::edges"
```
Restrict says "nothing beyond these three targets"; deny adds "and not
`graph::edges` either". The effective allowance is `graph::*` minus
`graph::edges` — an edge into `graph::edges` passes restrict but is reported
by deny.
### Validation
As with `deny`, the `from` pattern and every `to` pattern must reference a
**real module** in the crate — a pattern matching nothing fails the load with
exit `2` (`UnknownRuleModule`), catching typos before they silently allow or
guard nothing. An empty `to` list is not a typo and passes validation.
### Example
```toml
# cli may depend on analyzer and nothing else outside its own subtree.
[[check.restrict]]
from = "cli"
to = ["analyzer"]
```
If `cli` also depends on `web::repo`, the run exits `1` and reports:
```
crawk check: 1 violation
RESTRICT cli -> web::repo (rule: restrict cli -> [analyzer])
```
The violation quotes the **full allowance**, so a CI log says what *was*
allowed without a trip back to the config file. `-a` / `--show-apis` annotates
the offending symbols, same as for the other rule kinds.
## Cycle Rules — `deny-cycles`
`deny-cycles = true` bans **dependency loops**: groups of modules that reach
each other in a circle, the same strongly connected components `deps --cycles`
reports. Where `deps --cycles` describes, `deny-cycles` enforces.
Each banned loop yields **one violation per edge** of the loop, and every row
cites the whole loop, so a three-module cycle costs three lines:
```
crawk check: 3 violations
CYCLE alpha -> beta (rule: cycle: alpha, beta, gamma)
CYCLE beta -> gamma (rule: cycle: alpha, beta, gamma)
CYCLE gamma -> alpha (rule: cycle: alpha, beta, gamma)
```
The rule text is a **comma list, not an arrow path**: the modules are listed
alphabetically, not in traversal order, so an arrow would imply a direction the
list does not carry. The concrete edges are the rows themselves — each one names
a place the loop could be cut. `CYCLE` rows sort **after** `DENY`, `RESTRICT`
and `LAYER`.
A cycle edge can break a layer order or a deny rule at the same time; those are
separate violations of different kinds, and all of them are reported.
### Parent-child loops are skipped
A parent module that re-exports a submodule while the child reaches back with
`use super::…` forms a loop in the graph:
```rust
// src/rules/mod.rs
mod eval;
pub(crate) use eval::evaluate; // edge: rules -> rules::eval
// src/rules/eval.rs
use super::RuleSet; // edge: rules::eval -> rules
```
This is containment, not an architectural tangle, and it appears in nearly every
Rust crate. `deny-cycles` therefore **skips** any loop in which one module is an
ancestor of all the others:
| `rules`, `rules::eval`, `rules::load` | no | `rules` is an ancestor of the rest |
| `parser`, `parser::visitor` | no | same shape, two modules |
| `alpha`, `beta`, `gamma` | **yes** | no common ancestor — a real tangle |
| `rules`, `rules::eval`, `graph` | **yes** | `rules` is not an ancestor of `graph` |
Set `deny-parent-child-cycles = true` to turn the filter off and have those
loops reported too.
### Grandfathering known loops — `[[check.allow-cycle]]`
Turning the rule on in a crate that already has loops would fail from day one.
An allowlist entry tolerates a specific loop while it is being untangled (on a
fresh crate, `crawk check --init` writes these entries for you):
```toml
[check]
deny-cycles = true
[[check.allow-cycle]]
modules = ["alpha", "beta", "gamma"]
reason = "legacy render loop, tracked in #1"
```
Matching is by **subset**: a detected loop passes when every one of its modules
appears in the entry. That makes the list a ratchet in both directions —
- a loop that **shrank** after a partial fix is still covered, so progress never
breaks the build;
- a module **joining** the loop escapes the entry, and the loop is reported
again — new coupling is caught even inside a tolerated cycle.
An entry that covers no detected loop is **config rot**, not a failure. It warns
on stderr and leaves the exit code alone, so untangling a cycle never breaks the
build of whoever fixed it:
```
WARN allow-cycle [delta, standalone] (untangled in #2) covers no detected cycle; remove or update it
```
Entries are validated at load time (exit `2`): every module must exist, an entry
needs at least two modules (a loop cannot be shorter), and the same set must not
be listed twice. Because entries hold exact names rather than patterns, a typo is
always a mistake, never "a pattern that happens to match nothing".
One gotcha: `-t` / `--include-tests` puts test modules in the graph, which can
make a loop **bigger** than the entry that covers it. Either list the test module
in the entry too, or keep the flag out of the CI invocation that gates on cycles.
## Worked Example
A complete, copy-pasteable `crawk.toml`:
```toml
[check]
# Require every module to be placed in a layer.
strict-layers = true
# Crate-wide default: same-layer dependencies are allowed unless a group opts in.
deny-same-layer = false
# Primary top-to-bottom architecture. Highest layer first:
# cli may depend on analyzer/graph/parser; parser must not depend on cli.
# This group overrides the default to forbid same-layer coupling between its
# top-level modules.
[[check.layers]]
name = "arch"
order = ["cli", "analyzer", "graph", "parser"]
deny-same-layer = true
# A second, overlapping group: within the parser subsystem, the visitor sits
# below the parser entry point. `parser` appears in both groups — each is
# checked on its own. It omits `deny-same-layer`, inheriting the false default.
[[check.layers]]
name = "parser-internal"
order = ["parser", "parser::visitor"]
# A point rule outside the stack: the CLI layer (and only it — no `::*`, so
# submodules are not covered) must never reach into the cache internals.
[[check.deny]]
from = "cli"
to = "cache::*"
```
Add the cycle ban on top, with the one loop the crate has not untangled yet:
```toml
[check]
deny-cycles = true
[[check.allow-cycle]]
modules = ["analyzer", "graph"]
reason = "analyzer builds the graph and reads it back; split in #42"
```
A sample violation line (default `plain` format):
```
crawk check: 1 violation
LAYER parser -> cli (rule: layer 'arch' forbids upward dependency (parser -> cli))
```
This says: in group `arch`, `parser` (a lower layer) depends on `cli` (a higher
layer), which points upward. **The fix:** invert the dependency — move the shared
type down so `cli` depends on `parser` instead of the reverse, or place the two
modules in their correct order if the hierarchy itself is wrong.
With `-a` / `--show-apis`, each line also lists the API symbols on the edge:
```
LAYER parser -> cli [CrawkArgs] (rule: layer 'arch' forbids upward dependency (parser -> cli))
```
When several rule kinds fire in one run, the report is grouped by kind: all
`DENY` rows first, then `RESTRICT`, then `LAYER`, then `CYCLE`. The kind
column is padded to the widest kind present in the report, so a mixed report
stays aligned while a single-kind report keeps a plain single space.
## Exit Codes
| `0` | Clean — all rules satisfied (including an empty `[check]` table). |
| `1` | One or more violations found (printed to stdout). |
| `2` | Operational error — missing/invalid config, a rule naming an unknown module, a duplicate group name or allowlist entry, a one-module `allow-cycle`, or (under `strict-layers`) an uncovered module. |
## CLI Flags
```
crawk check [OPTIONS]
```
| `--init` | Scaffold a starter `crawk.toml` — layer skeleton plus a cycle baseline — then exit (refuses to overwrite). |
| `-c, --config <FILE>` | Rule config path. When omitted, search the crate root for `crawk.toml`, then `.crawk.toml`. |
| `-t, --include-tests` | Include `#[cfg(test)]` modules and test targets in the dependency graph (excluded by default). |
| `-a, --show-apis` | Annotate each violation with the API symbols that create the offending edge. |
| `-f, --format <FMT>` | Output format: `plain` (default) — one violation per line. |
Global options (`-p`, `-v`, `-l`) must appear **before** the `check` subcommand.