Sibling packages form no cycle
Two packages that import each other can only be understood, tested and released together, even when no single module imports itself back. import-linter writes this as an acyclic_siblings contract. In Rulebearing's dependency rules it is a circular rule with scope: folder, which looks for cycles between folders rather than between modules.
- Plan: plan 0004, Step 5 (the import-linter contract kinds)
- Source: design § import-linter contracts
- Decisions: ADR-0013 (the import-linter mapping), ADR-0034 (the slice form the import writes)
- Rule language: rules.md § The four families (
scope: folder) - Requirement: FR-REACH-01
The rule
Every folder under shop/ is held to it, so a new package is covered the day it is created.
rulebearing.yaml$schema: https://benbahrenburg.github.io/rulebearing/schema/config-v1.json
languages:
python: {}
rules:
dependencies:
forbidden:
- name: sibling-packages-form-no-cycle
comment: "Packages that import each other can only be understood, tested and released together. plan:0004-wave-4-reach"
fix: "Break the cycle at its weakest edge: move the code one package borrows into the package that needs it, or into a new package both import."
severity: error
scope: folder
from: { path: "^shop/" }
to: { circular: true }
# A cycle between two folders takes at least two edges, and `rulebearing test` builds its
# graph from one edge per example, so the examples show what stays allowed and the
# fixture's repository holds the cycle (expected.json).
examples:
allowed:
- "shop/orders/order.py -> shop/billing/invoice.py"
- "shop/orders/order.py -> shop/catalog/prices.py"
The file is rulebearing.yaml. A cycle between two folders takes at least two edges, and rulebearing test builds its graph from one edge per example, so this rule's examples show only what stays allowed; the forbidden side is the cycle in the repository below, which the page's expected.json holds the binary to.
The same rule for dependency-cruiser
rulebearing config convert rulebearing.yaml --to dependency-cruiser writes the rule in dependency-cruiser's format, where scope: folder means the same, and names what it dropped: the languages.python block, the $schema, the fix and the examples.
.dependency-cruiser.json{
"forbidden": [
{
"name": "sibling-packages-form-no-cycle",
"comment": "Packages that import each other can only be understood, tested and released together. plan:0004-wave-4-reach",
"to": {
"circular": true
},
"severity": "error",
"scope": "folder",
"from": {
"path": "^shop/"
}
}
]
}
dropped (stderr of config convert)dropped 4 item(s) dependency-cruiser cannot hold:
languages.python: dependency-cruiser reads TypeScript and JavaScript only
$schema: points at the native schema
forbidden[sibling-packages-form-no-cycle].fix: a Rulebearing rule field
forbidden[sibling-packages-form-no-cycle].examples: a Rulebearing rule field
The files are converted/.dependency-cruiser.json and converted/dropped.txt.
The failing edge
In repo/, an order issues its invoice (shop/orders/order.py imports shop.billing.invoice), and the dunning run asks whether an order is still open (shop/billing/dunning.py imports shop.orders.status). No module is on a cycle, but the two packages are, and the rule reports each folder edge of it:
| Severity | Rule | From | To | Type | Through |
|---|---|---|---|---|---|
| error | sibling-packages-form-no-cycle | shop/billing | shop/orders | cycle | shop/orders → shop/billing |
| error | sibling-packages-form-no-cycle | shop/orders | shop/billing | cycle | shop/billing → shop/orders |
sibling-packages-form-no-cyclefix: Break the cycle at its weakest edge: move the code one package borrows into the package that needs it, or into a new package both import.
The fix
Break the cycle at its weakest edge. Dunning needs one fact about the order, so let the caller pass it in, and shop/billing/ no longer imports shop/orders/:
# shop/billing/dunning.py
def remind(order_id: str, *, is_open: bool) -> str | None:
return f"reminder for {order_id}" if is_open else None
When the borrowed code is more than one fact, move it into the package that needs it, or into a new package both import.
From an import-linter contract
The same question as import-linter's acyclic_siblings contract, in repo/.importlinter:
.importlinter[importlinter]
root_package = shop
[importlinter:contract:acyclic]
name = The packages under shop form no cycle
type = acyclic_siblings
ancestors =
shop
rulebearing import import-linter translates it into slice rules: one per package at or below each ancestor, each slicing that package's children with segments: 1 and requiring beFreeOfCycles (ADR-0034). Slice rules have no dependency-cruiser form, which is why the rule above is the folder-scoped one:
rulebearing import import-linter# Imported from .importlinter by `rulebearing import import-linter`.
# Each rule's comment names its import-linter contract; the contract's options are written above its first rule.
$schema: https://benbahrenburg.github.io/rulebearing/schema/config-v1.json
languages:
python:
roots: ["."]
rules:
slices:
# import-linter contract `acyclic` (acyclic_siblings): The packages under shop form no cycle
# ancestors = shop
- name: acyclic:shop
comment: "import-linter contract: The packages under shop form no cycle"
severity: error
matching: "shop.(*)"
segments: 1
should: beFreeOfCycles
allowEmpty: true
- name: acyclic:shop.billing
comment: "import-linter contract: The packages under shop form no cycle"
severity: error
matching: "shop.billing.(*)"
segments: 1
should: beFreeOfCycles
allowEmpty: true
- name: acyclic:shop.catalog
comment: "import-linter contract: The packages under shop form no cycle"
severity: error
matching: "shop.catalog.(*)"
segments: 1
should: beFreeOfCycles
allowEmpty: true
- name: acyclic:shop.orders
comment: "import-linter contract: The packages under shop form no cycle"
severity: error
matching: "shop.orders.(*)"
segments: 1
should: beFreeOfCycles
allowEmpty: true
Run over the same repository, the imported rules find the same cycle. A slice finding names the slices, billing and orders, and lists the module edges that close the cycle under Through:
| Severity | Rule | From | To | Type | Through |
|---|---|---|---|---|---|
| error | acyclic:shop | billing | orders | slice | shop/billing/dunning.py -> shop/orders/status.py; shop/orders/order.py -> shop/billing/invoice.py |
The files are imported/rulebearing.yaml and imported/expected.json.
Try it
The playground holds this page's repository and rule. Run cruises the files in this browser, as rulebearing cruise does in repo/; paste the imported rules into the configuration and run them, or pick another output type.
Everything runs in this browser, with the same library crates as the rulebearing binary: nothing you paste, choose or drop is uploaded or sent anywhere. The configuration is YAML or JSON. A JavaScript configuration is not accepted: the browser build has no JavaScript engine (ADR-0067). An extends of a file cannot be followed; the bundled presets resolve as on the command line. Errors are the module's own words, shown as they come.
- A cruise result: paste the JSON that
rulebearing cruise -T json(or dependency-cruiser) wrote, and Run evaluates the configuration's rules over it, asrulebearing cruise --graph graph.json -T <output type>does. This is the way in for .NET: assemblies are not accepted, since reading them needs a build a browser cannot run, so paste the cruise result the command line wrote for the solution. - Source files: choose a folder or files, or drop them, holding TypeScript and JavaScript or Python, and Run extracts and evaluates them, as
rulebearing cruise -T <output type>does in that folder. Paths are taken from the chosen folder. The language is the one picked, or, left on Infer from the files, the one with more source files outsidenode_modulesand virtual environments (a tie asks you to pick). Files that are not UTF-8 text, files over 1 MiB, anything past 32 MiB or 20,000 files in all, and version-control folders are skipped, and the note under the pickers names them. A configuration file at the folder's root (rulebearing.yaml,rulebearing.yml,rulebearing.jsonor.dependency-cruiser.json) is loaded into the configuration box. What needs the disk or a process is refused with the reason: the Node sidecar, Yarn Plug'n'Play, a ratchet's budget file, and a path outside the folder. - Explain prints what
rulebearing explain <rule> --graph graph.jsonprints for the named rule over the current cruise result: the pasted one, or thejsoncruise of the chosen files.
Examples:
No files chosen yet.