The CLI
The package ships a second way to ask the same question.
architecture check [roots…] # evaluate the policyarchitecture conformance [roots…] # how far the tree is from the policy: residue, vacancy, violations, slackarchitecture baseline [roots…] # record what the repository currently violatesarchitecture coverage [roots…] # how much of the tree the policy reachesarchitecture explain <file> # what governs this file, and whyarchitecture facts <file> # what the parser read out of this filearchitecture init # write a starter architecture.yamlarchitecture infer # describe what the tree does today, as a manifestarchitecture migrate # rewrite a JavaScript manifest as architecture.yamlroots are directories to walk, defaulting to packages. The walker visits the source files
of the languages the policy’s scopes name — for TypeScript, .ts, .tsx, .mts and .cts,
skipping declaration files — and steps over the folders no policy is written about:
node_modules, build, dist, .next, coverage.
The manifest is found by name in the working directory — architecture.yaml, .yml,
.json, or architecture.config.mjs, exactly one of which may be present — or named by
ARCHITECTURE_CONFIG. See Formats.
Why a second adapter
Section titled “Why a second adapter”oxlint’s JS plugin API is alpha. A policy that only one alpha host can evaluate has a single point of failure, and the policy is the valuable artefact — it should outlive any particular way of running it.
The CLI covers all five per-file families, and the graph. Where the plugin reads the syntax tree oxlint hands it, the CLI parses each file itself with oxc-parser — the same parser oxlint is built on, emitting the same tree — and one reader in the TypeScript pack turns either tree into the same vocabulary: a specifier, a binding, a member site. Both hosts answer to the same core rather than to each other, and that shared core is what makes the second adapter a check on the first rather than a second implementation to keep in sync.
It is also the only way to write a baseline, which needs every finding at once rather than one file at a time.
Graph rules
Section titled “Graph rules”The graph family — cycles, orphans, what a
tier may reach through any number of hops — is evaluated by the CLI only. The plugin sees
one file at a time and has no view of the others; “does anything import this?” is not a
question it can ask. Both adapters still compile and probe graph rules at load, so a vacuous
one fails the lint run too, but only check has the graph to evaluate them against.
That makes the CLI a superset of the plugin rather than a mirror of it, and it is the one
deliberate asymmetry between them. Run both: oxlint for the per-file families at editor
speed, check in CI for those plus the graph. The graph is built only when a rule needs it,
and every file is parsed once whether the per-file families or the graph pass asks first.
packages/server/src/modules/user/domain/user/user.root.ts [modules-module/domain/{subdomain}/imports] domain/ holds the model — no framework, no persistence, no bus.
1284 files, 1 violations, 37 carried by the baselineExits non-zero on any reportable violation, any unresolved import, or any stale baseline entry. Each finding names the rule that produced it, and the rule name is the node path in the manifest — so the sentence that caused it is found by reading down the same path.
check --json
Section titled “check --json”The same run as one JSON object on stdout and nothing else there; the exit code is unchanged, and the sentence saying why it failed goes to stderr. Assert on this in a script rather than on the prose, which is free to improve.
{ "version": 1, "files": 90, "roots": ["packages"], "ok": false, "manifest": { "path": "architecture.yaml", "sha256": "…" }, "violations": [ { "fingerprint": "import|cli/imports|packages/cli/src/x.ts|packages/oxlint/src/y.ts", "kind": "import", "ruleName": "cli/imports", "file": "packages/cli/src/x.ts", "subject": "packages/oxlint/src/y.ts", "message": "…", "baselined": false } ], "unresolved": [{ "file": "…", "specifier": "…", "detail": "…" }], "stale": ["<fingerprint>"], "coverage": { "imports": { "covered": 90, "total": 90, "floor": 1 }, "structure": { "covered": 0, "total": 90 }, "members": { "covered": 54, "total": 90, "floor": 0.6 }, "surface": { "covered": 86, "total": 90, "floor": 0.95 }, "graph": { "covered": 53, "total": 90, "floor": 0.58 } }, "conformance": { "residue": { "count": 0, "ceiling": 0 }, "vacant": { "count": 1 }, "slack": { "count": 4, "ceiling": 5 }, "concentration": { "count": 0 } }, "adoption": { "unrestricted": [], "partial": [] }}violations carries every finding, baselined ones included, and baselined says which;
fingerprint is the baseline’s key. stale lists baseline entries the code no longer
produces. coverage is what coverage prints, with a floor beside each
family the manifest states one for. conformance is what conformance
measures, as counts, with a ceiling beside each measure the manifest states one for in
limits.conformance. ok is true only when nothing above would fail the run. manifest.sha256 hashes the file the policy was read from — the root file only, when
it is split with include. version names this shape, so a document that grows it can say
which one it grew.
conformance
Section titled “conformance”The distance between the tree and the manifest, as one report: the residue no family
reaches, the vacant nodes no file is under, the violations ordered leaf-first, and the
slack — allowances nothing imports through, attributed to the defs fragment when they
came through use. --json emits the snapshot document, and --against <manifest> measures the
same tree against a target manifest instead of the repository’s own. It never fails; it
is a measurement, and check is what holds it to any ceiling the manifest states in
limits.conformance. See Conformance.
campaigns
Section titled “campaigns”The campaigns the manifest declares. Alone, the status table — each campaign’s progress, what is left, its owner, whether it is stalled or complete, the distribution of its sectors over its phases when it has any, and each objective’s burn-down beneath:
$ architecture campaigns
1 campaign under packages
lowering-reports-not-throws 21% 15 left @dataquail throws-in-lowering 21% 15 left 4 cleared 2 concededarchitecture campaigns status --changed [--base <ref>] [--json] [--hotfix "<reason>"] # the nudgearchitecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>] # a step no detector seesarchitecture campaigns note <sector> "<text>" # a remark for the next toucharchitecture campaigns history [<campaign>] [--since <ref>] [--json] # the ledgers' git historystatus --changed is the nudge: per
sector the diff touches, which campaign, what phase, what would move it on, and a verdict it
exits non-zero on. attest and note write the sector’s record; both take --campaign <id>
when the policy declares more than one. history replays the ledgers’ and the manifest’s git
history as a series. Roots follow the subcommand, as they do everywhere else.
objectives
Section titled “objectives”The ledgers:
architecture objectives clear [<campaign>[/<objective>]] # reconcile with the codearchitecture objectives concede <campaign>[/<objective>] --reason "<why>" [--by <email>] # record why a count may riseclear reconciles every ledger with the code wherever that is not a regression: stale
holdouts leave, drifted ones are rewritten, a sector entering a window is recorded with its
initial (which is how a first ledger is written), a sector past a window has its holdouts
closed, a receipted phase change re-baselines, and each sector’s reached and the plan are
recorded. concede is the one way a holdout is added, and it always appends a concession —
the holdouts, a reason, a timestamp and the author, which defaults to git’s user.email;
--holdouts a,b concedes a subset and leaves the rest failing, --sector <name> narrows to
one sector. campaigns init, prune and allow are refused by name.
coverage
Section titled “coverage”How much of the tree the policy reaches, per family, with the tiers that said “not tightened
yet” beneath. check compares the same numbers against any floors the manifest states in
limits.coverage. See Adoption.
conformance names the files this counts.
explain
Section titled “explain”A tree answers “what governs this file?” well and “which files does this rule govern?”
badly — the exact inverse of a flat rule list. explain is the answer to the second
question, asked one file at a time:
$ architecture explain packages/server/src/modules/todos/commands/create-todo.handler.ts
packages/server/src/modules/todos/commands/create-todo.handler.ts
may import: — modules-module/commands/imports ^node:.* ^packages/server/src/modules/$1/domain(/.*)? ^packages/server/src/modules/$1/commands(/.*)? ^packages/server/src/platform/ddd(/.*)? /node_modules/effect/ /node_modules/@effect-server-utils/cqrs/
may not import: repo/deny-2 — The SQL driver is an implementation detail of @org/database. modules-module/deny-0 — A Live is wired at a composition root, never named by a use case. modules-module/deny-1 — A module may only import another module through its index.ts barrel.
lives in: modules-module/commands/layout owes: packages/server/src/modules/todos/commands/create-todo.handler.test.tsIt prints the allowlist actually in force after inheritance, every prohibition that reaches
the file with the first sentence of its reason, the folder rule that admits it, the siblings
it owes — and then, one section per family that has something to say about the file, the
exports restrictions, members vocabularies, surface rules and graph scopes that
select it. facts is the other half: what those rules are evaluated against.
A campaign selecting the file is printed with the sector the file is in, its phase and definedness, the objectives in window for that sector (✗ marks one firing on this file) and the nearest remaining holdouts in the file — then each objective’s truth table: one line per leaf term of its detector and what that term answered for this file, every term evaluated with no short-circuit, so a detector that “should fire” and does not shows which term is not saying what its author thinks:
campaigns: campaign/lowering-reports-not-throws: sector scope in window: throws-in-lowering ✗ nearest holdouts in this file: :208 throws-in-lowering violatingSampleFor#a0afd25d campaign/lowering-reports-not-throws/throws-in-lowering — `loadPolicy` returns a `Result`, but … (match; fires here) ✓ path /\.ts$/ ✓ syntax {"pattern":"throw new Error($$$)"} (11)The allowlist is shown as compiled regular expressions rather than as the globs you wrote,
because what is in force after inheritance is not any single line of the manifest. $1 is a
capture resolved from this file’s own path —
the rule says this module’s own domain, not any module’s.
When a rule seems to be firing too much or not at all, this is the first thing to run.
explain says which rules select a file. facts says what those rules are evaluated
against: every edge the parser found, the names carried across each, every declared member,
every called name, and every export site.
$ architecture facts packages/server/src/modules/todos/commands/create-todo.handler.ts
packages/server/src/modules/todos/commands/create-todo.handler.ts
edges: effect namespace * ../domain/todo/todo.root.ts named Todo named TodoId server-only (no bindings)
members: CreateTodoDeps.todos (type) CreateTodoDeps.clock (type)
calls: makeCommandHandler Effect.gen
exports: named createTodo (variable) named CreateTodoDeps (type)A rule that “should fire” and does not is one of two mistakes, and this is how to tell them
apart. If the site is listed here, the rule’s pattern does not select it — go back to
explain. If it is not listed, the adapter does not extract that fact from that syntax —
a members rule reads the members written in an alias, an interface or a class body, and
not a referenced type or an object literal.
--json prints the same facts as one JSON object, for scripting.
Writes a starter architecture.yaml for a repository that has none: the schema header, one
open root that reaches itself and the runtime, both adoption ceilings at zero, and a comment
per section naming the page that explains it. It refuses to overwrite a manifest in any of
the four names. The starter is tight enough to fire on the first external import, which is
the moment to run check, read what the tree reaches, and write that down by name.
The other way to start: from the tree rather than from a blank page. infer walks the
roots, resolves every import, and prints a manifest that describes what the repository does
today — one open node per folder to a depth, every node unrestricted: true, each
imports.allow filled from the edges its files actually have, externals and builtins listed
from what is actually imported. check accepts it with zero violations, by construction;
every entry a reader deletes is a decision. The walkthrough is
Starting from the tree.
architecture infer [--depth N] [--root DIR]... [--tsconfig PATH] [--write] [--yes | --exhaustive] [--collapse]--depth N— how many folders below a package’s source root become nodes of their own (default 2). A folder holding apackage.jsonis a package, and itssrc/is where counting starts, so a monorepo is described one package at a time whatever its nesting. Deeper folders are governed by the node above them and admitted by a**/catch-all.--root DIR— a folder to walk and a top-level key of the tree; repeatable. With a manifest present the roots are its top-level keys; without one,src/if it exists, otherwise every top-level folder holding a source file.--tsconfig PATH— the tsconfig to resolve through when there is no manifest to take aresolveblock from (defaulttsconfig.json). With a manifest present, itsresolveblock andaliasesare reused and carried into the output.--write— save asarchitecture.yamlinstead of printing. Refuses to overwrite a manifest in any of the four names; leave it off to print beside an existing one.--yes/--exhaustive— answer the questions below all yes, or do not ask them. Off a terminal,inferasks nothing and says so.--collapse— fold a node’s children into it when every child reaches the same things.
Sibling folders that share a shape — the same subfolders, or the same kinds of file — are
offered as one wildcarded node, modules/{module}/, instead of one node each; and when every
import between them lands on one place, such as index.ts, the tightening to
modules/*/index.ts is offered next. Both are questions, each answered once; the default is
to describe every folder on its own.
The output carries the floors check will hold it to: limits.unrestricted is the number of
nodes nobody has reviewed, and limits.coverage is what the manifest reaches on the day it
was written. An import nothing can resolve is in no allowlist, and is listed on stderr for the
same reason check reports it.
migrate
Section titled “migrate”Reads a JavaScript manifest — the one found by name, or the one ARCHITECTURE_CONFIG
names — and writes the same policy as architecture.yaml, schema header included. It
decodes the module first and writes nothing if that fails; it hoists nothing into defs,
since which subtrees are worth naming is the author’s call; and it cannot carry comments,
which it says. Delete the module afterwards: a repository with two manifests is refused.
See Using a JavaScript manifest.
Which forms count
Section titled “Which forms count”Both adapters read the same forms out of their respective syntax trees, and a test suite in the package holds them to it — every form below is parsed by both and must yield the same facts.
An import edge is any of:
import "m"; // side-effectimport x, { a, b as c } from "m"; // default, named, renamedimport * as ns from "m"; // namespaceimport type { T } from "m"; // type-only — still an edgeexport { a, b as c } from "m"; // re-exportexport * from "m"; // re-export everythingexport * as ns from "m";import x = require("m");await import("m"); // a literal argument onlyrequire("m"); // a literal argument onlyA computed specifier — a template literal, a variable, anything but a string literal — is not an edge in either adapter: it is not a fact a static policy can speak about.
Each edge carries bindings: a named one per name, a default one for a default import,
and one namespace binding, named *, for every form that takes the whole module — `import
- as
,export _,export _ as,import =,import(),require()`. A side-effect import carries none.
A call is named by its identifier or property — f() and x.f() are both f. A
computed property, x[f]() or x["f"](), and a private one, x.#f(), have no name a
vocabulary rule can speak about and are not reported.
Wiring it up
Section titled “Wiring it up”{ "scripts": { "lint:architecture": "architecture check packages", "architecture:baseline": "architecture baseline packages", "architecture:explain": "architecture explain", "architecture:facts": "architecture facts" }}Run check in CI alongside oxlint. On the per-file families they evaluate the same manifest
and should agree; if they ever disagree, one of the two adapters has a bug, and finding that
out is worth the duplicated run. On the graph, only check has an answer.