Zenith
zenith is a language-agnostic architecture linter for monorepos.
You write down how the parts of your codebase may depend on each other, as
contracts in a zenith.toml, and zenith check reports every import, or
chain of imports, that breaks one. A check of a monorepo with thousands of
files takes well under a second.
If you have used import-linter in Python, the contracts will feel familiar: zenith brings the same ideas to monorepos, whatever their languages.
An example
Two contracts: the first puts the app's code in layers, so that lower layers never import higher ones, and the second keeps server-only code out of client bundles:
[contracts.web-layers]
type = "layers"
layers = ["web/src/app", "web/src/components", "web/src/lib"]
guidance = "Lower layers must not import higher ones."
[contracts.client-safety]
type = "forbidden"
from = [{ path = "web/src/**", directive = "use client" }]
to = ["npm:sharp", "node:*", "npm:server-only"]
guidance = "Keep server-only modules out of client bundles."
zenith check finds a component that imports from the app layer above it, and
a client component that reaches sharp through another module. It shows each
violation with the import, or the chain of imports, that makes it, and how to
fix it, then a line per contract:
error[web-layers]: `web/src/components` must not import `web/src/app`
--> web/src/components/sidebar.tsx:3:32
|
3 | import { DASHBOARD_TABS } from "@/app/tabs";
| ^^^^^^^^^^^^ imports `web/src/app/tabs.ts`
|
= help: move the imported code into a module that may be imported here, or remove this import
= help: Lower layers must not import higher ones.
error[client-safety]: `web/src/components/upload.tsx` must not import `npm:sharp`
--> web/src/components/upload.tsx:3:24
|
3 | import { resize } from "@/lib/images";
| ^^^^^^^^^^^^^^ starts a chain of 2 imports
|
= note: import chain:
web/src/components/upload.tsx:3
-> web/src/lib/images.ts:1
-> npm:sharp
= help: remove this import, or one further along the chain
= help: Keep server-only modules out of client bundles.
Contracts
✗ web-layers layers broken (1 violation)
✗ client-safety forbidden broken (1 violation)
0 kept, 2 broken · 412 files, 968 imports
What it does
- Seven contract types:
forbidden,independence,layers,protected,acyclic,encapsulationandlimits. - Several languages: JavaScript and TypeScript, Go, Python, Rust and Terraform, in one import graph, with the same contracts for each.
- Monorepo aware: workspace packages, Go modules, Python packages and Cargo
crates are found automatically, and can be named in contracts with
pkg:selectors. - Built for adoption: a baseline records the violations a codebase already has, so only new ones fail, and suppressions exempt single imports.
- Made for CI: a stable exit code, and
compact,jsonandgithuboutput formats.
Installation
zenith is a single binary, for macOS, Linux and Windows. Every GitHub release has prebuilt binaries and installers, and is published to npm, to a Homebrew tap, and as a VS Code extension.
npm
To add zenith to a project's dev dependencies, and run it with npx:
npm install --save-dev @feds01/zenith
npx zenith check
Install it with --global to have zenith everywhere.
The binary for each platform is in a package of its own, such as
@feds01/zenith-darwin-arm64 or @feds01/zenith-linux-x64, which
@feds01/zenith lists as optional dependencies, so the package manager
installs only the one for your platform. There is no install script, and
nothing is downloaded when zenith first runs:
- bun installs it without an entry in
trustedDependencies; - the lockfile's integrity hashes cover the binary;
- it installs offline from the package manager's cache.
There are packages for macOS (Apple silicon and Intel), Linux (ARM64 and
x86-64, static binaries that run with glibc or musl) and Windows (x86-64).
Leaving optional dependencies out, e.g. with npm install --omit=optional,
leaves out the binary too, and zenith then says which package to add. On
other platforms, build zenith from source and set
ZENITH_BINARY to the path of the binary.
Homebrew
brew install feds01/tap/zenith
Use the full name: brew install zenith installs a different tool of the same
name, from Homebrew's core formulae.
Shell installer
On macOS and Linux:
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/feds01/zenith/releases/latest/download/zenith-installer.sh | sh
PowerShell installer
On Windows:
powershell -ExecutionPolicy Bypass -c "irm https://github.com/feds01/zenith/releases/latest/download/zenith-installer.ps1 | iex"
Prebuilt binaries
Download the archive for your platform from the
latest release, unpack it,
and put zenith on your PATH.
From source
With a Rust toolchain:
cargo install --locked --git https://github.com/feds01/zenith zenith
VS Code
The VS Code extension comes with zenith, and runs a project's own zenith when it has one, from npm. It shows where the code breaks its contracts, as you save.
Check the installation
zenith version
Getting started
1. Write a configuration
In the root of your repository, run:
zenith init
It writes a zenith.toml for the workspace packages it finds. With two or
more packages, its one contract keeps them free of dependency cycles:
#:schema https://raw.githubusercontent.com/feds01/zenith/main/zenith.schema.json
[contracts.packages-acyclic]
type = "acyclic"
modules = ["pkg:*"]
The #:schema line lets editors with a TOML language server (Taplo, or Even
Better TOML in VS Code) complete and check the file.
zenith reads JavaScript and TypeScript by default. For a project in any other
language, or one that mixes several, enable each language with its table, such
as [languages.python]: see languages.
2. Check the contracts
zenith check
zenith finds the project's source files, parses and resolves their imports in parallel, and checks every contract against the import graph. Each violation is shown with the code that makes it, then a line per contract and a summary:
Contracts
✗ packages-acyclic acyclic broken (1 violation)
0 kept, 1 broken · 2 files, 2 imports
zenith check exits with 1 when a contract is broken, so it can fail a CI job,
as with zenith's GitHub Action.
3. Add contracts
Describe the architecture you want, a rule at a time: see the contract types. Three commands help while writing them:
zenith suggestprints contracts that the code already keeps, inferred from the import graph, ready to add tozenith.toml: see suggesting contracts.zenith graph web/src --depth 1shows a directory's children and the imports between them.zenith why web/src/lib web/src/appshows why one part imports another, chain by chain.
4. Adopt it on an existing codebase
A large codebase usually breaks its contracts in many places at first.
zenith baseline records the existing violations, so that zenith check only
fails on new ones, and the baseline shrinks as they are fixed.
Concepts
The project root
The project root is the directory with the zenith.toml. zenith finds it by
searching upward from the working directory, or takes it from --config or
--root. Every path in the configuration, in the output and in the baseline
is a POSIX path relative to the root, with / separators on every platform.
Nodes
zenith models the codebase as a tree of nodes, with the external packages and builtins that it imports:
| Node | Example | |
|---|---|---|
| Directory | web/src/domains | Every ancestor directory of a file. A directory stands for its whole subtree, and is also what an import of a Go package refers to. |
| File | web/src/domains/ai/api/client.ts | Every source file, and every file inside the root that an import resolves to, such as .css or .json. |
| External package | npm:react, gomod:github.com/pkg/errors | A package from outside the project, with its language's scheme. |
| Builtin | node:fs, go:net/http | A module of a runtime or standard library, with its language's scheme. |
Each language says how it names its external packages and
builtins. A file such as an index.ts is an ordinary file, with no special
role for its directory.
Workspace packages
Each language finds the packages of the project: the workspace packages of a
JavaScript monorepo, the modules of a Go project, and the crates of a Cargo
workspace. Each has a name and a
directory, and names and directories often differ, so contracts can name
packages with pkg: selectors. The languages
page says where each language finds them.
pkg: selector matches packages by name, and picks their directories.Imports
An import is an edge from a file to a file, directory, package or builtin. Each import statement is recorded with its kind:
| Kind | |
|---|---|
value | an ordinary import, which every language has |
type | an import that is erased at runtime, such as TypeScript's import type |
dynamic | an import made while the code runs, such as JavaScript's import("s") |
require | a CommonJS require("s") |
The languages page lists the statements that make each kind.
Contracts can leave out imports by kind with
exclude_kinds: type-only imports
are erased at runtime, so they are often excluded.
Re-exports
In JavaScript and TypeScript, a pass-through module only re-exports other
modules, such as an index.ts of export * from "./a" and
export { b } from "./b" lines, with no code of its own. JavaScript calls it a
barrel. Through it, the import graph has a chain to every module it
re-exports, so import { format } from "./lib" reaches whatever else
lib/index.ts re-exports, even server code. Bundlers that know the module has
no side effects leave the unused modules out.
A contract with reexports = "transparent"
sees an import of names from a pass-through module as imports of the modules
that define them, following re-exports and export * through nested
pass-through modules. An import that takes the whole module, such as
import * as lib from "./lib", still imports all of it, and so does an
import of a name that can't be followed, such as one that may come from an
external package through export *. Importing only a name's type is a
type import.
[contracts.client-safety]
type = "forbidden"
from = [{ path = "web/src", directive = "use client" }]
to = ["node:*", "npm:sharp"]
reexports = "transparent"
exclude_kinds = ["type"]
encapsulation contracts always see re-exports as they are, since importing
a module through its entry point, which often only re-exports, is what they
allow.
Units
Contracts reason about units: labelled sets of nodes, which selectors pick. A matched directory is one unit, with its whole subtree as members; a matched file, package or builtin is a unit of one.
web/src/features/* matches is a unit, with everything under it.Two units overlap if they share a member, and contracts skip pairs of
overlapping units, so from = ["a"] and to = ["a/*"] checks nothing between
a and itself.
Directives
A directive is a string literal at the very top of a file, before any other
code, such as "use client". In React Server Components, "use client" makes
a file the entry point of a client bundle, so everything it imports ships to
the browser. zenith records every directive as written, and a
directive selector can pick the files that
have one, to draw a boundary that the directory layout doesn't show.
directive selector makes each of them a unit.Languages
zenith reads JavaScript and TypeScript, Go, Python, Rust and Terraform. Contracts, selectors, the baseline and the output work the same in every language: each language only decides which files it reads, which packages a project has, and what each import refers to. Each has a page of its own:
| Language | Table | Files | Packages | External packages | Builtins |
|---|---|---|---|---|---|
| JavaScript and TypeScript | [languages.js] | .ts, .tsx, .mts, .cts, .js, .jsx, .mjs, .cjs | workspace packages | npm:react | node:fs, bun:sqlite |
| Go | [languages.go] | .go | modules | gomod:github.com/pkg/errors | go:net/http |
| Python | [languages.python] | .py, .pyi | packages with a pyproject.toml | pypi:requests | python:os |
| Rust | [languages.rust] | .rs | crates | crate:serde | rust:std |
| Terraform | [languages.terraform] | .tf, .tofu, terragrunt.hcl | none | tfmod:hashicorp/consul/aws | none |
Enabling languages
Each language has a table of its own in [languages], with the options that
its page lists, and a language is enabled by writing its table, even an empty
one:
[languages.js]
[languages.go]
[languages.python]
[languages.rust]
[languages.terraform]
Without a [languages] table, JavaScript and TypeScript are enabled. A
[languages] table with no language in it is an error, since there would be
nothing to scan. Several languages can be enabled at once, and their files
share one import graph, so a contract can span them.
Files
Each language scans the files that its include globs match, which default to
its own source files. A file belongs to the first language whose include
matches it, or, for a language that leaves include to its default, whose
extensions or file names it has, such as Terraform's terragrunt.hcl. The
[files] table is about what is skipped, whatever
the language.
A path selector may leave the extension off a file, so src/types matches the
file src/types.ts as well as the directory src/types. The extensions it
may leave off are those of the enabled languages.
Schemes
A language owns the selector schemes that only its projects have, such as
npm: for JavaScript and gomod: for Go, so a selector that uses one is an
error when its language is not enabled. pkg: selects the packages of any
language, and belongs to none.
JavaScript and TypeScript
[languages.js] # also accepted as [languages.typescript]
include = ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"] # the default
tsconfig = "auto" # the nearest tsconfig.json of each file; "off"; or a path
conditions = ["types", "source", "import", "module", "require", "node", "default"]
main_fields = ["module", "main"]
extensions = [".ts", ".tsx", ".d.ts", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"]
alias = { "types" = "web/src/types" } # aliases only a bundler knows
ignore_unresolved = ["/*", "virtual:*"] # specifiers not to warn about
JavaScript and TypeScript are enabled when the configuration has no
[languages] table. The table is called js, and typescript is accepted for
it too; giving both is an error.
| Option | |
|---|---|
include | Globs of the files to scan. |
tsconfig | Which tsconfig.json files to use: "auto" uses the nearest one of each file, "off" uses none, and any other value is the path of the one to use for every file. |
conditions | The condition names to match in exports maps, replacing the defaults. |
main_fields | The package.json fields that point at a package's entry point, replacing the defaults. |
extensions | The extensions to try, in order, when a specifier has none, replacing the defaults. |
alias | Aliases that only a bundler knows: a specifier prefix, and the path that it maps to. |
ignore_unresolved | Name patterns of the specifiers that are expected not to resolve, so are not reported as unresolved. |
Packages
Workspace packages are found from the root package.json's workspaces
field and from pnpm-workspace.yaml. Each has a name, from its own
package.json, and a directory, so pkg:@acme/ui selects the package named
@acme/ui wherever it lives.
Imports
Each import statement is recorded with its kind:
| Kind | Made by |
|---|---|
value | import x from "s", import "s", export … from "s" |
type | import type …, export type …, imports whose every name is type, and TypeScript import types such as typeof import("s") |
dynamic | import("s") with a literal specifier |
require | require("s") with a literal specifier, and import x = require("s") |
Imports whose specifier isn't a literal, such as import(path), are skipped
and counted; -v lists them. A file's directives,
such as "use client", are recorded too, and contracts can see through
re-exports.
Resolution
Specifiers resolve as TypeScript and Node resolve them, with each file's
nearest tsconfig.json and its paths, project references and extends.
Imports of workspace packages resolve to their source, through their exports
maps, and so do tsconfig.json files that extend one from a workspace package,
whether or not node_modules is installed. A project inside another checkout,
such as a git worktree, never resolves them through the other checkout's
node_modules.
| Imported | Node | |
|---|---|---|
| a file of the project | web/src/lib/format.ts | |
| an external package | npm:react | Named without its subpath, so lodash/get is npm:lodash. A bare specifier that doesn't resolve still names its package, so npm: contracts work in CI without installing dependencies. |
| a builtin | node:fs, bun:sqlite | Node builtins always have the node: prefix, and lose their subpath too: fs/promises is node:fs. |
A tsconfig.json that extends one from any other package can only be loaded
once the package is installed. Until then, its files resolve without it, so
their path aliases don't resolve, and zenith check says so.
Go
[languages.go] # off unless the table is written
include = ["**/*.go"] # the default
ignore_unresolved = ["example.com/generated/*"] # import paths not to warn about
| Option | |
|---|---|
include | Globs of the files to scan. |
ignore_unresolved | Name patterns of the import paths that are expected not to resolve, so are not reported as unresolved. |
Packages
The project's modules are found from its go.mod and go.work files. Each is
named by its module path, so pkg:example.com/api selects a module.
Imports
Every import is a value import. An import of a package in the project is an
import of its directory, which is one node of the graph, so a contract names
internal/store and not the files in it.
zenith-ignore comments work among a file's imports, as
// or /* */ comments.
Resolution
Import paths resolve against the modules that the go.mod and go.work files
declare.
| Imported | Node | |
|---|---|---|
| a package of the project | internal/store | Its directory. |
| the standard library | go:net/http | The whole import path. |
| any other module | gomod:github.com/pkg/errors | Named by the module that provides it. A module that no go.mod requires is named from the import path alone. |
Python
[languages.python] # off unless the table is written
include = ["**/*.py", "**/*.pyi"] # the default
roots = ["services/api/src", "."] # found from the packages unless given
ignore_unresolved = ["generated.*"] # module paths not to warn about
| Option | |
|---|---|
include | Globs of the files to scan. |
roots | The directories that absolute imports start from, relative to the project root, as on sys.path. |
ignore_unresolved | Name patterns of the module paths that are expected not to resolve, so are not reported as unresolved. |
Packages
Every pyproject.toml that names a package, in its [project] table or in
Poetry's [tool.poetry], is a package, which pkg: selects by that name, so
pkg:acme-* selects the packages whose names start with acme-. A
pyproject.toml that only configures tools, or a uv workspace, is not a
package.
Roots
An absolute import, such as import acme.api, starts from a root, as it
would from a directory on sys.path. Unless roots names them, the roots are
the src directory of each package in the src layout, or the package's own
directory otherwise, and then the project root. The roots that hold the
importing file come first.
A file that no root reaches, such as a script run from its own directory or
a test that pytest imports, also starts from the directory above its outermost
package: the nearest directory above it without an __init__.py. So
scripts/migrate.py can import helpers from scripts/helpers.py.
Imports
import a.b imports a.b, and from a import b imports b from a, which
is the module a.b if there is one, and the name b in a otherwise.
from a import * imports a, and a lazy import, such as lazy import a, is
an import like any other. Relative imports, such as from . import b and
from ..c import d, start at the importing file's package. from __future__
imports nothing.
| Import | Kind |
|---|---|
import, from … import | value, wherever it is, in a function or a try as at the top of the file |
an import in an if TYPE_CHECKING: block | type |
importlib.import_module("a.b"), __import__("a") | dynamic |
A call of importlib.import_module or __import__ whose module is not a
string literal, or is relative, can't be resolved, and is counted as a
non-literal import. zenith-ignore comments work as #
comments.
Resolution
A module path leads through the packages to the file of the last module on
it, and what follows is names in that module. A regular package is its
__init__.py, and a module is its .py file, or its .pyi stub when it has
no source. A directory without an __init__.py is a namespace package, which
leads on to the modules in it. Names are compared exactly, even where the file
system ignores case.
| Imported | Node | |
|---|---|---|
| a module of the project | services/api/src/acme/api/handlers.py | Its file. from . import * in a namespace package is its directory, and any other name in one that is not a module doesn't resolve. |
| the standard library | python:os | Named by its top-level module. |
| anything else | pypi:requests | Named by its top-level module, which is usually, but not always, the name of the distribution: import yaml is pypi:yaml, from PyYAML. |
A module or a regular package of the project hides a standard library module
of the same name, as it does when Python runs, but a namespace package does
not, so a directory called email doesn't hide email. A file doesn't import
itself.
Rust
[languages.rust] # off unless the table is written
include = ["**/*.rs"] # the default
ignore_unresolved = ["generated::*"] # paths not to warn about
| Option | |
|---|---|
include | Globs of the files to scan. |
ignore_unresolved | Name patterns of the paths that are expected not to resolve, so are not reported as unresolved, such as a module that a macro declares. |
Packages
Every Cargo package of the project is a crate, which pkg: selects by its
package name, so pkg:zenith_lang_* selects the crates whose names start with
zenith_lang_. zenith asks Cargo for the crates, with
cargo metadata --no-deps --offline, which reads the manifests but resolves no
versions and never touches the network. Without Cargo, or when it fails,
zenith reads the Cargo.toml files itself and finds each crate's targets the
way Cargo does.
Each target of a crate is the root of a module tree: its library, its binaries, its tests, benchmarks and examples, and its build script. The other targets of a crate use its library by its name.
Imports
Every import is a value import. Each use declaration imports each of its
leaves, so use a::{b, c::d} imports a::b and a::c::d, and a glob imports
the path before it. So does an extern crate, and so does a path in code that
starts with crate, self, super, :: or the name of a crate or module,
such as zenith_lang_js::JS written without a use. A use path that starts
with a name that another use or an extern crate brought into scope imports
what the name stands for: after use smoltcp::socket::tcp;, use tcp::State;
imports smoltcp::socket::tcp::State. mod a; declares a module but doesn't
import it. zenith-ignore comments work as // or
/* */ comments.
The tokens of a macro call or an attribute aren't parsed as code, so zenith
reads the paths in them: vec![crate::a::b()] imports crate::a::b. A path
that starts at a macro variable, such as $crate::a or $module::a, or at an
interpolation, which macros like quote! write as #krate::a, imports
nothing. Nor does a path in the body of quote!, quote_spanned!,
parse_quote! or parse_quote_spanned!, whatever path names the macro: the
body is code that a procedural macro generates for the crates that use it, so
its paths are theirs, not imports of the macro's crate.
Files are parsed with rust-analyzer's
parser, in the edition of their crate, which Cargo or the crate's
Cargo.toml gives (and Rust 2015 if it gives none). A file in no crate is
parsed in the latest edition.
Resolution
A path imports the file of the last module on it: a.rs or a/mod.rs for the
module a, in the module tree of the importing file's target. The rest of the
path is items in that file. The file is spelt as the module is, even where
names ignore case, as they do by default on macOS and Windows, so crate::Config
is the type Config in the crate root, not the module in config.rs. A crate's own dependencies, from its
[dependencies], [dev-dependencies] and [build-dependencies], including
renamed ones and those that workspace = true takes from the workspace, are
the crates that its code may name.
| Imported | Node | |
|---|---|---|
| a module of the project | crates/api/src/store.rs | Its file. A dependency that is a crate of the project resolves into the module tree of its library. |
| another crate | crate:serde | Named by its package. |
| the standard library | rust:std | std, core, alloc, proc_macro and test. |
An inline mod a { } block is part of its file, so a path into it, used or
not, imports nothing. #[path] attributes are not followed, and a file
doesn't import itself. A raw identifier names what it spells: r#false is the
module false. A path that starts with the name of a tool, such as
clippy::too_many_lines in an #[allow] or rustfmt::skip, is the tool's
attribute or lint, and imports nothing unless a crate or module has the name.
Terraform
Terraform, OpenTofu and Terragrunt files share one language.
[languages.terraform] # off unless the table is written
include = ["**/*.tf", "**/*.tofu", "**/terragrunt.hcl"] # the default
ignore_unresolved = ["../../shared/*"] # module sources not to warn about
registries = ["terraform-mirror.acme.com"] # mirrors of the public registry
repository_hosts = ["*.ghe.acme.com"] # read like github.com
bucket_hosts = ["minio.acme.com"] # read like S3
| Option | |
|---|---|
include | Globs of the files to scan. A .hcl file that it matches is read as a Terragrunt file. |
ignore_unresolved | Name patterns of the module sources and dependency paths that are expected not to resolve, so are not reported as unresolved. |
registries | Name patterns of hosts that serve the public registry's modules, such as a mirror, whose modules are named as the public registry's are. |
repository_hosts | Name patterns of hosts of repositories that module sources name without a scheme, as they name GitHub's, such as a GitHub Enterprise server. |
bucket_hosts | Name patterns of hosts of buckets that module sources name without a scheme, as they name S3's. |
Each host option adds to the public hosts: the public registries,
github.com and bitbucket.org, and S3 (*.amazonaws.com) and GCS
(www.googleapis.com). Hosts are matched in lowercase.
The .terraform and .terragrunt-cache directories, where Terraform and
Terragrunt keep the modules they download, are never scanned.
Packages
Terraform has no manifest that names a package, so its projects have no
packages, and pkg: selects none of their directories. Name a module by its
directory instead, such as modules/vpc.
Imports
A module is a directory, so an import of a module of the project is an import
of its directory, which is one node of the graph, and a contract names
modules/vpc and not the files in it. Every import is a value import, and is
located by its string:
| File | Import |
|---|---|
.tf, .tofu | The source of each module block. |
terragrunt.hcl | The source of the terraform block, the config_path of each dependency block, and each of the paths of the dependencies block. |
A value made from an expression, such as
"${get_repo_root()}/modules/vpc" or local.source, can't be read without
evaluating it, so it is left out of the graph. include blocks and the other
attributes of a block are not imports.
zenith-ignore comments are #, // or /* */
comments. One on the line where a block or an attribute starts, or on the line
above it, applies to every import in it:
# zenith-ignore stacks-independent: shares the production database until the split
module "database" {
source = "../prod"
}
Resolution
| Imported | Node | |
|---|---|---|
a local path, such as ../../modules/vpc | modules/vpc | The directory it leads to from the importing file's directory, which must be in the project. A // before a subdirectory, as in ../modules//vpc, leads to the subdirectory. |
a registry module, such as hashicorp/consul/aws | tfmod:hashicorp/consul/aws | Named by its namespace, name and target system. A private registry's hostname stays in front, as in tfmod:app.terraform.io/acme/vpc/aws, unless registries names it as a mirror of the public one. Terragrunt's tfr:// addresses are read the same. |
a repository, a bucket or an archive, such as git::https://github.com/acme/modules.git//vpc?ref=v1.2.0 | tfmod:github.com/acme/modules | Named by its host and path, without the getter, scheme, credentials, port, .git, subdirectory or query. On GitHub, Bitbucket and the repository_hosts, written without a scheme, the repository is the first two segments of the path, so github.com/acme/modules/vpc is tfmod:github.com/acme/modules too. |
Registry names and hostnames ignore case, so both are in lowercase.
Each file resolves as its tool reads it:
- In a Terraform file, as Terraform requires, a local path starts with
./or../, somodules/vpcis not a module source, and is reported as unresolved. Anything else that is not a repository is a registry address. - In a Terragrunt file, a registry module is written with
tfr://, and anything else that is not a repository is a local path, soconfig_path = "vpc"is the directoryvpcnext to the file.
Selectors
A selector picks the units that a contract is about. It is a string, or a table for the advanced forms.
String forms
| Form | Matches | Example |
|---|---|---|
| path glob | directories and files in the project | web/src/domains/*, packages/ai/src/**/server.ts |
pkg:<name> | the directory of each matching package, of any language | pkg:@acme/*, pkg:example.com/api |
<scheme>:<name> | the external packages or builtins of a language | npm:sharp, node:*, gomod:github.com/pkg/*, crate:serde |
Each scheme belongs to a language, whose page lists its schemes, so using one while its language is not enabled is an error, as is any other scheme that zenith doesn't know.
pkg:* never matches the root package, which would contain everything.
Package, external and builtin names are matched with name patterns, in which
* matches any run of characters, / included, so npm:* matches scoped
packages like @tanstack/react-query.
When a path glob matches nested nodes, such as web/** matching both
web/src and web/src/a.ts, only the outermost become units, since they
already contain the others.
Glob syntax
zenith's glob dialect needs no escaping for Next.js and TanStack file names:
/separates segments. A leading./is ignored.*matches any run of characters within one segment.**as a whole segment matches zero or more segments; a trailing/**matches one or more, soa/**means strictly insidea.?matches one character,{a,b}either alternative, and\escapes the next character.[,],(,),$,@and+are literal, soweb/src/app/(dashboard)/[id]/page.tsxmatches that file.- Files match with or without the extension of an enabled language, such as
.tsforjs, sosrc/typesmatches both the directorysrc/typesand the filesrc/types.ts.
The table form
{ path = "web/src/**", directive = "use client" }
{ path = "web/src/features", except = ["web/src/features/experimental"] }
pathis any string-form selector.exceptremoves the members that its selectors match from each unit.directivekeeps only the files whose directives include it, exactly as written, and makes each file a unit of its own, so a violation names the file. It only applies to path andpkg:selectors.
Selectors that match nothing
A path or pkg: selector that matches nothing is a configuration error, since
it is most likely a typo or a rename. The selectors of a language's scheme, such
as npm:sharp, may match nothing: it only means that nothing imports the
package. The check is made
before except and directive filter the units, and never for
ignore_imports entries.
Continuous integration
zenith's GitHub Action checks the contracts in a workflow. It installs zenith
from npm, runs zenith check, and uploads the results to GitHub code
scanning, which shows each one as an alert on its line of a pull request. The
step fails when a contract is broken.
steps:
- uses: actions/checkout@v7
- uses: feds01/zenith@v0.6.0
Uploading to code scanning needs the security-events: write permission:
name: Architecture
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
security-events: write
actions: read # only needed in a private repository
jobs:
zenith:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: feds01/zenith@v0.6.0
Inputs
| Input | Default | |
|---|---|---|
version | the action's | The version of zenith to install from npm, such as 0.6.0, or a dist-tag, such as latest. See the version. |
config | The configuration file, instead of the nearest zenith.toml. | |
upload-sarif | true | true to upload the results to code scanning, as a SARIF log, or false to annotate them instead. Any other value is an error. |
category | zenith | The category of the upload. Give each run of the action in a repository its own, or each upload replaces the results of the others. |
args | More arguments for zenith check, such as --contract web-layers. They are split on spaces and newlines, and quotes are not interpreted, so an argument can't contain a space. Not --format, which the action picks. | |
binary | The path of a zenith binary to run instead of installing one, such as one built from source. Nothing is installed then. |
And its output:
| Output | |
|---|---|
exit-code | zenith's exit code: 0 when every contract of error severity is kept, 1 when one is broken, and 2 when zenith couldn't check, such as with an invalid configuration or no zenith at binary. |
Each run also writes a summary of the check to the workflow run's page, which is where a push to a branch shows its result.
Installing zenith needs Node.js and npm, which GitHub's runners have. On a
runner without them, add actions/setup-node@v6 before the action, or build
zenith and give its path as binary.
The version of zenith
Used at a release tag, such as feds01/zenith@v0.6.0, the action installs
the zenith of that release, so pinning the action pins zenith too. At any
other ref, it installs the latest release unless version names one, so
set version when you:
- pin the action by the SHA of a commit, as in
feds01/zenith@<sha> # v0.6.0; - call it from a composite action of your own, where the action's ref isn't reliable (actions/runner#2473).
Code scanning or annotations
zenith runs once, and its results reach the pull request one of two ways:
- With
upload-sarif, zenith writes a SARIF log and the action uploads it. Code scanning shows each result on its line of the pull request, and keeps it as an alert in the repository's Security tab until a later upload no longer has it. - Without, zenith writes annotations, which GitHub shows on their lines for that run only.
Code scanning needs GitHub Advanced Security in a private repository, and the workflows of pull requests from forks can't upload to it. Annotate those instead:
- uses: feds01/zenith@v0.6.0
with:
upload-sarif: ${{ github.event.pull_request.head.repo.fork != true }}
When a contract is broken, the action still uploads the results, then fails
with exit-code 1. When zenith couldn't check, the action fails with
exit-code 2 without uploading anything.
A project in a subdirectory
The paths of a SARIF log are relative to the root of the git repository, so
code scanning finds the files of a project anywhere in it. Running the action
on two projects of one repository, give each its own category:
- uses: feds01/zenith@v0.6.0
with:
config: web/zenith.toml
category: zenith-web
- uses: feds01/zenith@v0.6.0
with:
config: services/zenith.toml
category: zenith-services
The paths of annotations, with upload-sarif: false, are still relative to
the project root (the directory of the configuration file, unless args has
--root), while GitHub reads them from the root of the repository. To
annotate, the project root must be the repository's.
Contracts
Contracts are tables under [contracts.<id>]. The id is how --contract, the
baseline and suppression comments refer to the contract: it may contain ASCII
letters, digits, - and _, and is kebab-case by convention.
| Type | Rule |
|---|---|
forbidden | Some units must not import others, directly or through any chain of imports. |
independence | Units must not depend on one another. |
layers | Higher layers may import lower layers, never the reverse. |
protected | Only some importers may import a unit directly. |
acyclic | Units must not depend on each other in cycles. |
encapsulation | A unit may only be imported through its entry points. |
limits | A unit may only be imported by so many files, and import so many modules. |
Common options
Every contract type takes these options:
| Option | Default | |
|---|---|---|
type | required | The contract type, such as "layers". |
name | the id | The title shown in the output. |
severity | "error" | "warn" reports a broken contract without failing the check, for rolling a contract out gradually. |
exclude_kinds | [analysis] exclude_kinds | The import kinds to leave out, e.g. ["type"]. |
reexports | [analysis] reexports | "transparent" sees an import of names from a [pass-through module](../concepts.md#pass-through modules) as imports of the modules that define them, as bundlers load them; "opaque" as an import of the pass-through module. Not for encapsulation. |
ignore_imports | [] | "<selector> -> <selector>" entries: the direct imports between them are left out of the contract. |
unmatched_ignore_imports | "error" | Whether an ignore_imports entry that matches no import breaks the contract ("error"), is a warning ("warn") or is fine ("none"). |
guidance | none | Text shown under each violation, such as how to fix it. |
An import between two files is left out of a contract when none of its statements count: each statement may be of an excluded kind, suppressed with a comment, ignored, or in the baseline.
forbidden
The from units must not import the to units, directly or through any chain
of imports.
Example
[contracts.client-safety]
type = "forbidden"
name = "Client components never reach server-only code"
from = [{ path = "web/src/**", directive = "use client" }]
to = ["npm:sharp", "node:*", "npm:server-only"]
guidance = "Keep server-only modules out of client bundles."
upload.tsx reaches npm:sharp through lib/images.ts: one violation, shown with its chain.No file under web/src that starts with "use client" may reach sharp, a
Node builtin or server-only. Each client file is a unit of its own, so a
violation names it and shows the chain, such as
web/src/components/upload.tsx → web/src/lib/images.ts → npm:sharp.
Server components and route handlers are unaffected, since they have no
"use client".
Each pair of units is one violation, with every chain between them, shortest first: as in import-linter, once a chain is found its imports are set aside, and the search runs again until no chain is left.
Options
| Option | Default | |
|---|---|---|
from | required | Selectors. |
to | required | Selectors, which may be external packages and builtins. |
allow_indirect_imports | false | Only direct imports count. |
Common options
Like every contract type, forbidden also takes:
| Option | Default | |
|---|---|---|
type | required | The contract type, such as "layers". |
name | the id | The title shown in the output. |
severity | "error" | "warn" reports a broken contract without failing the check, for rolling a contract out gradually. |
exclude_kinds | [analysis] exclude_kinds | The import kinds to leave out, e.g. ["type"]. |
reexports | [analysis] reexports | "transparent" sees an import of names from a [pass-through module](../concepts.md#pass-through modules) as imports of the modules that define them, as bundlers load them; "opaque" as an import of the pass-through module. Not for encapsulation. |
ignore_imports | [] | "<selector> -> <selector>" entries: the direct imports between them are left out of the contract. |
unmatched_ignore_imports | "error" | Whether an ignore_imports entry that matches no import breaks the contract ("error"), is a warning ("warn") or is fine ("none"). |
guidance | none | Text shown under each violation, such as how to fix it. |
independence
The modules units must not depend on one another, in either direction,
directly or through other modules.
Example
[contracts.features-independent]
type = "independence"
modules = ["web/src/features/*"]
ignore_imports = ["web/src/features/* -> web/src/features/shared"]
shared are ignored.Each directory under web/src/features is a unit, and no feature may depend
on another: features/checkout importing features/catalog/api.ts breaks the
contract, and so does an indirect route such as features/checkout →
web/src/lib/cart.ts → features/catalog.
features/shared matches features/* too, so every feature that uses it
would be flagged. The ignore_imports entry exempts imports into shared,
while shared importing a feature is still flagged.
A chain through a third unit is reported as two violations, one on each side of it.
Options
| Option | Default | |
|---|---|---|
modules | required | Selectors. The units may not overlap. |
allow_indirect_imports | false | Only direct imports count. |
Common options
Like every contract type, independence also takes:
| Option | Default | |
|---|---|---|
type | required | The contract type, such as "layers". |
name | the id | The title shown in the output. |
severity | "error" | "warn" reports a broken contract without failing the check, for rolling a contract out gradually. |
exclude_kinds | [analysis] exclude_kinds | The import kinds to leave out, e.g. ["type"]. |
reexports | [analysis] reexports | "transparent" sees an import of names from a [pass-through module](../concepts.md#pass-through modules) as imports of the modules that define them, as bundlers load them; "opaque" as an import of the pass-through module. Not for encapsulation. |
ignore_imports | [] | "<selector> -> <selector>" entries: the direct imports between them are left out of the contract. |
unmatched_ignore_imports | "error" | Whether an ignore_imports entry that matches no import breaks the contract ("error"), is a warning ("warn") or is fine ("none"). |
guidance | none | Text shown under each violation, such as how to fix it. |
layers
Higher layers may import lower layers, never the reverse.
Example
[contracts.package-tiers]
type = "layers"
layers = [
"pkg:web | pkg:sdk | apps/* | services/*",
"pkg:@acme/ui | pkg:@acme/forms",
"pkg:@acme/client",
"pkg:@acme/core",
"pkg:@acme/types : pkg:@acme/config",
]
exclude_kinds = ["type"]
@acme/core importing @acme/forms points up, and web importing sdk crosses an independent layer.The workspace packages are in five tiers, highest first, and each may import
any tier below it but nothing above. In the top tier, web, sdk, the apps
and the services may not import one another. At the bottom, @acme/types and
@acme/config may import each other. Type-only imports don't count.
Writing layers
Each entry of layers is a string:
"a"is a layer. Entries are selectors, so"apps/*"is one layer of every app, which may import each other."a | b"is one layer of independent siblings, which may not import each other."a : b"is one layer of siblings that may import each other. The:must have spaces around it, sincepkg:,npm:andnode:contain one too."(a)"is an optional layer, which a container may not have.
An entry may also be a table, { layer = "a | b", closed = true }. Layers
above a closed layer may not import the layers below it: they must go
through it.
layers = ["app", { layer = "service", closed = true }, "db"]
app may import service, which may import db, but app may not import db past the closed layer.Layers may not overlap. The illegal dependencies are those from a lower layer
to a higher one, between independent siblings, and across a closed layer.
Each is searched for without passing through any other layer, so a chain
low → mid → high is reported as low → mid and mid → high.
Containers
[contracts.domain-internals]
type = "layers"
containers = ["packages/api/src/domains/*"]
layers = ["hooks", "api", "types"]
Inside each domain, hooks may import api and types, api may import
types, and types may import neither. Each domain is checked on its own, so
imports between domains are outside this contract.
Options
| Option | Default | |
|---|---|---|
layers | required | Ordered from highest to lowest; see Writing layers. |
containers | none | Selectors. If set, the layers are relative to each container, and each container is checked on its own. |
exhaustive | false | Every child of every container must be declared as a layer. Needs containers. |
exhaustive_ignores | [] | Names of children that exhaustive doesn't require, with or without their extension. |
allow_indirect_imports | false | Only direct imports count. |
Common options
Like every contract type, layers also takes:
| Option | Default | |
|---|---|---|
type | required | The contract type, such as "layers". |
name | the id | The title shown in the output. |
severity | "error" | "warn" reports a broken contract without failing the check, for rolling a contract out gradually. |
exclude_kinds | [analysis] exclude_kinds | The import kinds to leave out, e.g. ["type"]. |
reexports | [analysis] reexports | "transparent" sees an import of names from a [pass-through module](../concepts.md#pass-through modules) as imports of the modules that define them, as bundlers load them; "opaque" as an import of the pass-through module. Not for encapsulation. |
ignore_imports | [] | "<selector> -> <selector>" entries: the direct imports between them are left out of the contract. |
unmatched_ignore_imports | "error" | Whether an ignore_imports entry that matches no import breaks the contract ("error"), is a warning ("warn") or is fine ("none"). |
guidance | none | Text shown under each violation, such as how to fix it. |
protected
Only the allowed_importers, and the protected units' own members, may
import a modules unit directly.
Example
[contracts.agents-are-api-only]
type = "protected"
modules = ["web/src/agents"]
allowed_importers = ["web/src/app/api"]
Say web/src/agents holds LLM agents that read API keys and call paid
services, and should only run behind API routes:
- Broken by
web/src/components/chat.tsximporting@/agents/support, and byweb/src/lib/run.tsimporting@/agents/tools. - Allowed:
web/src/app/api/chat/route.tsimporting an agent, and the agents importing each other. - Not checked:
chat.tsximportingrun.ts, which isn't protected, so the chain fromchat.tsxto an agent isn't reported, onlyrun.ts's own import.protectedonly checks direct imports, to keep the list of importers short and deliberate; aforbiddencontract can rule out the chains as well.
Restricting a third-party dependency
A language's scheme protects an external package, so that only some of your code may use it:
[contracts.sharp-only-in-images]
type = "protected"
modules = ["npm:sharp"]
allowed_importers = ["web/src/images"]
Say sharp, a native image library, should stay behind one module that
resizes images:
- Broken by
web/src/app/profile/avatar.tsimportingsharp. - Allowed:
web/src/images/resize.tsimportingsharp, andavatar.tsimportingweb/src/images/resize.ts.
Other languages' packages work the same, such as
gomod:github.com/disintegration/imaging or crate:serde. Since such a
selector may match nothing, a misspelt package name isn't an error: the
contract is simply kept.
Options
| Option | Default | |
|---|---|---|
modules | required | Selectors of the protected units. |
allowed_importers | required | Selectors. |
Common options
Like every contract type, protected also takes:
| Option | Default | |
|---|---|---|
type | required | The contract type, such as "layers". |
name | the id | The title shown in the output. |
severity | "error" | "warn" reports a broken contract without failing the check, for rolling a contract out gradually. |
exclude_kinds | [analysis] exclude_kinds | The import kinds to leave out, e.g. ["type"]. |
reexports | [analysis] reexports | "transparent" sees an import of names from a [pass-through module](../concepts.md#pass-through modules) as imports of the modules that define them, as bundlers load them; "opaque" as an import of the pass-through module. Not for encapsulation. |
ignore_imports | [] | "<selector> -> <selector>" entries: the direct imports between them are left out of the contract. |
unmatched_ignore_imports | "error" | Whether an ignore_imports entry that matches no import breaks the contract ("error"), is a warning ("warn") or is fine ("none"). |
guidance | none | Text shown under each violation, such as how to fix it. |
acyclic
The modules units must not depend on each other in cycles. One unit depends
on another when any of its members imports a member of the other.
Example
[contracts.packages-acyclic]
type = "acyclic"
modules = ["pkg:*"]
exclude_kinds = ["type"]
@acme/client and @acme/core depend on each other. Removing core's one import of client breaks the cycle, so zenith nominates it.The workspace packages must not depend on each other in cycles. If a file in
@acme/core imports @acme/client, and one in @acme/client imports
@acme/core, the report names the cheaper dependency to remove, such as
@acme/core -> @acme/client (1 import). Type-only imports make no cycle at
runtime, so they don't count: @acme/core's type import of @acme/ui would
otherwise close a second cycle.
Cycle breakers
When there are cycles, zenith nominates the cycle breakers: the fewest dependencies whose removal would make the units acyclic, found with grimp's weighted heuristic, ties broken by name. Each breaker is on a cycle, and is shown with its number of imports and a cycle through it.
Options
| Option | Default | |
|---|---|---|
modules | required | Selectors, e.g. ["pkg:*"] or ["web/src/domains/*"]. |
depth | 0 | How many generations further to check: with depth = 1, the children of each directory unit are checked as units of their own too. |
skip | [] | Selectors of units not to check the children of. |
Common options
Like every contract type, acyclic also takes:
| Option | Default | |
|---|---|---|
type | required | The contract type, such as "layers". |
name | the id | The title shown in the output. |
severity | "error" | "warn" reports a broken contract without failing the check, for rolling a contract out gradually. |
exclude_kinds | [analysis] exclude_kinds | The import kinds to leave out, e.g. ["type"]. |
reexports | [analysis] reexports | "transparent" sees an import of names from a [pass-through module](../concepts.md#pass-through modules) as imports of the modules that define them, as bundlers load them; "opaque" as an import of the pass-through module. Not for encapsulation. |
ignore_imports | [] | "<selector> -> <selector>" entries: the direct imports between them are left out of the contract. |
unmatched_ignore_imports | "error" | Whether an ignore_imports entry that matches no import breaks the contract ("error"), is a warning ("warn") or is fine ("none"). |
guidance | none | Text shown under each violation, such as how to fix it. |
encapsulation
Code outside a unit may only import it through its entry points, while code inside it may import anything in it: the "public API" rule, with no deep imports into a package or a feature's folder. import-linter has no equivalent.
Example
[contracts.ui-public-surface]
type = "encapsulation"
modules = ["pkg:@acme/ui"]
entrypoints = ["index", "src/components/*/index"]
severity = "warn"
@acme/ui, code may import its entry points. Importing Button.tsx directly is a deep import.Outside @acme/ui, code may only import the package's index and the
index of each component's directory. web importing
@acme/ui/src/components/button/Button.tsx is a deep import, reported as a
warning while the existing ones are migrated.
Options
| Option | Default | |
|---|---|---|
modules | required | Selectors of directory units. |
entrypoints | ["index"] | Globs relative to each unit's root, with or without an extension. They match files only: public/** exposes a directory. |
allowed_importers | [] | Selectors of importers that may import anything, such as tests. |
Common options
Like every contract type, encapsulation also takes:
| Option | Default | |
|---|---|---|
type | required | The contract type, such as "layers". |
name | the id | The title shown in the output. |
severity | "error" | "warn" reports a broken contract without failing the check, for rolling a contract out gradually. |
exclude_kinds | [analysis] exclude_kinds | The import kinds to leave out, e.g. ["type"]. |
reexports | [analysis] reexports | "transparent" sees an import of names from a [pass-through module](../concepts.md#pass-through modules) as imports of the modules that define them, as bundlers load them; "opaque" as an import of the pass-through module. Not for encapsulation. |
ignore_imports | [] | "<selector> -> <selector>" entries: the direct imports between them are left out of the contract. |
unmatched_ignore_imports | "error" | Whether an ignore_imports entry that matches no import breaks the contract ("error"), is a warning ("warn") or is fine ("none"). |
guidance | none | Text shown under each violation, such as how to fix it. |
limits
Each modules unit may be imported by at most max_fan_in files outside
it, and import at most max_fan_out files and packages of the project
outside it. A module that everything imports, or that imports everything, is
hard to change without touching much else.
Example
[contracts.small-surface]
type = "limits"
modules = ["web/src/*"]
max_fan_in = 20
max_fan_out = 12
max_fan_in = 2, a third file importing web/src/lib breaks the contract. app/main.ts counts once, though it imports two of its files.Each directory of web/src is checked on its own:
- Fan-in is the number of files outside the unit that import it. A file
counts once, however many of the unit's files it imports. A unit may be an
external package:
modules = ["npm:*"]limits how many files import each. - Fan-out is the number of files outside the unit that it imports, and of packages that it imports as a whole, such as a Go package. Each counts once, however many of the unit's files import it.
- Not counted: imports between the unit's own files, imports of external
packages such as
npm:reactand of builtins such asnode:fs, and the imports that the contract leaves out: those of itsexclude_kinds, itsignore_imports, suppressed ones, and those in the baseline. Only direct imports count. To restrict who may import an external package, use aprotectedcontract.
A unit over a limit is a violation that names the count and the limit, and lists the imports that make up the count. A unit over both limits is two violations.
Options
| Option | Default | |
|---|---|---|
modules | required | Selectors of the limited units. |
max_fan_in | none | The most files outside each unit that may import it. |
max_fan_out | none | The most files and packages of the project outside each unit that it may import. |
At least one of max_fan_in and max_fan_out is required.
Common options
Like every contract type, limits also takes:
| Option | Default | |
|---|---|---|
type | required | The contract type, such as "layers". |
name | the id | The title shown in the output. |
severity | "error" | "warn" reports a broken contract without failing the check, for rolling a contract out gradually. |
exclude_kinds | [analysis] exclude_kinds | The import kinds to leave out, e.g. ["type"]. |
reexports | [analysis] reexports | "transparent" sees an import of names from a [pass-through module](../concepts.md#pass-through modules) as imports of the modules that define them, as bundlers load them; "opaque" as an import of the pass-through module. Not for encapsulation. |
ignore_imports | [] | "<selector> -> <selector>" entries: the direct imports between them are left out of the contract. |
unmatched_ignore_imports | "error" | Whether an ignore_imports entry that matches no import breaks the contract ("error"), is a warning ("warn") or is fine ("none"). |
guidance | none | Text shown under each violation, such as how to fix it. |
The baseline
A codebase that adopts zenith usually breaks its contracts in many places at
first. The baseline records those violations, so zenith check only fails on
new ones, and the debt can only shrink.
zenith baseline
writes zenith.baseline.toml, which is meant to be committed. Its entries
are direct imports, sorted by contract, one a line, so changes to it are easy
to review:
version = 1
[contracts]
web-layers = [
"web/src/lib/orders.ts -> web/src/domains/orders/api/client.ts",
]
-
zenith checkleaves each contract's entries out of it, as if they wereignore_imports. -
An entry whose import no longer exists is stale, and fails the check, so the baseline shrinks as violations are fixed.
zenith baseline --pruneremoves the stale entries without adding any. With[baseline] stale = "warn", stale entries are only warnings. -
Renaming or moving a file with baselined imports would make its entries stale, and its imports new violations.
zenith baseline --prunefollows the files that git sees renamed since the last commit, so their entries take the new names.zenith checkdoesn't, so prune the baseline before checking, for example in a pre-commit hook:zenith baseline --prune --tracked zenith check --tracked -
zenith check --no-baselineignores the file. -
When a merge leaves git's conflict markers in the file, zenith reads the entries of both sides, and warns. The entries that either side fixed are then stale, so
zenith baseline --pruneresolves the conflict.
How entries are found
For contracts that only check direct imports (protected, encapsulation,
and any with allow_indirect_imports), the entries are the offending imports.
For the others, zenith records the first import of each chain, the one made by
the importing unit's own code, then checks the contract again, until it is
kept. For acyclic, the entries are the imports of the cycle breakers. For
limits, they are the imports of the counted nodes over the limit, the last
ones by path, so the unit is left at its limit and any new importer (or
imported node) breaks it.
Updating it
zenith baseline --contract <id>updates only the entries of the given contracts, and keeps the others.- Without
--contract, the entries of contracts that no longer exist are dropped. - When no entries are left, the file is removed.
- When the baseline gains entries, zenith warns, so new debt is reviewed.
Suppressions
A zenith-ignore comment exempts the imports on a line from the contracts it
names:
// zenith-ignore web-layers: legacy, tracked in ENG-123
import { x } from "@/domains/orders";
import { y } from "@/app/(dashboard)/types"; // zenith-ignore web-layers
- It works the same in Go files, where it is a
//or/* */comment among the imports, and in Terraform files, where it may also be a#comment, and one above a block or an attribute applies to every import in it. - The syntax is
zenith-ignore <id> [<id>…][: <reason>], in a line or a block comment. Ids may also be separated by commas. - A comment after code applies to the imports that start on its own line. A comment on a line of its own applies to the imports on the next line.
- It removes those imports from the contracts it names, and no others.
A suppression that has no effect is a warning: one that names no contract, names a contract that doesn't exist, or whose contracts, among those checked, have no import on its line.
Editor integration
zenith lsp is a language server: an editor starts it, and it shows what
the contracts find as diagnostics on the code, for the people and the agents
that read them.
- When the editor starts it, and whenever the editor saves a file, it checks
the whole project, as
zenith checkdoes, and publishes a diagnostic on each import that breaks a contract, a finding each as in the compact format. - A diagnostic is an error or a warning as the contract's severity says, has the contract's id as its code, says how to fix the finding, as the compact format does, and has the contract's guidance after it. The rest of an import chain, or the other imports of a dependency that would break a cycle, are its related information.
- Stale baseline entries, and
ignore_importsentries that match nothing, are diagnostics on the baseline and the configuration file. A finding that points at no file, such as a module that an exhaustivelayerscontract doesn't declare or a limit that a module exceeds, is counted in a message that points atzenith check, shown when those findings change rather than on every save. - A suppression that has no effect is a hint on its comment, which editors fade.
- A file's diagnostics are cleared once it has none.
- A check that can't run keeps the diagnostics of the last one that did. A
mistake in the configuration is an error where it is in
zenith.toml, until it is fixed and saved; anything else that stops the check, even a crash, is shown as a message, and the server carries on. - It checks what is saved, not what is being typed, and offers no completions or code actions.
It finds the project as zenith check would in the folder that the editor
opened, or in its working directory if the editor names no folder. It takes
the options of every command, such as --config and --no-cache,
and logs to stderr, which editors keep in a log of the server.
Known limits:
- Of an editor's workspace folders, only the first is checked. The VS Code extension runs a server for each project that its folders are in.
- Each save is checked in turn, so saving several files in quick succession checks the project once for each.
VS Code
Install the Zenith Linter extension
from the Marketplace, or from Open VSX
in editors that use it, such as Cursor and VSCodium. It checks each folder of
the workspace that has a zenith.toml, in it or in a folder above it, and
starts or stops checking a folder as it gains or loses one. It runs a server
for each project: folders under the same zenith.toml, such as the packages
of a monorepo, share one, which shows each finding once.
It runs the first of these binaries that it finds:
- the one that the
zenith.pathsetting names, absolute or relative to the folder; - the project's own, if it has
@feds01/zenithamong its npm dependencies, so that the editor runs the version that the project pins, unless it is 0.5.0 or older, which have nozenith lsp; - the one that comes with the extension, for macOS, Linux (ARM64 and x86-64) and Windows (x86-64);
zenithon thePATH.
It runs only in a trusted workspace, as the project's binary is the
workspace's own, and zenith runs tools such as cargo and git in it.
| Setting | |
|---|---|
zenith.path | The path of the binary to run. |
zenith.args | More arguments for zenith lsp, such as ["--config", "tools/zenith.toml"]. A folder is also checked when they name its configuration file or its root. |
zenith.trace.server | Logs the messages between VS Code and the server: off, messages or verbose. |
zenith: Restart the server finds the binary and the configuration anew, and zenith: Show the server's output shows its log, which says which binary it runs.
Neovim
With Neovim 0.11 or later, in init.lua:
vim.lsp.config("zenith", {
cmd = { "zenith", "lsp" },
filetypes = { "javascript", "javascriptreact", "typescript", "typescriptreact",
"go", "python", "rust", "terraform", "hcl" },
root_markers = { "zenith.toml" },
})
vim.lsp.enable("zenith")
Helix
In languages.toml, add the server, then list it among the servers of each
language, after those that the language already uses:
[language-server.zenith]
command = "zenith"
args = ["lsp"]
[[language]]
name = "typescript"
roots = ["zenith.toml"]
language-servers = ["typescript-language-server", "zenith"]
Configuration
zenith.toml lives in the project root. Unknown keys are errors, with
suggestions for likely typos. Editors can complete and check the file from its
JSON Schema,
which zenith init points to with a #:schema comment.
#:schema https://raw.githubusercontent.com/feds01/zenith/main/zenith.schema.json
[files]
exclude = ["experimental/**", "**/*.gen.ts"] # added to the defaults below
respect_gitignore = true # the default
[languages.js] # a table per language, with the options on its page
[analysis]
exclude_kinds = [] # the import kinds that every contract leaves out
reexports = "opaque" # or "transparent": how contracts see pass-through modules
[baseline]
path = "zenith.baseline.toml"
stale = "error" # or "warn"
[cache]
enabled = true # the default; `--no-cache` turns it off for one run
dir = ".zenith_cache" # the default, relative to the root
[contracts.<id>]
# see the contract types
Languages
[languages] has a table for each language to read, which enables it, with
the options that the language's page lists. Without a [languages] table,
JavaScript and TypeScript are enabled. See languages.
Files
node_modules, .git, .yarn, coverage, .next, .nuxt, .output,
.svelte-kit, .turbo, .vercel, .cache, .wxt, .mastra and
storybook-static are always excluded, wherever they are, and so are Python's
.venv, site-packages, __pycache__, .tox, .nox, .mypy_cache,
.pytest_cache and .ruff_cache, Terraform's .terraform and
.terragrunt-cache, and Yarn's .pnp.cjs and .pnp.loader.mjs. dist,
build and out are excluded only next to a package.json, since elsewhere,
as in src/build/, they may hold source code.
Resolution
Each language resolves its imports its own way, which its
page describes. Imports that can't be resolved are left out of
the graph, and zenith check warns about them: --show-unresolved lists them.
To see how one import resolves, and why, run
zenith resolve <file> <specifier>.
Cache
zenith keeps what it finds in each file in a cache, so that a run after an edit neither reads nor parses the files that haven't changed. A file is taken from the cache without being read if its size, times and inode are unchanged, or without being parsed if its contents are. The cache never changes what a check finds.
The cache is one file in [cache] dir, .zenith_cache by default. zenith creates the directory, with a
.gitignore that ignores everything in it, so it is never committed. A new
version of zenith, or a configuration that needs more from each file, such as
reexports = "transparent", starts a new cache; a cache that is corrupt, or
can't be written, is skipped.
Command line
zenith check [<path>…] [--since <ref>] [--contract <id>]…
[--format pretty|compact|json|github|sarif]
[--no-baseline] [--show-unresolved] [--show-timings]
zenith baseline [--contract <id>]… [--prune]
zenith graph [<selector>] [--depth <n>] [--format tree|dot|mermaid|json] [--exclude-kind <kind>]…
zenith graph diff <base> [<head>] [--depth <n>] [--format tree|json] [--exclude-kind <kind>]…
[-- <selector>]
zenith why <from-selector> <to-selector> [--exclude-kind <kind>]… [--limit <n>]
zenith resolve <file> <specifier>
zenith init [--force]
zenith suggest
zenith lsp
zenith version
| Command | |
|---|---|
check | Checks the contracts, or only those of --contract. With paths, or --since, it only reports what involves the files under them. --format picks one of the output formats, such as sarif for code scanning. |
baseline | Records the current violations in the baseline. |
graph | Shows the module tree under a selector, and the dependencies between each node's children, as a tree, Graphviz, Mermaid or JSON. |
graph diff | Shows the dependencies between a selector's children that were added and removed from one git ref to another. See Comparing refs. |
why | Shows the shortest import chains from one selector to another. |
resolve | Shows what a specifier resolves to from a file, and why. |
init | Writes a starter zenith.toml for the workspace packages it finds. |
suggest | Prints contracts that the code already keeps, as TOML: see suggesting contracts. |
lsp | Runs a language server, which shows the violations in an editor: see editor integration. |
Every command takes these options:
| Option | |
|---|---|
--config <path> | The configuration file, instead of the nearest zenith.toml. |
--root <dir> | The project root, instead of the configuration file's directory. |
--tracked | Only scan the files that git tracks, leaving out untracked ones. Use it in pre-commit hooks, where untracked files aren't part of the commit. Outside a git repository, it is an error. |
--no-cache | Read and parse every file, without reading or writing the cache. |
--color auto|always|never | Whether to colour the output. By default, only when it goes to a terminal and NO_COLOR isn't set. |
-v, -vv | Show more of what zenith is doing, on stderr. |
Arguments may also come from a file, with @path. Selectors are matched by
zenith itself, so quote them, as in zenith graph "web/src/*".
When a graph selector matches several modules, each is shown on its own,
except in Mermaid, where each is a subgraph of one flowchart, so that the
output renders from a single code block.
Checking some files
zenith check <path>… still checks the whole project, but only reports what
involves the files under the paths, and only fails if that is broken. That
suits an agent or an editor checking the files it has just changed:
zenith check --format compact src/web/orders.ts
What involves a file:
- a violation's chain or import, if any import on it is from or into the file;
- an
acycliccycle, if one of the imports it suggests removing is; - an undeclared module of an exhaustive
layerscontract, if the file is in it; - a stale baseline entry, an unused suppression, an unresolved import or a file that can't be read, of the file.
Unmatched ignore_imports entries are about the configuration, not a file,
so they are always reported. A path is relative to the working directory and
must be inside the root, but needn't exist, so a deleted file's importers
still count.
A cycle is reported on the imports zenith suggests removing, so one that a
change closes can be left out if those imports are elsewhere. A full
zenith check, as in CI, still finds it.
To report on the files that a branch changed, without listing them, use
--since.
Checking what a branch changed
zenith check --since <ref> reports as if the paths were every file that the
branch changed since it left <ref>: every file that differs between the
merge base of <ref> and HEAD and the working tree, as in
git diff <ref>... and a pull request's diff. So what has landed on <ref>
since the branch left it is not reported. The files are those added, copied,
modified, renamed or deleted, a renamed file under both its names, and the
untracked files that git doesn't ignore, unless --tracked is given. <ref>
is anything git names a commit by, such as a branch, a tag, HEAD~1 or a
hash:
zenith check --since main --format github
With paths too, it reports on the files under either. A ref that names no
commit, as in a shallow clone that hasn't fetched it, and a project outside a
git repository, are errors. Where git finds no merge base, as in a shallow
clone that hasn't fetched enough history to reach the merge base, the working
tree is compared with <ref> itself, with a warning.
Code scanning
zenith check --format sarif > zenith.sarif writes a SARIF log, for GitHub
code scanning and other dashboards, which is then uploaded, as in
the workflow of the sarif format. Its paths are relative
to the root of the git repository, so a project in a subdirectory of one works
too. The exit code is the same as with any other format.
Suggesting contracts
zenith suggest reads the import graph and prints, as TOML, contracts that
the code keeps as it is, each with a comment that says why. They are about
the workspace packages, or the top-level directories when there are fewer
than two packages:
acyclic, when they depend on each other in no cycle. The packages are written aspkg:*, so it covers the packages added later too, unless a package is inside another;layers, when those that depend on each other stack in one strict order, each depending on the next;independence, for the packages in one directory, such asapps, when none of them depends on another.
# The workspace packages in `apps` do not depend on each other.
[contracts.apps-independent]
type = "independence"
modules = ["pkg:@acme/admin", "pkg:@acme/web"]
Each suggestion is checked against the graph before it is printed, as a
contract of the configuration, with its [analysis] defaults, so it passes
zenith check from the start.
It never writes a file. Write the suggestions to one to review them, as in
zenith suggest > suggested.toml, and copy the ones you want into
zenith.toml; or let an agent read them. A contract whose id the
configuration already has is not suggested, so once you have added them, a
second run only prints what is new.
Comparing refs
zenith graph diff <base> [<head>] answers "how did the architecture change
in this branch?". It builds the graph at the merge base of the refs <base>
and <head>, or HEAD without one, and at <head>, or of the working tree
without one, and shows the dependencies between the children of the selector
that were added (+) and removed (-). A ref is anything git names a commit
by, such as main or HEAD~3, as for --since:
$ zenith graph diff main -- web/src
dependencies between the children of `web/src` that changed:
+ web/src/app -> web/src/server (2 imports)
- web/src/lib -> web/src/types.ts (1 import)
As in git diff <base>... and a pull request's diff, the base is where the
branch left <base>, so what has landed on <base> since is not shown as the
branch's change. Where git finds no merge base, as in a shallow clone, the
tip of <base> is compared instead, with a warning.
As in git diff, the selector comes after --, so that it can't be taken for
a ref, and the options go before it. It defaults to the whole project, and
need only match at one of the refs, so a package that the branch adds shows
every dependency between its children as added.
--depth means something else here than in graph, where it is how deep
the tree is drawn: it is how far below the selector to compare; 1 compares
its children. --depth 2 compares the children of the children instead, and
so on. --exclude-kind leaves imports out as in graph. --format json
gives each node of the selector as {"module", "added", "removed"}, and each
dependency as {"from", "to", "imports"}.
A file that git sees renamed keeps its dependencies under its new name,
rather than losing them all and gaining them back. The command exits with 0
whatever changed: it is for reading, not for failing a build. As with
--since, a ref that names no commit, and a project outside a git
repository, are errors. Only the head's unresolved imports and unreadable
files are reported, as zenith graph reports them; the base's are not the
working tree's to fix.
Each ref is read straight from git, without checking it out: nothing is
written, neither in the repository nor anywhere else, and Git LFS files are
read as their pointers, without fetching them. The files scanned at a ref
are those that git tracks at it, even with respect_gitignore = false. An
import that leads to what git ignores in the working tree, such as an
installed node_modules, is resolved there, so that it resolves at the ref
as it does now; the working tree's other untracked files are not part of
the ref. Rust crates are found from their Cargo.toml files at a ref,
without asking Cargo, which can only read the working tree. The files that
are the same at the ref as in the working tree reuse the parse cache, which
the ref never writes.
The project's root and configuration file are those at the same paths in the
ref; without that file, the ref's project uses the zenith.toml in its root,
or else the defaults. A configuration file outside the repository is used as
it is.
zenith graph diff takes a subcommand's place after graph, so to show a
directory called diff, write zenith graph ./diff.
Exit codes
| Code | |
|---|---|
| 0 | Every contract of error severity is kept. |
| 1 | A contract of error severity is broken, or has stale baseline entries that are errors. |
| 2 | The configuration or the command line is invalid, or something went wrong. |
zenith resolve exits with 1 when the specifier doesn't resolve, and zenith lsp
when the editor tells it to exit without shutting it down first.
Output formats
zenith check --format picks how the results are shown.
pretty
The default: each violation with an excerpt of the code that makes it, the chains of imports it goes through, a hint on how to fix it, and the contract's guidance; then a line for each contract and a summary.
Contracts
✓ sdk-standalone forbidden kept
✗ web-layers layers broken (2 violations) · 1 suppressed
! ui-public-surface encapsulation broken (2 violations, warning only)
1 kept, 1 broken, 1 warning · 78 files, 127 imports
The summary line counts the kept and broken contracts, the broken contracts
of warn severity, and any stale baseline entries, then the files and imports
checked.
compact
A line for each finding, path:line:column: level[contract]: message; hint,
where the hint says how to fix it, then the summary line. A finding about no
single import, such as an undeclared module or an exceeded limit, has no
path:line:column: prefix.
json
Everything the check found, for tools to read: each contract with its status,
violations, chains and counts, the unused suppressions, the imports that
couldn't be resolved, and with --show-timings, how long each step took. The
format has a version: fields may be added within a version, but none are
removed or change meaning.
Fixes
Each finding of a violation says how to fix it: each chain, illegal import
and cycle breaker has a fix, and so does the violation of an undeclared
module or of a unit over a limit:
"fix": {
"action": "remove_import",
"import": {
"file": "web/src/components/upload.tsx",
"line": 3,
"column": 24,
"specifier": "@/lib/images",
"kind": "value"
},
"hint": "remove this import, or one further along the chain"
}
actionis what to do, one of the actions below.importis the import statement to change: its file, the line and column of its specifier, which arenullif the file can't be read, the specifier as written, and the kind of import. It isnullfor a fix that is not about an import.hintsays what to do in a line of plain language, asprettyandcompactshow it.
A fix never loosens a contract: every action but declare_layer changes
code, and declare_layer adds the module to the architecture. No fix raises
a limit.
| Action | Contract types | What to do |
|---|---|---|
remove_import | forbidden, protected | Remove the import, or for a chain, one further along it. |
move_module | independence, layers | Move the imported code into a module that the importer may import, or remove the import. |
import_through_entry_point | encapsulation | Import the module through one of its entry points. |
break_cycle | acyclic | Remove the dependency, which breaks the cycle. |
declare_layer | layers | Declare a module that an exhaustive contract doesn't know in layers. It has no import. |
split_module | limits | Split the unit, or remove some of the imports that put it over its limit. It has no import. |
zenith doesn't apply fixes itself.
github
A GitHub Actions workflow command for each finding, which GitHub shows as an annotation on the line in a pull request, with the hint on how to fix it, then the summary line. A finding about no single import, such as an undeclared module or an exceeded limit, is an annotation on no line:
- run: npx zenith check --format github
sarif
A SARIF 2.1.0 log, which GitHub code scanning, GitLab and security dashboards read. Unlike annotations, its alerts persist: GitHub shows them in the repository's Security tab, and closes each one once a later upload no longer has it.
- Each checked contract is a rule, with its id, its name and type, its
guidance as its help, and its severity as its level. Unused suppressions
have a rule of their own,
zenith/unused-suppression. - Each finding, as
compactlists it, is a result: anerror, or awarningfor a contract ofwarnseverity, with its title as its message, and its fix, itsactionandhint, asproperties.fix. It points at its import, with the rest of its chain of imports, or the other imports of a cycle breaker, as related locations, up to ten. Stale baseline entries and unmatchedignore_importsentries are results of their contract too. A finding without an import, such as an undeclared layer, points at its contract's table inzenith.toml; a unit over a limit does too, with the imports that make up its count as related locations. What suppressions and the baseline leave out is not a result. - Each result has a fingerprint,
zenith/v1, made from its contract and what it is about, such as the files of its chain, but not their lines. An alert stays the same alert when the code around its import moves. - The imports that couldn't be resolved are notifications of the run rather than results.
Paths are relative to the root of the git repository that the project is in, as code scanning reads them, even when the project's root is a directory inside it. Inside a submodule, they are relative to the submodule, whose repository code scanning reads. Outside a repository, they are relative to the project root.
Upload the log with
github/codeql-action/upload-sarif.
zenith exits with 1 when a contract is broken, so let the upload run anyway:
permissions:
contents: read
security-events: write
actions: read # only needed in a private repository
steps:
- uses: actions/checkout@v7
- run: npx zenith check --format sarif > zenith.sarif
- uses: github/codeql-action/upload-sarif@v4
if: ${{ !cancelled() }}
with:
sarif_file: zenith.sarif
category: zenith
Upload the log of a full check. Code scanning closes the alerts of a category
that an upload doesn't have, so the log of a check of some contracts, with
--contract, or of some files, with paths, closes the alerts of the others.
zenith's GitHub Action installs zenith, checks and uploads in a single step.