easement
Architecture quality gate for polyglot codebases.
Declare the components of your system and which of them may depend on which. easement extracts the real dependencies from the code, builds the graph and fails the build when the code drifts from the declared architecture.
easement — a legal right to cross someone else's land. In
easement, every allowed dependency between components is an explicit easement; everything else is trespassing.
Install
Homebrew works on macOS and Linux. Other options:
-
the install script for macOS and Linux, which checks the archive against the checksums of the release and installs to
/usr/local/bin:|Pass
-b <dir>to install elsewhere and-v <version>to pin a release:... | sh -s -- -b ~/.local/bin -v 0.1.0; -
.deband.rpmpackages for amd64 and arm64 on each GitHub release:sudo apt install ./easement_<version>-1_amd64.deborsudo dnf install ./easement-<version>-1.x86_64.rpm; -
cargo install easement; -
prebuilt binaries for macOS, Linux and Windows, with a PowerShell installer for Windows, on each release.
Status
Early. Go, TypeScript/JavaScript, Python and Java work.
Usage
easement.yml lives in the project root; all paths are relative to it.
version: 1
components:
handler:
service:
repository:
model:
common:
rules:
handler:
service:
may_depend_on:
because: services never talk HTTP
exclude:
- components: an
inpattern (a glob or a list of globs) names a module, whether it is a directory or a file:src/app/clicovers thecli/directory with everything below it and a filecli.py.*stays within one directory,**crosses directories. When several components match a file, the longest pattern wins, so a single file can be carved out of a larger component. - rules: an allow-list. A component may depend on itself, on
commoncomponents and on whatmay_depend_onlists — nothing else.becauseis printed with every violation of the rule, so the reader (or an agent) learns where the code should go instead. - Files outside every component are not checked; external packages are not checked yet.
- exclude: globs to skip.
.gitignoreis honoured andnode_modulesis always skipped. - python.roots:
sys.pathentries inside the project, default[".", "src"].
$ easement check
internal/handlers/books.go:6: handler -> repository is not allowed (imports example.com/shop/internal/repository) [dependency]
easement failed: 1 violation(s), 0 stale, 0 legalized; 5 files, 6 internal deps
Rules
Every violation carries a rule id, used in the JSON report, the baseline and suppressions.
| Rule | Fails when |
|---|---|
dependency |
a component imports one that its may_depend_on does not list |
privacy |
a component imports a path of another component outside that component's public API |
cycle |
components covered by no_cycles depend on each other in a cycle |
suppression-reason |
an inline suppression has no reason |
unused-suppression |
an inline suppression matches no violation, or names a rule that cannot be suppressed |
Public API
components:
billing:
in: src/billing
public:
because: billing internals change without notice
Other components may import only the public paths (src/billing/index.ts, anything under src/billing/api); code inside billing reaches its own internals freely. A component without public is open.
Cycles
no_cycles: true # or a list: [billing, orders, catalog]
A group of components that all reach each other is reported once, with the shortest cycle to break:
src/billing/charge.ts:1: component dependency cycle among 3 components: billing, catalog, orders (shortest: billing -> orders -> billing) [cycle]
In diff mode a cycle is reported when any import inside it was added by the change.
Suppressions
An exception for one import, in a comment of any language, always with a reason after --:
// easement:disable-next-line dependency -- legacy admin export, remove with #123
import { exportAll } from "../admin/export";
import { client } from "../billing/generated"; // easement:disable-line privacy -- generated client
A suppression without a reason does not suppress and fails as suppression-reason. One that no longer matches a violation fails as unused-suppression, so exceptions do not outlive the code they excused. Prefer the baseline for old debt; suppressions are for deliberate, reviewed exceptions.
Baseline: adopting on an existing project
easement baseline writes current violations to easement.baseline.yml (path configurable with baseline:). After that the gate is a ratchet:
- violations in the baseline are legalized;
- any new violation fails;
- a baseline entry that no longer occurs also fails, so fixed debt has to be removed from the file with
easement baseline. The baseline can only shrink.
Entries are keyed by rule, file, target and components, not by line, so unrelated edits do not invalidate them. A cycle is keyed by its components.
Diff mode: judging a pull request
easement check --diff <base> reports only violations whose import line was added or changed since the merge-base with <base>, including uncommitted and untracked files. Old violations elsewhere do not fail the change, so the gate can be turned on without a baseline. In CI fetch the base branch (actions/checkout with fetch-depth: 0).
The baseline still applies, and stale entries are still reported. A rule change in easement.yml does not touch the importing lines, so it shows up only in a full easement check; run both on pull requests if the config changes often.
Languages
| Language | Extracts | Resolves |
|---|---|---|
| Go | import |
module paths from every go.mod in the repo |
| TypeScript / JavaScript | import, export … from, import x = require(), require(), import() |
Node/bundler rules via oxc_resolver: relative paths, tsconfig paths, package exports, workspaces, .js → .ts |
| Python | import, from … import, relative imports |
longest existing module or package under the source roots; from a import b resolves to a/b.py if it exists, otherwise to a |
| Java | import, import static, import ….* |
classes indexed by their package declaration, so any source layout and multi-module builds work without configuration |
Known limits: Java references by fully qualified name without an import are not seen; Python imports built from strings (importlib) are not seen.
Adding a language means implementing one trait (src/lang/mod.rs): extract imports from a file, resolve a specifier to a project path or an external module. Rules, baseline and reports are language-agnostic.
Languages whose dependencies are not visible from imports (Ruby, Elixir) are out of scope.
How it works
- Walk the project, parse each supported file with tree-sitter and collect imports.
- Resolve every import to a file or package directory inside the project, or to an external module.
- Map both ends to components and check each edge against the rules.
- Compare violations with the baseline and report.
Inspired by
easement builds on ideas from these tools, each of which covers one language or ecosystem:
- ArchUnit: architecture rules as tests for Java.
- dependency-cruiser: dependency rules for JavaScript and TypeScript.
- import-linter: import contracts between Python modules.
- Deptrac: layers and allowed dependencies for PHP.
- arch-go: architecture checks for Go.
- depguard: import allow and deny lists for Go.
easement puts one component model, one config and one baseline over all of them.
Development
Tools are pinned in mise.toml: mise install, mise run setup for the git hooks, then mise run check (every lint, the same set CI runs) and cargo test. Commits follow Conventional Commits; releases are cut by merging the release-please pull request.
License
MIT