Architecture rules
Architecture tests answer one question:
Is the dependency shape of the app still allowed?
They read the static Craft graph — routes, services, components, primitives and their edges — without starting the application. That makes them useful for rules that are about relationships, ownership or declarations rather than runtime behaviour.
Choose the right kind of test
| If you want to verify… | Use… | Example |
|---|---|---|
| one unit computes the right result | service tests | a service returns the expected value |
| one component renders and reacts correctly | component tests | a button disables after a click |
| two parts of the app are allowed to depend on each other | architecture tests | checkout must not depend on admin |
| a complete user journey works in a browser | e2e/ tests | a user can create and then see a task |
Use an architecture rule when the requirement sounds like one of these:
- must not depend on — a feature must not reach into another feature;
- must be owned once — an HTTP endpoint or persisted identity has one owner;
- must declare a relationship — a mutation must refresh a query;
- must model input-driven work as a form — a button must not send input state directly into a mutation or async process;
- must remain pure — reading a computed value must not perform work.
A green architecture suite does not prove that a button works. It proves that the app still respects the boundaries that make that button maintainable.
Start with the graph-wide baseline
Add assertDeclarativeArchitecture(graph.graph) first. It checks the core invariants that are easiest to break during a refactor: unique identities, unique HTTP ownership, pure craftComputed values, no dependency cycles and declared mutation reactions. Add focused rules when your application has an additional boundary, such as route DI, folder ownership or URL-backed resource params.
The default baseline also rejects event-only craftMethod wrappers through assertNoEventOnlyCraftMethods. It scans every TypeScript source file in the application's graph project, so moving the wrapper to another file does not avoid the rule. Use eventAction(...) on the element to apply DOM event modifiers and invoke the action directly.
What a rule looks like
A rule is an ordinary Vitest assertion. Look up a node, inspect its graph relationships or call a built-in assertion, then let CI protect the invariant:
it('keeps checkout away from admin internals', () => {
noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));
});The rest of this page explains the graph, the setup and the built-in rules.
Import
import {
analyzeDependencyGraph,
architectureCatalogToTypeScript,
assertCraftComputedPure,
assertCraftEffectNoImperativeSync,
assertCraftEffectNoNetwork,
assertCraftUnique,
assertDeclarativeArchitecture,
assertHttpEndpointUnique,
assertInputActionForms,
assertInsertSelectUnique,
assertInteractiveElementNamed,
assertMutationHasReactOn,
assertNoDependencyCycles,
assertNoEventOnlyCraftMethods,
assertPathBoundaries,
assertPrimitiveLoaderRequirements,
assertQueryMutationHasServerState,
assertResourceParamsPreferQueryParams,
assertPersistedPrimitiveHasUnique,
assertRouteComponentsInSeparateFiles,
assertRouteDiProofs,
buildArchitectureCatalog,
createArchitectureGraph,
noExclusiveLink,
} from '@craft-ts/dev-tools';Mental model
analyzeDependencyGraph reads the application sources with the TypeScript program — routes, services, components, HTTP calls, craftUnique identities, route DI proofs (CanRun, RouteCheckedDI) — and builds a graph of nodes and edges.
createArchitectureGraph wraps that graph with typed lookups. Names come from a generated catalog (as const): autocomplete, and a type error when a renamed symbol disappears.
A rule is then a Vitest assertion on those lookups. The suite lives next to e2e/, in an architecture/ folder, and runs in Node — no TestBed, no browser.
ESLint already forbids local slips (inject, raw HttpClient) and can generate the route proof blocks. Architecture tests catch graph-wide slips those rules cannot see: a feature leaking into another, an endpoint called from two APIs, a duplicate storage key, a route or app.config error screen whose DI proof was never armed. See ESLint rules.
The graph vocabulary
Think of the graph as a typed inventory of architectural facts, not as a second runtime. A node is a thing the architecture can name; an edge is an observed relationship between two nodes. The graph is intentionally more fine-grained than a project graph: one app can contain many services, components, primitives and HTTP endpoints.
Node families
Not every application produces every kind of node. The built-in vocabulary is grouped below by the questions it helps answer:
| Family | Node kinds | What they represent |
|---|---|---|
| Application structure | route, route-hook, route-check, app-config, component, service | Navigation, route-level checks, application configuration, UI entry points and injectable units. |
| Reactive structure | primitive, property, source, template-element | A state, query, mutation, craftComputed, craftEffect, craftMethod, queryParams, or an exposed member/source/template element. A primitive's details.name keeps its concrete primitive name. |
| Boundaries and identities | http-endpoint, unique | A verb + URL boundary and a canonical craftUnique identity, such as a persisted query key. |
| Server functions | server-function-family, server-function-contract, server-function-client, server-function-server, server-function-misnamed, server-function-middleware, server-function-middleware-misnamed, client-function-middleware, client-function-middleware-misnamed | The client/server contract, implementation, middleware and naming checks around server functions. |
| Protocol and extensions | handshake, plus adapter/contributed kinds such as effect-service, effect-operation, effect-layer, data-classification, and external-output | Protocol facts or backend concepts. Effect and data-flow extensions are still queried through the same graph API. |
For example, a page can be represented as these facts: a route loads a component; the component contains a query; a service calls the GET users http-endpoint; a consumer service depends-on a browser boundary; and a mutation triggers a query. These are independent, typed relations that a rule can inspect directly.
The labels are deliberately semantic. A rule can ask “which service calls this endpoint?” or “which mutation triggers this query?” without matching file text or reconstructing the dependency tree itself.
Edge families
The built-in edge kinds describe different types of fact; they should not all be treated as interchangeable dependency arrows:
| Edge kinds | Meaning | Typical architecture question |
|---|---|---|
loads, renders, contains, provides | Structural ownership or composition | Which component does a route load? Which service is provided by a route or component? |
depends-on, calls | A unit reaches another unit or invokes a boundary/method | Can this feature depend on that feature? Who calls HTTP or a mutation? |
reads, writes, subscribes, triggers | Data-flow and reactive behaviour | Is a computed pure? Does a mutation refresh a query? |
checks, uses-property | Proof and member-level usage | Is a route DI proof armed? Which service member is actually selected? |
| Extension relations | Backend-specific facts, for example requires-service, provided-by-layer, composes-layer, exposes-data, flows-data | Is an Effect service supplied by a Layer? Can a classified value reach an external output? |
The direction matters: from --kind--> to is the fact asserted by the analyzer. A depends-on edge is therefore different from a provides edge, and a structural contains edge should not be mistaken for a runtime cycle. This is why assertNoDependencyCycles follows depends-on rather than every edge in the graph.
What the graph is based on
The analyzer works from the TypeScript program selected by the analysis tsconfig:
- AST evidence records syntax that is visible in the source: a route loading a component, a component rendering an element, or a service calling an HTTP client.
- Type evidence records relationships resolved through TypeScript: an injected/yielded service, a provider, or a route proof connected to its target.
- Source proofs keep the file, line, symbol and pattern that explain an edge when the analyzer has one.
graph.proofs(edge)exposes them, so a failing rule can point back to the declaration that created the fact.
The result is static and deterministic: architecture tests do not boot the application, instantiate services, make HTTP requests or observe user behaviour. They prove that the source still has an allowed shape. Runtime behaviour belongs in service tests, component tests and e2e tests.
Choosing the granularity of a rule
Start at the smallest graph level that expresses the invariant, then widen only when the invariant is genuinely architectural:
| Granularity | Example assertion | Best for |
|---|---|---|
| Node property | every unique is static; every interactive element has a name | Presence, identity and declaration rules |
| Direct edge | a mutation has a triggers edge to a query | Required relationships and ownership |
| Neighbourhood | a service calling HTTP is a browserBoundary | Local boundary policies |
| Path or subgraph | no exclusive path links admin and checkout; no depends-on cycle | Feature isolation, reachability and cycles |
| Whole graph | every endpoint is unique; every route has its DI proof | Global invariants and completeness |
The public API mirrors those levels: use graph.nodes(kind) and graph.edges(kind) for typed collections, node.incoming() / node.outgoing() for neighbourhoods, and graph.pathsBetween() when the rule is about reachability. Built-in assert* helpers package recurring whole-graph checks; custom rules should state the product or team invariant before describing the traversal.
Setting it up
The demo app is the working reference: apps/demo/architecture/, run with npx nx architecture demo. Commands are listed in apps/demo/README.md. Copy that layout, or scaffold it with the migrator (Vitest, Node):
npx craft-migrate-architecture \
--project tsconfig.app.json \
--root src \
--writeThat writes tsconfig.graph.json, tsconfig.architecture.json, vitest.architecture.config.ts, the architecture/ suite (loader, catalog, baseline rules, and an architecture.spec.ts), an Nx architecture target or a package.json script, and ignores the generated catalog in the nearest flat ESLint config. --write overwrites the scaffold. --check fails when the suite is missing or the generated tooling files drifted. craft-migrate --write runs this as its last step.
Keep the rules and app-specific lookups in one architecture.spec.ts file when the graph is expensive to analyze. loadArchitectureGraph() caches only within one Vitest worker; separate spec files rebuild the TypeScript graph separately. The three demo apps use this single-file layout, which performs one graph analysis per app run.
1. Analysis tsconfig
Point analysis at every application source file. tsconfig.app.json often lists only main.ts; the graph would then miss routes, services and components.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}2. Suite tsconfig
A second project compiles only the architecture folder, with Node and Vitest types:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node", "vitest/globals"],
"module": "esnext",
"moduleResolution": "bundler"
},
"include": ["architecture/**/*.ts"]
}Reference it from the app tsconfig.json references array so the IDE typechecks the suite.
3. Vitest, at the app root
Keep the config next to project.json — not inside architecture/. A nested vitest.config.ts is picked up by the Nx Vitest plugin and breaks the app's unit-test target.
/// <reference types="vitest" />
import { defineConfig } from 'vite';
export default defineConfig(() => ({
root: import.meta.dirname,
cacheDir: '../../node_modules/.vite/apps/demo-architecture',
plugins: [],
resolve: {
tsconfigPaths: true,
},
test: {
name: 'demo-architecture',
watch: false,
globals: true,
environment: 'node',
testTimeout: 180_000,
hookTimeout: 180_000,
include: ['architecture/**/*.spec.ts'],
},
}));Analysis of a real app takes seconds, not milliseconds. Size the timeouts accordingly; beforeAll uses hookTimeout.
4. Load the graph, rewrite the catalog
import { writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import {
analyzeDependencyGraph,
architectureCatalogToTypeScript,
buildArchitectureCatalog,
createArchitectureGraph,
mergeStyleDump,
} from '@craft-ts/dev-tools';
import { loadStyleDump } from '@craft-ts/style/vite';
import { architectureCatalog } from './catalog';
const workspaceRoot = resolve(import.meta.dirname, '../../..');
const catalogPath = join(import.meta.dirname, 'catalog.ts');
export async function loadArchitectureGraph() {
const graph = analyzeDependencyGraph({
rootDir: workspaceRoot,
tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',
});
writeFileSync(
catalogPath,
`// Generated. Do not edit.\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,
);
// The style half: every *.style.ts, evaluated by the same code the build
// runs. The catalog stays built from the code graph alone.
const styleDump = await loadStyleDump(join(workspaceRoot, 'apps/your-app/src'));
return createArchitectureGraph(
mergeStyleDump(graph, styleDump),
architectureCatalog,
);
}Load it once in a beforeAll(async () => { graph = await loadArchitectureGraph(); }).
The imported catalog is what TypeScript autocompletes against. The rewrite keeps it in sync with the sources: after a rename, the next typecheck of the suite fails until the lookups are updated.
Ignore the generated catalog in ESLint. Commit it so the first clone typechecks.
Bootstrap with npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json. Rename the generated catalog.architecture.ts to catalog.ts. After that, loading the graph keeps it current.
5. Nx target
{
"architecture": {
"executor": "nx:run-commands",
"options": {
"command": "npx vitest run --config vitest.architecture.config.ts",
"cwd": "apps/your-app"
},
"inputs": [
"{projectRoot}/src/**/*.ts",
"{projectRoot}/architecture/**/*.ts",
"{projectRoot}/tsconfig.graph.json"
],
"cache": true
}
}npx nx architecture your-appLooking up nodes
Pass the catalog into createArchitectureGraph and names become unions. A missing name throws Unknown service '…'. Two nodes sharing a name throw until you pass a relative file path.
graph.route('craft/query/:userId');
graph.service('UsersApiOnError');
graph.service('ApiService', 'users/api.service.ts'); // homonym
graph.component('ListWithPagination');
graph.providedOn('UserList');
graph.httpEndpoint('GET', 'users');
graph.unique('{"key":"user-query","storeName":"demo-app"}');
graph.services({ browserBoundary: true, providedIn: 'global' });
graph.usingHttp();
graph.dependingOnBrowserBoundary();
graph.craftMethods();| Lookup | Returns |
|---|---|
route(path, file?) | one route node |
service(name, file?) | one service node |
component(name, file?) | one component node |
providedOn(name) | every node that provides that service |
httpEndpoint(method, url) | one HTTP endpoint |
unique(canonicalJson) | one craftUnique identity |
services({ browserBoundary, scope }) | filtered services |
usingHttp() | nodes that call CraftHttpClient |
dependingOnBrowserBoundary() | nodes that depend on a browserBoundary service |
uniques() / httpEndpoints() / craftMethods() | all nodes of that kind |
Each node exposes providers(), provider(name), outgoing(kind?), incoming(kind?) and httpEndpoints(). Edge kinds include depends-on, provides, calls, loads, renders, reads, writes, checks, triggers.
unique(...) takes the canonical JSON of the identity object: keys sorted in depth. { storeName, key } and { key, storeName } index as the same string.
For adding a TypeScript backend with its own typed nodes and relations, see Extensible architecture graph. To propose project-specific source folders, see the folder layout organizer guide.
Built-in helpers
The declarative baseline is the aggregate set of graph-wide checks below. Import them all, then either call each one or assertDeclarativeArchitecture for the aggregate checks together. The demo suite keeps all checks in apps/demo/architecture/architecture.spec.ts so the graph is loaded once. Run it with npx nx architecture demo.
Each rule has a focused page with the invariant it protects, the failure it prevents and the smallest useful test. Start with the declarative baseline, then add the rules that express your application's boundaries.
| Helper | Fails when |
|---|---|
assertCraftUnique | the same craftUnique identity appears twice, or the argument is not a static literal |
assertHttpEndpointUnique | the same HTTP verb+URL is called from more than one site |
assertVisualHappyPathArchitecture | a routed page, mobile/desktop viewport, or Craft HTTP endpoint has no successful visual happy-path fixture |
assertCraftComputedPure | a craftComputed calls a method or writes a source$ |
assertPrimitiveMethodsUsedOnce | an exposed primitive insertion method is used from more than one call site |
assertNoUnusedPrimitiveMethods | an exposed primitive insertion method has no call site anywhere in the project |
assertNoDependencyCycles | a directed cycle exists on depends-on (services, components, computeds) |
assertMutationHasReactOn | a mutation has no query insertReactOnMutation edge (allow skips named fire-and-forget mutations) |
assertInputActionForms | a button directly triggers an input-dependent mutation or async process instead of using the form submission boundary, including when the primitive is declared in a service |
assertDeclarativeArchitecture | any of the baseline checks fail |
assertRouteDiProofs | a routed component, pending UI or error screen has no armed CanRun mapper, a collection is missing assertExhaustiveRouteExceptions, or app.config.ts registers a global / route-load error screen without its RouteExceptionComponentCheckedDI |
assertRouteComponentsInSeparateFiles | a route loads its page component from the routing file, or multiple routed page components share one component file |
assertPathBoundaries | a depends-on (or opted-in calls) crosses a folder allowlist / denylist |
noExclusiveLink(a, b) | the only path between two branches is a leak, not a shared kernel |
assertPersistedPrimitiveHasUnique | insertStoragePersister is used without wrapping the identity in craftUnique |
assertInsertSelectUnique | the same insertSelect key appears twice on one host primitive |
assertCraftEffectNoNetwork | a craftEffect calls HTTP or a mutation |
assertCraftEffectNoImperativeSync | a craftEffect writes a state / source$ or triggers a query / mutation / asyncProcess |
assertInteractiveElementNamed | an interactive element lacks a literal name or duplicates a data-craft-name |
assertMetricThresholds | opt-in: a selected node exceeds a team-defined complexity, size or coupling threshold |
assertQueryMutationHasServerState | a query or mutation does not reach an allowed server-state boundary |
assertPrimitiveLoaderRequirements | an Effect-aware primitive does not declare an allowed dependency boundary |
assertResourceParamsPreferQueryParams | a query or asyncProcess params graph depends on a state instead of URL-backed queryParams |
noExclusiveLink
Forbids edges that exist only because two branches touch each other. A shared kernel — auth, HTTP client, browser boundaries — is allowed. Membership stops at other provides sites, so a leak into a third feature is not reclassified as shared.
it('keeps exclusive feature branches from linking', () => {
const [userList] = graph.providedOn('UserList');
const [userMutation] = graph.providedOn('UserMutation');
expect(userList).toBeDefined();
expect(userMutation).toBeDefined();
noExclusiveLink(userList, userMutation);
});The same helper works on routes: noExclusiveLink(graph.route('/admin'), graph.route('/checkout')).
assertPathBoundaries
Nx depConstraints tag projects and forbid TypeScript imports. This helper tags folders on the Craft graph and forbids depends-on (optionally calls) between them — including inside one app, where module-boundary ESLint does not run. Same intention, different altitude: Craft graph vs Nx.
Paths are relative to graph.rootDir. * is one segment, ** is any depth, :name captures a segment. The same capture in source and onlyDependOn / forbidTarget must match, so a feature can depend on itself but not on siblings.
onlyDependOn is an allowlist; forbidTarget is a denylist. When both are set, the target must match the allowlist and miss the denylist. Nodes whose path matches no source are unconstrained. Edges without a filePath on either end, and structural edges (provides, loads, renders, contains), are ignored.
it('keeps features and UI in their folders', () => {
assertPathBoundaries(graph.graph, {
constraints: [
{
source: 'src/app/features/:feature/**',
onlyDependOn: [
'src/app/features/:feature/**',
'src/app/shared/**',
'src/app/ui/**',
],
},
{
source: 'src/app/ui/**',
onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],
forbidTarget: ['src/app/data/**'],
},
],
});
});Sibling features are an allowlist job (onlyDependOn includes features/:feature/**). A denylist features/** would also forbid self.
assertCraftUnique
Each craftUnique(...) identity must appear once, and the argument must be a static literal — otherwise the graph cannot tell two call sites apart. Used with persistence so two queries cannot silently share a storage key.
it('requires craftUnique identities to appear once', () => {
assertCraftUnique(graph.graph);
});A duplicate or a non-literal argument fails the test with the file:line of each call site.
assertHttpEndpointUnique
A GET users node is one verb + one URL. Two call sites — two services, or the same service twice — fail the test. Distinct pairs (GET users and POST users, or GET orders) are allowed.
it('owns each HTTP endpoint once', () => {
assertHttpEndpointUnique(graph.graph);
});This is the graph-wide counterpart of craftUnique. Wrapping CraftHttpClient in craftUnique is not required: the identity is the verb+URL.
assertVisualHappyPathArchitecture
The visual overview contract connects routed pages, the default mobile and desktop viewports, and deterministic API datasets. It consumes the config created with defineVisualAppConfig and fails if a routed page is absent, a configured component is unknown, or any CraftHttpClient / CraftBinaryHttpClient endpoint lacks a successful mock in a dedicated *.happy-path.ts file.
import { assertVisualHappyPathArchitecture } from '@craft-ts/dev-tools';
import { visualTestConfig } from '../../e2e/visual-test.config';
it('covers every page and HTTP endpoint in the visual happy path', () => {
assertVisualHappyPathArchitecture(graph.graph, visualTestConfig);
});The assertion is separate from assertDeclarativeArchitecture because it needs the application's visual config. Dynamic URL segments are represented by *, so a template URL such as `/api/users/${id}` is indexed as /api/users/* and uses the same key in its fixture.
assertCraftComputedPure
A craftComputed may only read. Outgoing calls (a craftMethod, increment, mutate, …) and writes (source$.emit / .set) fail.
Local slips are also caught by ESLint craft-ts/no-craft-computed-side-effects. The graph catches a computed that calls a method declared in another binding.
it('keeps craftComputed free of methods and source$ writes', () => {
assertCraftComputedPure(graph.graph);
});assertNoDependencyCycles
Directed cycles on depends-on only: service A → B → A, two craftComputed that yield each other, a self-yield*. provides, contains, loads and renders are structure, not a cycle of use. A shared kernel (Left → Auth, Right → Auth) is not a cycle.
it('forbids depends-on cycles', () => {
assertNoDependencyCycles(graph.graph);
});assertDeclarativeArchitecture
Runs the aggregate checks above and joins their messages. Pass { allow } through to assertMutationHasReactOn for fire-and-forget mutations.
import { architectureWaiverList } from './waivers';
it('keeps the app declarative', () => {
assertDeclarativeArchitecture(graph.graph, {
allow: ['logout'],
waivers: architectureWaiverList,
});
});The aggregate includes four style rules. They make @craft-ts/style the only way to style a component, and they need the style dump merged into the graph (step 4):
| rule | fails when |
|---|---|
style-only-design-system | an element's class does not reach a sheet of a *.style.ts (a string, a computed class, a sheet declared elsewhere), or a component carries meta.styles or imports a .css |
style-obligations-discharged | a sheet requires(...) an obligation nothing provides(...) |
no-dangling-css-vars | a variable is read and never declared, or declared and never read (a read from craftGlobalStyles counts) |
no-global-stylesheet | an entry file imports a .css, or index.html links a stylesheet — the only one is virtual:craft-style.css |
A component of pure composition, with no class at all, is not at fault. A class passed through an input typed CraftClass is accepted.
Waivers
A deliberate bypass — a third-party widget, HTML rendered from markdown — is a waiver: a rule, a target, and the reason. Declare them in architecture/waivers.ts, typed against the catalog, so a target that does not exist does not compile:
import { defineArchitectureWaivers } from '@craft-ts/dev-tools';
import { architectureCatalog } from './catalog';
export const architectureWaiverList = defineArchitectureWaivers(
architectureCatalog,
[
{
rule: 'style-only-design-system',
target: 'MarkdownArticle',
reason: 'The HTML rendered from markdown carries its own classes.',
},
],
);The target is a component name, file:<path>, obligation:<id>, css-var:<name>, or '*' for the whole rule — the only form a rule that checks the whole graph (no-dependency-cycles, …) accepts, and the one a project uses while it migrates. Two things keep the list honest: an empty reason is refused, and a waiver that no longer waives anything is stale and fails the check. Review Attest lists every waiver for a decision.
craft-architecture-check reads the same file (statically, without running the app) and takes the dump the build wrote with --style-dump <path>.
assertRouteDiProofs
The routing DI contract is type-level by design. CanRun, RouteCheckedDI and RouteExceptionComponentCheckedDI are unused aliases unless they stay in the file: comment one out and TypeScript still compiles. That is the one fragile step in an otherwise compile-time guarantee.
This helper makes that step a test failure. It walks the static graph and requires every routed component — including lazy loadChildren collections, which a parent proof never covers — every pending or error screen, and every craftAppConfig error surface to be hooked to an armed mapper. A mapper without CanRun is dead: the graph indexes it, then this rule fails. TypeScript still judges whether a dependency is provided; the architecture suite judges whether that judgement was invoked.
it('requires a DI proof on every routed component and app-config error screen', () => {
assertRouteDiProofs(graph.graph);
});A missing proof, an unarmed mapper, a pending/error screen without its own RouteCheckedDI, a collection without assertExhaustiveRouteExceptions, or an app.config.ts that registers provideCraftGlobalErrorComponent / provideCraftRouteLoadErrorComponent (or withErrorComponent / withRouteLoadError) without an armed RouteExceptionComponentCheckedDI fails with the file:line of the hole.
assertRouteComponentsInSeparateFiles
Route definitions describe navigation and loading; page components live in their own files. This assertion compares the route file with every component target discovered through component, loadComponent or a lazy import(), then rejects multiple routed page components that share one component file.
it('keeps route definitions separate from page components', () => {
assertRouteComponentsInSeparateFiles(graph.graph);
});The rule checks the page file boundary only. It does not restrict components rendered inside a page, and it does not require one route collection per file.
assertMutationHasReactOn
A mutation that no query reacts to is the graph-wide form of the button that knows which lists to refresh. The analyzer records insertReactOnMutation as a triggers edge from the mutation to the query — including when the insertion is nested in insertQueryPipe. This helper fails on every mutation primitive that has no such edge.
Fire-and-forget writes (logout, a form submit with no cache, a demo that refreshes by incrementing local state) pass an allow list of mutation names:
it('requires a query to react to each mutation', () => {
assertMutationHasReactOn(graph.graph, { allow: ['logout'] });
});assertPersistedPrimitiveHasUnique
assertCraftUnique says an identity appears once. This helper says a persisted primitive has an identity: insertStoragePersister / insertLocalStoragePersister must take craftUnique(...). A raw { key, storeName } indexes the primitive as persisted and fails here.
it('requires craftUnique on every persisted primitive', () => {
assertPersistedPrimitiveHasUnique(graph.graph);
});See Persistence.
assertInsertSelectUnique
insertSelect('cell') names a slice on its host state / query. Two siblings with the same key on the same host stomp each other. The same key on two different hosts is allowed — each list can have a cell.
it('keeps insertSelect keys unique on each host', () => {
assertInsertSelectUnique(graph.graph);
});See Selecting.
assertCraftEffectNoNetwork
A craftEffect that calls CraftHttpClient or a mutation is a query or mutation in disguise. Reads of local state stay valid.
it('keeps craftEffect off HTTP and mutations', () => {
assertCraftEffectNoNetwork(graph.graph);
});assertCraftEffectNoImperativeSync
A craftEffect that writes another state or source$, or that calls query.call / mutation.mutate / asyncProcess.method, is glue that should be a sourced state or reactive params instead. Logging, focus, and other I/O that does not push into a Craft primitive stay valid. ESLint craft-ts/no-imperative-craft-resource-trigger catches the resource-trigger half in the editor; this helper is the graph-wide counterpart, including state writes.
it('keeps craftEffect from pushing into other primitives', () => {
assertCraftEffectNoImperativeSync(graph.graph);
});assertInteractiveElementNamed
button('increment', {}, '+') stamps data-craft-name="increment". Type-level proofs and DOM tests already key off that name. This helper makes the first string mandatory on clickable and fillable elements, and unique in the app: two button('save') in two components fail, and so does button({ click() {} }, 'Save'). ESLint craft-ts/require-interactive-local-name is the editor counterpart for the missing / non-static cases.
it('requires a unique literal data-craft-name on every interactive element', () => {
assertInteractiveElementNamed(graph.graph);
});Metric thresholds
Every node of the graph carries metrics: cyclomatic complexity (its own and with everything it contains), line count, fan-in and fan-out. No threshold applies by default; put the ones your team agrees on in the suite. See the focused rule guide for before-and-after examples:
import { assertMetricThresholds } from '@craft-ts/dev-tools/architecture-graph';
it('keeps services and primitives small', () => {
assertMetricThresholds(graph.graph, {
kinds: ['service', 'primitive'],
max: { cyclomaticOwn: 15, fanOut: 12 },
allow: ['src/legacy/**', 'ReportingService'],
});
});allow takes node ids, labels, or path globs. A metric the graph could not compute — a node without a source range — is skipped, never treated as 0; graph.diagnostics lists the unmeasured kinds. The assertion refuses a graph that carries no metrics at all, such as a JSON file written by an older version. metricThresholdViolations returns the same findings as data.
See Graph insights for how the metrics are computed, the hotspot ranking and the report.
Documentation rules
The graph reads the JSDoc of each declaration and, with the opt-in Markdown collector, the pages that cite a node. assertNodesDocumented turns that into a rule:
import { assertNodesDocumented } from '@craft-ts/dev-tools/architecture-graph';
import {
analyzeDependencyGraph,
createMarkdownDocsCollector,
} from '@craft-ts/dev-tools/dependency-graph';
const documented = analyzeDependencyGraph({
rootDir: workspaceRoot,
tsConfigFilePath: 'apps/shop/tsconfig.graph.json',
collectors: [createMarkdownDocsCollector({ include: ['docs/**/*.md'] })],
});
it('documents every service', () => {
assertNodesDocumented(documented, {
kinds: ['service'],
requireDocPage: true,
allow: ['src/legacy/**'],
});
});A node fails without a JSDoc summary, and with requireDocPage when no page cites it in inline code. A node without a source range is skipped: its documentation is unknown, not missing. requireDocPage refuses a graph built without the collector. undocumentedNodeViolations returns the findings as data.
Writing your own rules
Start from a node you care about and assert what should be true of its neighbourhood. The demo suite does this for routes and HTTP; the same pattern covers any invariant you can see on the graph.
A route provides the feature service
it('indexes demo routes and provided feature services', () => {
expect(graph.route('craft/query/:userId').kind).toBe('route');
expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(
expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),
);
});An HTTP endpoint has a single owner
it('indexes the users HTTP endpoint', () => {
expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');
expect(graph.usingHttp().map((node) => node.label)).toEqual(
expect.arrayContaining(['UsersApiOnError']),
);
});HTTP only from a browser boundary
Browser boundaries are the line to the network. A rule can require that CraftHttpClient is only yielded from a service marked browserBoundary: true:
it('only browser-boundary services call HTTP', () => {
const boundaryIds = new Set(
graph.services({ browserBoundary: true }).map((node) => node.id),
);
const leaked = graph
.usingHttp()
.filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));
expect(leaked.map((node) => node.label)).toEqual([]);
});A persisted identity exists
it('looks up a persisted unique identity', () => {
expect(graph.unique('{"key":"user-query","storeName":"demo-app"}').kind).toBe(
'unique',
);
});If the lookup throws, the identity left the graph — the key changed, or craftUnique was removed.
Anything you can express with outgoing / incoming is a rule: “this craftMethod is either called or writes a source$, never both”, “this component does not depends-on that service”, “only providedIn: 'global' services appear under usingTemporal()”. Keep the assertion next to a comment that states the product invariant, not the graph traversal.
Inspecting the graph
npx craft-graph (also npx craft graph) writes the same analysis to disk without running tests:
npx craft-graph \
--project apps/your-app/tsconfig.graph.json \
--root . \
--out craft-dependency-graph \
--format all--format | Writes |
|---|---|
json | the raw graph + a .architecture.ts catalog |
mermaid | a .mmd diagram |
html | a standalone explorer (no server, no runtime) |
both | JSON + catalog + Mermaid |
all | JSON + catalog + Mermaid + HTML + report |
report | .report.md and .report.json |
--include <text> restricts analysis to matching source paths. --feature-glob, --churn-since and --coverage shape the report. Use the HTML explorer to see a route expand into components and services before you write the assertion.
Pitfalls
The analysis tsconfig must include the app, not just main.ts. An empty graph with a passing usingHttp() is the usual symptom.
Do not nest vitest.config.ts under architecture/. Put vitest.architecture.config.ts at the app root.
The catalog lags by one run. Lookups are typed against the committed file. After adding a route or service, run the suite once so the rewrite lands, then the new name typechecks.
Homonyms need a file path. graph.service('ApiService') throws Ambiguous service 'ApiService' when two files export that name. Pass 'users/api.service.ts'.
craftUnique must be a literal. A computed { storeName, key } indexes as static: false and assertCraftUnique fails — the graph cannot prove uniqueness.
A commented CanRun still type-checks. Unused aliases are not errors. assertRouteDiProofs is the CI counterpart — that is the whole point of the helper.
These tests are not e2e. They never boot the app. Pair them with service and component tests for behaviour, and with ESLint for local architecture.
See Also
- Craft graph vs Nx — what each graph can and cannot see
- Testing services — the runtime graph of one service
- Browser boundaries — the nodes
browserBoundary: truerefers to - Persistence — why
craftUniqueidentities must be unique - ESLint rules — local architecture, autofixed
- Routing setup — the proofs this helper keeps armed
- Learn: test what you wrote