Skip to content

The CLI

The package ships a second way to ask the same question.

Terminal window
architecture check [roots…] # evaluate the policy
architecture conformance [roots…] # how far the tree is from the policy: residue, vacancy, violations, slack
architecture baseline [roots…] # record what the repository currently violates
architecture coverage [roots…] # how much of the tree the policy reaches
architecture explain <file> # what governs this file, and why
architecture facts <file> # what the parser read out of this file
architecture init # write a starter architecture.yaml
architecture infer # describe what the tree does today, as a manifest
architecture migrate # rewrite a JavaScript manifest as architecture.yaml

roots 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.

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.

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 baseline

Exits 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.

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.

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.

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 conceded
Terminal window
architecture campaigns status --changed [--base <ref>] [--json] [--hotfix "<reason>"] # the nudge
architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>] # a step no detector sees
architecture campaigns note <sector> "<text>" # a remark for the next touch
architecture campaigns history [<campaign>] [--since <ref>] [--json] # the ledgers' git history

status --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.

The ledgers:

Terminal window
architecture objectives clear [<campaign>[/<objective>]] # reconcile with the code
architecture objectives concede <campaign>[/<objective>] --reason "<why>" [--by <email>] # record why a count may rise

clear 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.

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.

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.ts

It 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.

Terminal window
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 a package.json is a package, and its src/ 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 a resolve block from (default tsconfig.json). With a manifest present, its resolve block and aliases are reused and carried into the output.
  • --write — save as architecture.yaml instead 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, infer asks 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.

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.

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-effect
import x, { a, b as c } from "m"; // default, named, renamed
import * as ns from "m"; // namespace
import type { T } from "m"; // type-only — still an edge
export { a, b as c } from "m"; // re-export
export * from "m"; // re-export everything
export * as ns from "m";
import x = require("m");
await import("m"); // a literal argument only
require("m"); // a literal argument only

A 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.

{
"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.