Configuration
Rulebearing reads two configuration formats into one model: dependency-cruiser's, unchanged, and its own native format, which is a superset. Decisions: ADR-0005 (the two formats), ADR-0006 (JavaScript in a sandbox), ADR-0016 and ADR-0028 (regular expressions). Source: design § Configuration. The rule language itself is in rules.md.
Finding the file
--config FILE names it; --config - reads stdin; --no-config runs without one. With --config and no value, or with no flag at all on the commands that need a configuration, the first of these found in the working directory is used:
| Order | Name | Format |
|---|---|---|
| 1 | rulebearing.yaml, .yml, .json, .jsonc, .toml |
native |
| 2 | .dependency-cruiser.js, .cjs, .mjs, .json, .yaml, .yml |
dependency-cruiser |
--config-format native or --config-format dependency-cruiser settles a name that says neither.
dependency-cruiser's format, as it is
A .dependency-cruiser.js that works with dependency-cruiser 18.2.0 works here without edits: forbidden, allowed, allowedSeverity, required, options and extends, with every key, attribute and option of the coverage tab. Unknown keys are refused, as dependency-cruiser refuses them.
JavaScript configurations run in an embedded QuickJS sandbox with no filesystem, network or process access, and with CommonJS and ES module syntax. require and import resolve only inside the repository, and the pure path and url modules are provided (ADR-0027). A configuration that reads files, such as one that lists a folder to build a pattern, is refused with a message naming --config-via-node, which evaluates it with the local Node instead.
extends takes a file, a package, or one of the bundled presets. A package named <package>/<path> is looked for in this order, so a rule library installed from any one registry resolves under one name (ADR-0020, plan 0003 Step 23):
| Layout | Where extends: rulebearing-rules/nextjs is found |
|---|---|
| npm | node_modules/rulebearing-rules/nextjs.yaml, in the configuration's folder or one above it |
| NuGet | <global packages>/rulebearing.rules/<version>/rulebearing-rules/nextjs.yaml; the global packages folder is $NUGET_PACKAGES, else ~/.nuget/packages; the version is the one installed, else the one a project under the repository root references, and several installed with none referenced is an error naming them |
| PyPI | <site-packages>/rulebearing_rules/nextjs.yaml, in $VIRTUAL_ENV, else a .venv or venv folder in the configuration's folder or one above it |
The rule library itself is rulebearing-rules on npm and PyPI and Rulebearing.Rules on NuGet, published with each release at the release version and built from the presets below (wrappers/rules, ADR-0071).
Presets
| Preset | What it is |
|---|---|
dependency-cruiser/configs/recommended, recommended-strict, recommended-warn-only |
dependency-cruiser's own, vendored with their licence (presets/dependency-cruiser) |
rulebearing:typescript |
The TypeScript and JavaScript defaults: type-only imports kept as edges, export maps resolved as Node and bundlers resolve them, node_modules recorded but not followed, build output (dist/, build/, coverage/, .next/, out/) left out, the npm and Node rules (no-non-package-json, no-deprecated-core, no-duplicate-dep-types), and no-orphans with the toolchain's entry files exempt (presets/rulebearing/typescript.yaml) |
rulebearing:dotnet |
The .NET exclusions and nothing else: C# sources under obj/ and bin/, *.g.cs, *.Designer.cs left out; Program.cs, Startup.cs, AssemblyInfo.cs and Migrations/ not orphans (presets/rulebearing/dotnet.yaml) |
rulebearing:python |
The Python exclusions and nothing else: .venv/, venv/, site-packages/, __pycache__/ and .pyi stubs left out; __main__.py and conftest.py not orphans (presets/rulebearing/python.yaml) |
rulebearing:recommended |
Composes the three (extends: [rulebearing:typescript, rulebearing:dotnet, rulebearing:python]) and adds no-circular and not-to-unresolvable; every rule has a fix (presets/rulebearing/recommended.yaml) |
rulebearing:nextjs |
An opinion, off unless named: route entries and API handlers are not imported, components/, hooks/ and lib/ outside the route folders never import a route; type-only imports are never findings (presets/frameworks/nextjs.yaml) |
rulebearing:clean-architecture |
An opinion, off unless named: Domain <- Application <- Infrastructure and Presentation, read from path segments (a module's layer is the last one in its path), for a .NET solution or a TypeScript or Python tree (presets/frameworks/clean-architecture.yaml) |
rulebearing:django |
An opinion, off unless named: models are innermost, views are reached through URL configurations, migrations are not imported (presets/frameworks/django.yaml) |
rulebearing:fastapi |
An opinion, off unless named: routers, services, repositories, each importing only the layers beneath it, with models and schemas innermost (presets/frameworks/fastapi.yaml) |
rulebearing:vertical-slices |
An opinion, off unless named: a slice under features/ or slices/ never imports another, and the shared kernel never imports a slice (presets/frameworks/vertical-slices.yaml) |
The merge replaces a rule's from and an option's value whole, so rulebearing:recommended lists the union of the three presets' no-orphans exclusions and exclude patterns, and a test keeps it the union. A repository with one language puts its own preset first, so that its exclusions win and no other language's are carried (design § What stays honest across the boundary):
extends: [rulebearing:python, rulebearing:recommended]
rulebearing init writes that line for the language it finds, and extends: rulebearing:recommended when it finds several (cli.md).
The framework presets are opinions, and nothing extends one unless a configuration names it (design § The developer relations hat: "off by default, each a documented opinion"). Each reads module paths only, declares its own rules (named with its prefix, so none replaces a language preset's) and no options, and goes after the language presets. Every rule's comment carries the decision token plan:rulebearing-<preset>, which --require-comment-token accepts, and its examples pass rulebearing test. The opinion behind each rule is in presets/frameworks/README.md.
extends: [rulebearing:typescript, rulebearing:recommended, rulebearing:nextjs]
Rules merge by name, as dependency-cruiser merges them: a rule in the extending file with the name of an extended rule replaces the attributes it names.
The native format
$schema: https://benbahrenburg.github.io/rulebearing/schema/config-v1.json
extends: rulebearing:recommended
defines:
legacyApps: { fromJson: eng/legacy-apps.json, select: "apps[*].name" }
languages:
typescript:
tsConfig: { fileName: tsconfig.json }
tsPreCompilationDeps: true
rules:
dependencies:
forbidden:
- name: no-app-to-app
comment: "An app never imports another app. adr:0003"
fix: "Move the shared code into a package under packages/ and import it from both apps."
severity: error
from: { path: "^apps/([^/]+)/", pathNot: "^apps/(${legacyApps})/" }
to: { path: "^apps/([^/]+)/", pathNot: "^apps/$1/" }
examples:
forbidden: ["apps/web/src/a.ts -> apps/admin/src/b.ts"]
allowed: ["apps/web/src/a.ts -> apps/web/src/b.ts"]
layers:
- name: clean
layers: ["^src/web/", "^src/application/", "^src/domain/"]
independence:
- name: features
pattern: "^src/features/([^/]+)/"
ratchets:
- name: routes-via-service
from: { path: "^apps/([^/]+)/src/app/.*/(page|route)\\.tsx?$" }
to: { path: "^apps/$1/src/(server|domain)/" }
budget: eng/routes-via-service-budget.json
options:
doNotFollow: { path: node_modules }
The top level holds $schema, extends, defines, languages, options, rules, and dependency-cruiser's forbidden, allowed, allowedSeverity and required, which are aliases for rules.dependencies.*. schema/config-v1.json is generated from the model and checked by a test, so an editor with YAML schema support completes and validates the file.
| Key | Holds |
|---|---|
languages.typescript |
The per-language options: tsConfig, tsPreCompilationDeps, babelConfig, webpackConfig, enhancedResolveOptions, moduleSystems, parser, baseDir and the rest of the TypeScript extractor's options. dependency-cruiser's flat option names are accepted at the top of options too, as aliases |
languages.dotnet |
The .NET extractor's options: solution, configuration, targetFramework, excludeProjects, the loader keys (assemblies, includeDependencies, directories, namespaces), and mode: compiled (the default) or source, which reads the .cs files without a build (source-mode.md) |
rules.dependencies |
dependency-cruiser's rule set: forbidden, allowed, allowedSeverity, required (rules.md) |
rules.layers |
Paths from the highest layer to the lowest; each lower layer gets one forbidden rule per higher layer, named <name>:<lower>-to-<higher> |
rules.independence |
A pattern with one capturing group; a module in one group may not import a module in another |
rules.ratchets |
A count of direct edges that may only fall, against a budget file { "ceiling": n } (ADR-0029) |
defines.<name> |
A value read from a JSON file (fromJson), selected with dot-separated keys, [*] and [n] (select), escaped, and joined with joinWith (default |); ${name} in any pattern is replaced by it |
options |
Everything else of dependency-cruiser's options, including knownViolations |
allowEmpty |
Rules and ratchets allowed to match nothing, by name (allowed[N] for the Nth allowed entry), including rules from an extended dependency-cruiser file, which cannot carry the key itself. A name that is no rule is an error, so an exception cannot outlive its rule (ADR-0032) |
rulebearing config expand FILE prints a native file with defines substituted and the shorthands expanded into the rules they stand for.
webpackConfig (fileName, env, arguments, or --webpack-config [FILE]) is evaluated in the same sandbox as a JavaScript configuration, or by Node under --config-via-node. A function-shaped configuration is called with env and arguments, an array's first element is taken, and the resolve block's alias, aliasFields, conditionNames, exportsFields, extensions, mainFields, mainFiles, modules and symlinks reach the resolver, winning over enhancedResolveOptions as they do in dependency-cruiser; any other key is named in a warning. A configuration that loads webpack plugins from node_modules needs Node: evaluate it elsewhere and pass the JSON (the configuration, or its resolve block) with --webpack-config-json FILE.
Markdown fences. In a native file, listing .md in extraExtensionsToScan also reads the js, ts, jsx, tsx, javascript and typescript code fences of those files, with each dependency's line and column pointing into the Markdown. A dependency-cruiser file keeps dependency-cruiser's behaviour, which never reads a file of extraExtensionsToScan (ADR-0036).
The cache
options.cache turns on the extraction cache cruise --cache uses (cli.md; coverage § Options row cache). It takes dependency-cruiser's forms in both formats, and optionsUsed records it normalised, as dependency-cruiser does:
| Written | Means |
|---|---|
true |
on, in .graph/cache (node_modules/.cache/dependency-cruiser in a dependency-cruiser file) |
"tmp/cache" |
on, in that folder |
{ folder, strategy, compress } |
each given; strategy is metadata (default) or content, compress: true stores the extraction zlib-compressed |
false |
off |
options:
cache:
folder: .graph/cache
strategy: metadata
compress: true
--cache, --cache-strategy and --no-cache replace it for one run.
Fleets and declared edges
rulebearing fleet (cli.md) reads two files beside each other (ADR-0078; plan 0004 § 1.6). Their schemas are schema/fleet-v1.json and schema/edges-v1.json.
fleet.yaml names the repositories and the fleet-wide rules:
repos:
- { name: web, path: ../web, config: rulebearing.yaml }
- { name: api, path: ../api, config: rulebearing.yaml }
- { name: pipeline, path: ../pipeline }
rules:
dependencies:
forbidden:
- name: web-never-reaches-pipeline
comment: "adr:0021"
from: { path: "^web/" }
to: { path: "^pipeline/" }
| Key | Meaning |
|---|---|
repos[].name |
the prefix of the repository's module paths: letters, digits, ., _ and -, unique |
repos[].path |
the repository's folder, relative to the manifest |
repos[].config |
its configuration, relative to the repository; without it, the one cruise would find |
rules |
the native format's rules, over the prefixed paths: dependencies, layers and independence. Element, slice and diagram rules and ratchets belong in a repository's own configuration and are refused (exit 3) |
edges.yaml declares the edges no extractor can see, such as an HTTP call or a queue between two services (ADR-0014):
declared:
- from: web/src/api/client.ts
to: api/src/Web/Endpoints/Orders.cs
kind: http
comment: "OrdersClient calls POST /orders. adr:0007"
openapi:
- client: web/src/api/generated/
server: api/openapi.json
Paths are the union's module paths, prefixed with the repository's name. Each declared entry becomes one dependency of its from module with declared: true, dependencyKind: declared, declaredKind (the kind) and comment.
An openapi pair declares an edge for each operation the server document records both an operationId and a handler for, from each module under client whose code layer declares a function, class or member named exactly as the operationId (a generated client declares one per operation), to the handler. The handler is the operation's x-rulebearing-handler extension, a file path relative to the repository the document is in:
"/orders": { "post": { "operationId": "createOrder", "x-rulebearing-handler": "src/Web/Endpoints/Orders.cs" } }
Nothing is guessed: an operation without an operationId, or without a handler, or that no client module declares, declares no edge, and summary.declared.openapiSources[] counts each case and says why when a pair declares nothing. When a server framework cannot write the extension, declare the edges in declared instead.
Rule metadata
Every rule, in either format, may carry these fields dependency-cruiser does not have. They are what explain, err-long and the agent reporter print (design § Rule metadata).
| Field | Meaning |
|---|---|
fix |
The imperative to follow when the rule fires |
examples |
forbidden and allowed edges, from -> to, that rulebearing test checks against the rule |
owner |
Who answers for the rule |
expires |
A date after which the rule fails the run: for a temporary exception |
since |
The release the rule arrived in |
deprecated |
The release the rule was deprecated in; changelog lists it as retired |
replacedBy |
The rule that takes over from this one |
allowEmpty |
Opt the rule out of liveness: it may match nothing without failing the run (ADR-0007). dependency-cruiser's schema refuses it on a rule, so a rule in a .dependency-cruiser.* file is named in the native file's top-level allowEmpty list instead (ADR-0032) |
Lifecycle fields. since, deprecated and replacedBy are how a rule file shrinks honestly (design § The architect's hat). They are legal on dependency, element, slice and diagram rules, on the layers and independence shorthands (copied onto every rule an entry expands to, which config expand shows) and on ratchets. They change nothing about how a rule is evaluated: a deprecated rule still reports its violations, and one that matches nothing still fails the run unless it has allowEmpty (ADR-0007). The versions are strings and are not validated, since a repository may release as 1.2.0, v1.2.0 or 2026.09; they are compared as semver when they are semver (cli.md § Snapshots). rules --json prints all three, changelog reports the deprecations, and config lint checks them:
| Finding | When |
|---|---|
replaced-by-unknown |
replacedBy names no rule, layers or independence entry, or ratchet of the configuration |
replaced-by-self |
replacedBy names the rule itself |
since-after-deprecated |
since is later than deprecated, both semver |
A deprecated without a replacedBy is not a finding: a rule may be retired with nothing in its place.
- name: no-legacy-http-client
comment: "adr:0012"
since: "1.2.0"
deprecated: "2.0.0"
replacedBy: no-http-client-outside-gateway
severity: warn
A decision token in comment, adr:NNNN or plan:<slug>, links the rule to the decision behind it. --require-comment-token makes a rule without one a configuration error (exit 3).
Regular expressions
Patterns are JavaScript regular expressions, run on the linear-time regex crate through a translation table. Backreferences are supported (ADR-0028); lookaround is refused with a message. $1 in a to pattern takes the first capture of from.path, escaped so it matches only itself. --strict-compat refuses what dependency-cruiser would refuse, including the nested quantifiers its safe-regex check rejects, and any Rulebearing addition, so a file that passes it also runs under dependency-cruiser.
Between the formats
| Command | Does |
|---|---|
rulebearing config convert FILE |
dependency-cruiser to native, losslessly |
rulebearing config convert FILE --to dependency-cruiser |
native to dependency-cruiser, and prints what it dropped: ratchets, shorthands (expanded instead), defines (substituted), languages other than TypeScript, fix, examples and the other metadata |
rulebearing config expand FILE |
the native file with nothing hidden |
rulebearing config lint |
rules that can never match (with --graph), rules shadowed by an earlier rule, overlapping allowed entries, an allowed list that admits everything, a severity below error on a rule with no violations, rules without a fix or whose fix restates the name, and missing decision tokens under --require-comment-token |