- Rust 97%
- Nix 2%
- Just 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .claude/skills | ||
| docs | ||
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| architecture.rhai | ||
| Cargo.lock | ||
| Cargo.toml | ||
| cartulary.toml | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| README.md | ||
| rust-toolchain.toml | ||
terminvs
Architecture dependency-rule checker. Declare which parts of a codebase may depend on which — and fail the build when an import crosses a line it shouldn't.
Named after Terminus, the Roman god of boundary stones: the markers that fixed the limits of a field were sacred, and moving one was a crime. terminvs fixes the boundaries of an architecture the same way.
Status
Early extraction of the internal check-arch engine from
cartulary. Rust-first: the rule
engine is language-agnostic by design — imports come from a pluggable extractor
— but only the Rust extractor exists today.
What makes it different
- Default-deny. Nothing may depend on anything until a rule allows it. "No bounded context imports another" needs no rule; it is the baseline.
- Captures & a generator library. One parametric rule with captures
(
{X..}::usecasesmay import{X..}::model) applies across every context; or call a generator (hexagonal,stacked,isolated,sealed) that lowers to such rules. - Scoped exceptions with a reason. Accepted drift is explicit, localized, and carries the why.
Design
One crate, hexagonal — terminvs enforces the split on itself, at the module level:
domain— the rule engine: spec, resolution, verdict. No I/O, no language knowledge; it operates on nodes and edges.infra/driven— adapters behind the engine: the Rust extractor (syn) that turns source into edges, the Rhai spec front-end, the report. The seam takes another language's extractor without the engine learning it changed.infra/driving— the CLI: load the spec, run an extractor, feed the core, report.
Spec library
The spec is a Rhai script that generates the low-level rules, so common shapes
ship as generators (named arguments, #{ … }):
any()— every reference, internal and external.hexagonal(#{ model, usecases, driven, driving })— bans the outward edges (model ↛ usecases/adapters; usecases ↛ adapters; driven ↛ driving); you add the inward allows.stacked(#{ scopes, base })— an ordered layer stack.isolated(#{ scopes })— mutually-exclusive peers.sealed(#{ scope, from })— an encapsulated module: face public, internals hidden.
They carry no project names — pure patterns, portable across projects. Most named architectures are one of these shapes:
| Architecture | Shape |
|---|---|
| Clean / Onion / N-tier | stacked (rings inner→outer) |
| Vertical slices / feature modules | isolated |
| Plugin / microkernel | sealed core + isolated plugins |
| Ports & adapters | hexagonal |
Worked example: examples/hexagonal.rhai.
Dogfooding
terminvs and cartulary eat each other: terminvs uses cartu to manage its own
decision records and issues under docs/, and cartulary uses terminvs to check
its own architecture.
Development
Nix + direnv. direnv allow drops you into a shell with the pinned toolchain;
everything runs through just.
License
CeCILL.