Rules
The rule language is dependency-cruiser 18.2.0's, whole: every rule shape and attribute it accepts is accepted here with the same meaning, and its own test suites prove it (conformance gate 1). The exhaustive list, with each row's status, is the coverage tab § Rules; this page explains the shapes and what Rulebearing adds. Source: design § Dependency rules. Where rules live and how files are found is in config.md.
The four families
| Family | A violation is | Example |
|---|---|---|
forbidden |
an edge, or a module, that matches the rule | from: { path: "^src/domain/" }, to: { path: "^src/web/" } |
allowed |
an edge that no allowed entry covers; one rule, not-in-allowed, with allowedSeverity |
from: { path: "^src/" }, to: { path: "^(src|node_modules)/" } |
required |
a module matching module that does not import (or, with to.reachable: true, does not reach) a module matching to |
every page must reach src/auth.ts |
rules.ratchets (native) |
a count of matching edges above the ceiling in the budget file | route files importing server code, counted per app |
forbidden has three variants beside the plain from and to edge rule:
- Cycles:
to.circular: true, optionally narrowed withvia,viaOnly,viaNotorviaSomeNot. To ignore cycles that close only through type imports, writeviaOnly: { dependencyTypesNot: [type-only] };dependencyTypesNotontoitself filters only the edge reported, not the cycle it is on (the guard catalogue'sno-circular). - Reachability:
to.reachable: trueforbids reaching a module through any chain;to.reachable: falserequires every matching module to be reachable fromfrom. - Dependents:
modulewithnumberOfDependentsLessThanornumberOfDependentsMoreThan, for "a shared module used by fewer than two others" and the like.
from.orphan: true matches modules that import nothing and are imported by nothing. scope: folder applies circular to folders rather than modules.
scope: project (native configurations only) compares projects: the .csproj, or Python package, each module belongs to. from.path and to.path match project names, and to.circular and to.moreUnstable read the project layer, projects[], which a run writes beside folders[] when metrics are on. A dependency on a package or a framework assembly counts toward the project's efferent coupling, as a folder's does, and appears as a sink. A violation that is neither a cycle nor an instability has type project. In a dependency-cruiser configuration the scope is exit 3, and config convert leaves such a rule out and says so (ADR-0066).
rules:
dependencies:
forbidden:
- name: stable-projects-depend-on-stable-projects
comment: "adr:0066"
severity: warn
scope: project
from: { path: "^src/Domain/" }
to: { moreUnstable: true }
The metrics reporter lists projects as rows of type project; reporterOptions.metrics.hideProjects: true hides them.
Element, slice and diagram rules
Native configurations have three more families under rules, which carry ArchUnitNET's vocabulary to every language (plan 0002, Steps 5 and 6). Gate 2 holds them to ArchUnitNET's and NetArchTest's own tests (conformance/README.md).
rules:
elements:
- name: services-are-sealed
comment: "adr:0004"
select: { kind: class, where: { haveNameEndingWith: Service } } # That()
should: { beSealed: true } # Should()
because: services are composed, never extended
slices:
- name: bounded-contexts-do-not-know-each-other
comment: "adr:0005"
matching: "RiverBooks.(*)"
should: [notDependOnEachOther, beFreeOfCycles]
diagrams:
- name: matches-the-context-diagram
comment: "adr:0001"
select: { kind: type, where: { resideInNamespaceMatching: "^Shop\\." } }
adhereTo: docs/architecture/components.puml
- Element rules.
select.kindistype,class,interface,attribute,member,field,method,property,functionormodule;whereholds predicates (arePublic,doNotHaveName) andshouldconditions (bePublic,notHaveName);all,anyandnotcombine them, and ArchUnitNET'sAnd/Orchains are their left-to-right fold. A...Thatkey (dependOnAnyTypesThat) takes a nested selector.existandnotExistmay sit anywhere in a condition. An empty selection is vacuous unlessallowEmptyis set or the conditions mentionexist.select.includeReferencedalso selects the types the code references but does not define (ADR-0035). - Every language. Each key's answer in .NET, TypeScript, JavaScript and Python is in the generated reference. A key a language cannot answer (
beSealedin Python) is exit 3 naming the rule, unlessselect.languageleaves that language out (ADR-0014). - Every kind. The reference's Kinds column says which
select.kinds each key means something for:beSealedfor types,beVirtualfor methods and properties,adhereToPlantUmlDiagram(and so a diagram rule) for types and functions. A key used on another kind is exit 3 naming the rule, the key and the kind, rather than a fixed answer; a nested selector'swhereis checked against its own kind and languages.kind: moduleselects the module layer's modules: a name (the file name), a full name (the source), dependencies (the resolved imports;onlyDependOnjudges those on modules of the run that are neither core nor unresolved) and a namespace (a TypeScript or JavaScript module's path, a Python module's dotted name, any namespace a .NET file declares), and nothing else. - Slices group .NET types by namespace and TypeScript and Python modules by path or dotted name.
(*)and(**)name a slice as ArchUnitNET does, by everything after the prefix;Ns.(**)..names it by the first segment, andsegments: 1keeps one segment whatever follows, which is import-linter'sacyclic_siblings(ADR-0034). - Diagrams read the PlantUML component subset ArchUnitNET reads; a malformed diagram is exit 3 with ArchUnitNET's exception name.
Keys across languages
A native dependency rule may also narrow from and to by language, namespace / namespaceNot, project / projectNot and assembly / assemblyNot, and to by dependencyKind / dependencyKindNot (inherits, implements, attribute, ...). They read what the extractors record on each module and edge, and a .dependency-cruiser.* configuration that uses one is exit 3 (plan 0002, Step 8). to.license reads npm, NuGet and Python licences alike.
Not every extractor records every property:
| Property (keys) | .NET | TypeScript, JavaScript | Python |
|---|---|---|---|
namespaces (namespace, namespaceNot) |
the namespaces the file declares | not recorded | the dotted module name |
project (project, projectNot) |
the .csproj (or loaded assembly) path |
not recorded | the top-level package |
assembly (assembly, assemblyNot) |
the assembly of the file's types | not recorded | not recorded |
dependencyKind (dependencyKind, dependencyKindNot) |
inherits, implements, attribute, ... |
import |
import |
A rule whose from or to narrows by a property that a language the side can select does not record is exit 3 naming the rule, the side, the key and the language, because the key would be false for every module of that language (ADR-0014). Add that side's language to leave the language out: from: { language: dotnet, namespace: "^Shop\\." }. to can select the targets of the edges from what from can select, since no edge crosses languages. A core or external module, which no extractor analysed, has none of these properties and matches neither a key nor its Not form. The table is data in rb-config (capability::records).
Declared edges
A rule across languages matches nothing until an edge exists, because no extractor invents one (ADR-0014). In a fleet, edges.yaml declares the edges between services (config.md), and a rule over the prefixed paths then matches them like any other edge. Each declared edge carries declared: true and dependencyKind: declared, so a rule can also select them, or leave them out:
- name: no-new-http-calls-to-billing
from: { path: "^web/" }
to: { path: "^billing/", dependencyKind: declared }
Captures
A capturing group in from.path is available in to as $1 to $9 (and $0 for the whole match), escaped so it matches only itself. The common fence is one rule:
- name: apps-are-independent
from: { path: "^apps/([^/]+)/" }
to: { path: "^apps/([^/]+)/", pathNot: "^apps/$1/" }
rulebearing explain apps-are-independent --plain says it back as "Files under apps/<x>/ may not import files under apps/<y>/ unless x = y."
Severity and the exit code
error, warn, info and ignore. A gating reporter (err, err-long, null, teamcity, azure-devops, github-annotations, agent) exits with the number of error violations, capped at 255; json, csv and text exit 0, as dependency-cruiser's do, so a pipeline can save JSON in one step and gate in the next (ADR-0008, ADR-0030). Exit 2 means the run cannot be trusted and exit 3 means the configuration is invalid, whatever the reporter.
Liveness
A rule whose selecting side matches no module checks nothing, and a gate that reads green because a rule went stale is worse than no rule. So every rule whose from (or module) matches nothing is listed in summary.vacuousRules and named on stderr (ADR-0007). What that does to the run depends on the configuration (ADR-0032):
| Configuration | Default | A vacuous rule |
|---|---|---|
rulebearing.*, including one that extends a dependency-cruiser file |
strict |
fails the run with exit 2 |
.dependency-cruiser.*, run as it is |
warn |
a warning; the exit code is dependency-cruiser's, and the entry carries "severity": "warn" |
--liveness strict|warn|off overrides the default, and --no-liveness is off, which dependency-cruiser's own test suites need. A rule that may match nothing is excused by name: allowEmpty: true on the rule, or, for a rule that lives in a dependency-cruiser file, its name in the native file's top-level list:
extends: ./.dependency-cruiser.cjs
allowEmpty: [plugins-stay-apart] # matches nothing until the first plugin lands
A name in that list that is no rule is a configuration error. rulebearing adopt writes the list for the rules that match nothing on the day it runs.
What Rulebearing adds to a rule
| Addition | What it does |
|---|---|
fix |
The imperative printed under every finding by err-long, explain, can-import and the agent reporter |
examples.forbidden, examples.allowed |
Edges rulebearing test builds into a graph and checks the rule against, so a rule's regex is tested like code |
owner, expires |
Who answers for a temporary rule, and the day after which it fails the run |
since, deprecated, replacedBy |
When the rule arrived, when it was deprecated and what replaces it; reported by rules --json and changelog, checked by config lint, and without effect on evaluation (config.md § Rule metadata) |
A decision token in comment |
adr:NNNN or plan:<slug>; required by --require-comment-token |
A stable id on every violation |
RB- and eight hex digits from the rule, the two ends and the kind of edge (ADR-0015), so a baseline entry survives unrelated edits |
line and column on every TypeScript edge |
Where the import is, so a finding points at a line |
Baselines and ratchets
Known violations. options.knownViolations lists findings that are accepted for now. An entry written by rulebearing adopt or init carries the violation's id, its type and, for a cycle or a reachability chain, the modules in it, plus expires and owner; an entry written by dependency-cruiser (from, to, rule, cycle, via) matches the way dependency-cruiser matches it. A known finding is reported with severity ignore. An entry past its expires date fails the run the day after. An element or slice entry without an id matches on its rule and whichever of from and to it names, so an entry naming only a type (to) or only a slice (from) covers every finding of it, as ArchUnitNET's frozen rules do. rulebearing baseline writes and maintains the file (cli.md § Baselines).
Ratchets. A rules.ratchets entry counts the direct edges that match its from and to (with $1 captures) and compares the count with { "ceiling": n } in its budget file. cruise reports every ratchet in summary.ratchets: an exceeded one is one error, and a missing budget exits 2 (ADR-0029). rulebearing count --from ... --to ... --budget FILE --write lowers the ceiling to the current count and refuses to raise it.
Checking the rules themselves
| Command | Answers |
|---|---|
rulebearing test |
Does each rule flag its forbidden examples and pass its allowed ones? |
rulebearing config lint |
Can a rule never match? Is it shadowed by an earlier one? Does an allowed list admit everything? Does every rule have a fix that says more than its name? Does every replacedBy name a rule? |
rulebearing rules --json |
Every rule with its family, severity, and how many modules each side matched |
rulebearing rules --unused |
Which rules matched nothing in each of the last releases' snapshots (cli.md) |
rulebearing explain <rule> |
The rule in a sentence, its reason and fix, and the first edges it matched |