Write the boundary as a sentence that can be false.
A layering usually lives as a diagram and a paragraph. Both are true on the day they are written and drift quietly afterward, because nothing reads them. An agent reads them once, if at all, and then makes a hundred small edits that each look fine.
An enforcement layer is the part of a codebase that says no on its own. Type checks are one. Lint rules are one. This lesson adds the one most architectures lack: a check on which module may import which. The check is only as good as the sentence behind it, so the first step is the sentence.
| Instrument | Refuses | An agent change it catches | What it cannot see |
|---|---|---|---|
| Type checker | A value used as a type it is not. | An agent passes a Result where a Note was expected. | Which module may import which. A wrong import with the right types passes. |
| Linter | A pattern inside one file. | An unused variable, a floating promise, an any. | Anything that needs two files to judge. It does not know what a folder is for. |
| Import graph | An edge between two modules the rules forbid. | A handler importing an adapter; a component reaching into a feature; a cycle. | A string URL, a global, a permitted import used for the wrong job. |
| Tests | A behavior that differs from the one written down. | A wrong answer, if a test asks the question. | Structure. Tests written from the code agree with the code. |
| Hook or CI step | A commit or merge while any check above is red. | The moment someone forgets to run the checks. | Nothing on its own; it only carries the verdicts of the others. |
- System
- A miniature backend-for-frontend with six tiers: kernel, http, services, root, server, routes. Eleven files, all real, all type-checked.
- Intended shape
- Dependencies point down. A service takes its store as an argument. Only the root picks an adapter. Only the server tier knows Supabase exists.
- The change
- Asked to add a POST handler, an agent imported the Supabase adapter directly into the route and built the store there. Types pass, the service tests pass, and it works in development.
- What must hold
- After every change, the four sentences below are still true of the import graph. Behavior is the tests’ job, not this check’s.
Here are the four sentences. Each names two sides, an importing side and an imported side, and says which pairings are forbidden. That shape is what makes them checkable. A sentence like “keep the layers clean” cannot be false, so it cannot be enforced.
- 01
kernel is the vocabulary every tier shares. It may not know about any of them.
kernel-imports-nothing - 02
A service takes its store as an argument. It never reaches out to the tiers that assemble it.
services-stay-inside - 03
Column names and client errors live in one place. Only the server root may import a Supabase adapter.
only-server-root-touches-supabase - 04
Memory mode is chosen at the root or in a test, never by a handler or a service.
memory-adapters-are-for-root-and-tests
Read the eleven filesThe repo the sentences describe
import type { Result } from '../kernel/result';
// The transport port. The only tier allowed to name a header or a status code
// is the adapter behind this interface, and this example ships none.
export interface HttpClient {
get(path: string): Promise<Result<unknown>>;
}
// The vocabulary every tier shares. This file imports nothing.
export type FailureKind = 'not_found' | 'invalid' | 'unavailable';
export interface Failure {
kind: FailureKind;
message: string;
}
export type Result<T> = { ok: true; value: T } | { ok: false; failure: Failure };
export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
export const fail = <T = never>(kind: FailureKind, message: string): Result<T> => ({
ok: false,
failure: { kind, message }
});
import type { NoteStore } from '../services/notes/notes.ports';
import { memoryNotes } from '../services/notes/notes.memory';
export interface Root {
notes: NoteStore;
}
// The only function that decides which adapter serves a domain. It is
// framework-free: the caller hands live adapters in, and anything missing
// runs from memory.
export function createRoot(live: Partial<Root> = {}): Root {
return { notes: live.notes ?? memoryNotes() };
}
import { serverRoot, type Env } from '../../../server/root';
import { listNotes } from '../../../services/notes/notes.api';
// A handler is a few lines: root, service call, respond.
export async function GET(event: { env: Env }): Promise<Response> {
const root = serverRoot(event.env);
return Response.json(await listNotes(root.notes));
}
import { createRoot, type Root } from '../root';
import { createSupabaseClient } from './supabase/client';
import { supabaseNotes } from './supabase/notes';
export interface Env {
SUPABASE_URL?: string;
SUPABASE_KEY?: string;
}
// Reads the environment once per request and builds the root. This is the one
// place on the server that knows Supabase exists.
export function serverRoot(env: Env): Root {
if (!env.SUPABASE_URL || !env.SUPABASE_KEY) return createRoot();
const client = createSupabaseClient(env.SUPABASE_URL, env.SUPABASE_KEY);
return createRoot({ notes: supabaseNotes(client) });
}
// A stand-in for the Supabase client. It performs no network I/O: every call
// reports the service as unavailable, so the layering can be exercised without
// a database. The real client is an npm package; the shape here is what the
// adapter relies on.
export interface SupabaseLike {
from(table: string): {
select(columns: string): Promise<{ data: unknown[] | null; error: { message: string } | null }>;
insert(row: Record<string, unknown>): Promise<{
data: unknown[] | null;
error: { message: string } | null;
}>;
};
}
export function createSupabaseClient(url: string, key: string): SupabaseLike {
const unavailable = {
data: null,
error: { message: `no connection to ${url} (${key.length} chars)` }
};
return { from: () => ({ select: async () => unavailable, insert: async () => unavailable }) };
}
import { fail, ok } from '../../kernel/result';
import type { Note, NoteStore } from '../../services/notes/notes.ports';
import type { SupabaseLike } from './client';
// Column names exist here and in the migration, nowhere else.
interface NoteRow {
note_id: number;
body: string;
}
export function supabaseNotes(client: SupabaseLike): NoteStore {
const rows = client.from('notes');
const toNote = (row: NoteRow): Note => ({ id: row.note_id, text: row.body });
return {
list: async () => {
const { data, error } = await rows.select('note_id, body');
if (error || !data) return [];
return (data as NoteRow[]).map(toNote);
},
add: async (text) => {
const { data, error } = await rows.insert({ body: text });
if (error || !data?.[0]) return fail('unavailable', error?.message ?? 'no row returned');
return ok(toNote(data[0] as NoteRow));
}
};
}
import { fail, type Result } from '../../kernel/result';
import type { Note, NoteStore } from './notes.ports';
// A service takes its store as the first argument. It never chooses one.
export const listNotes = (store: NoteStore): Promise<Note[]> => store.list();
export async function addNote(store: NoteStore, text: string): Promise<Result<Note>> {
const trimmed = text.trim();
if (!trimmed) return fail('invalid', 'A note needs some text.');
if (trimmed.length > 280) return fail('invalid', 'Keep a note under 280 characters.');
return store.add(trimmed);
}
import { ok } from '../../kernel/result';
import type { Note, NoteStore } from './notes.ports';
// The adapter with no database. Root picks it when nothing live is configured;
// tests use it instead of mocks.
export function memoryNotes(seed: Note[] = []): NoteStore {
const notes = [...seed];
return {
list: async () => notes.map((note) => ({ ...note })),
add: async (text) => {
const note = { id: notes.length + 1, text };
notes.push(note);
return ok({ ...note });
}
};
}
import type { Result } from '../../kernel/result';
export interface Note {
id: number;
text: string;
}
// What the notes service needs from storage. Column names do not appear here.
export interface NoteStore {
list(): Promise<Note[]>;
add(text: string): Promise<Result<Note>>;
}
import { describe, expect, it } from 'vitest';
import { addNote, listNotes } from './notes.api';
import { memoryNotes } from './notes.memory';
describe('notes service against the memory store', () => {
it('rejects empty text and stores trimmed text', async () => {
const store = memoryNotes();
expect(await addNote(store, ' ')).toEqual({
ok: false,
failure: { kind: 'invalid', message: 'A note needs some text.' }
});
expect(await addNote(store, ' read the rule ')).toEqual({
ok: true,
value: { id: 1, text: 'read the rule' }
});
expect(await listNotes(store)).toEqual([{ id: 1, text: 'read the rule' }]);
});
});
A short list of sentences, each with an importing side, an imported side, and a name. If you cannot name the two sides, the rule is not ready to encode.
Turn each sentence into a from/to rule.
dependency-cruiser reads a
JavaScript or TypeScript tree, resolves every import, and evaluates a list of forbidden rules against each edge. A rule has a from and a to, each a regular expression over the file path, with pathNot to carve out exceptions. When both sides match, the edge is a violation with the rule’s name and
severity.
The four sentences become this file. The comments are the sentences; the patterns are their two sides. Notice the exceptions are written into the rule, not around it: the server root may import an adapter, and a test may import a memory store.
// A dependency-cruiser configuration: the same four rules as rules.ts, in the real tool's configuration format.
// Run from this examples/ directory:
// npx --yes dependency-cruiser@18.2.0 --config .dependency-cruiser.cjs repo
// Paths in `from` and `to` are matched against paths relative to the cruise
// root, so the rules below are written for a cruise that starts at repo/.
module.exports = {
forbidden: [
{
name: 'kernel-imports-nothing',
severity: 'error',
comment: 'kernel is the vocabulary every tier shares. It may not know about any of them.',
from: { path: '^repo/kernel/' },
to: { pathNot: '^repo/kernel/' }
},
{
name: 'services-stay-inside',
severity: 'error',
comment:
'A service takes its store as an argument. It never reaches out to the tiers that assemble it.',
from: { path: '^repo/services/' },
to: { path: '^repo/(server|root|app|routes)/' }
},
{
name: 'only-server-root-touches-supabase',
severity: 'error',
comment:
'Column names and client errors live in one place. Only the server root may import a Supabase adapter.',
from: { pathNot: '^repo/server/(root\\.ts$|supabase/)' },
to: { path: '^repo/server/supabase/' }
},
{
name: 'memory-adapters-are-for-root-and-tests',
severity: 'error',
comment: 'Memory mode is chosen at the root or in a test, never by a handler or a service.',
from: { pathNot: '^repo/root/|\\.spec\\.ts$' },
to: { path: '\\.memory\\.ts$' }
}
],
options: {
tsConfig: { fileName: 'tsconfig.json' },
tsPreCompilationDeps: true,
exclude: { path: 'node_modules' }
}
};
The lab on this page runs a small engine with the same rule shape, so you can see the whole mechanism in two functions. The first finds import edges and resolves them against the files it knows. The second asks every rule about every edge.
The engine behind the labTwo functions, no magic
const IMPORT = /^\s*(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/;
const SIDE_EFFECT = /^\s*import\s*['"]([^'"]+)['"]/;
const DYNAMIC = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/;
/** Every static, side-effect, and literal dynamic import in one file, with its line. */
export function scanImports(source: string): { specifier: string; line: number }[] {
const found: { specifier: string; line: number }[] = [];
source.split('\n').forEach((text, index) => {
const match = IMPORT.exec(text) ?? SIDE_EFFECT.exec(text) ?? DYNAMIC.exec(text);
if (match) found.push({ specifier: match[1], line: index + 1 });
});
return found;
}
/** Turn `../kernel/result` written inside `http/port.ts` into `kernel/result.ts`. */
export function resolveImport(from: string, specifier: string, known: Set<string>): string {
if (!specifier.startsWith('.')) return specifier;
const parts = from.split('/').slice(0, -1);
for (const segment of specifier.split('/')) {
if (segment === '..') parts.pop();
else if (segment !== '.') parts.push(segment);
}
const base = parts.join('/');
for (const candidate of [base, `${base}.ts`, `${base}/index.ts`]) {
if (known.has(candidate)) return candidate;
}
return `${base} (unresolved)`;
}
export function buildEdges(modules: Module[]): Edge[] {
const known = new Set(modules.map((module) => module.path));
return modules.flatMap((module) =>
scanImports(module.source).map(({ specifier, line }) => ({
from: module.path,
to: resolveImport(module.path, specifier, known),
specifier,
line
}))
);
} const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Does a path satisfy a rule side? `groups` are captures from the `from` side, for `$1`. */
function matches(
path: string,
match: { path?: string; pathNot?: string },
groups: string[] = []
): boolean {
const fill = (pattern: string) =>
pattern.replace(/\$(\d)/g, (_, index) => escape(groups[Number(index) - 1] ?? ''));
if (match.path && !new RegExp(fill(match.path)).test(path)) return false;
if (match.pathNot && new RegExp(fill(match.pathNot)).test(path)) return false;
return true;
}
/** Every edge each rule forbids. An edge can break more than one rule. */
export function evaluate(modules: Module[], rules: Rule[]): Report {
const edges = buildEdges(modules);
const violations = edges.flatMap((edge) =>
rules
.filter((rule) => {
if (!matches(edge.from, rule.from)) return false;
// A capture in `from` (e.g. the feature folder) can be referenced as $1 in `to`.
const groups = rule.from.path
? (new RegExp(rule.from.path).exec(edge.from) ?? []).slice(1)
: [];
return matches(edge.to, rule.to, groups);
})
.map((rule) => ({ rule, from: edge.from, to: edge.to, line: edge.line }))
);
return {
modules: modules.length,
edges,
violations,
errors: violations.filter((violation) => violation.rule.severity === 'error').length,
warnings: violations.filter((violation) => violation.rule.severity === 'warn').length
};
} The real tool resolves through the filesystem, tsconfig paths, and node_modules, follows re-exports, and knows about dependency types. This engine
resolves only against the module list it is given. Same rule shape, smaller world; the
lesson says which one produced each result.
A linter can do a smaller version of this. ESLint’s no-restricted-imports,
scoped with an override per folder, forbids a pattern of specifiers from a set of files. It
is worth using when it is all you have. Its patterns can negate with !, but
each override names its folders, so “a feature may import only itself” becomes one override
per feature. It reasons about files rather than tiers, and it has no rules about the graph
as a whole, such as a cycle or a module nobody imports. When the rules outgrow it, graduate.
A configuration file that runs. Every rule carries its sentence as a comment, because the comment is what a future reader, human or agent, will see in the failure output.
Run it on the tree as it is. Then prove zero means something.
Run the check before any change. You want zero violations, but you want something more: evidence that the check actually looked. A tool that scans nothing also reports nothing. Read the module count with the verdict.
$ npx dependency-cruiser --config .dependency-cruiser.cjs --output-type err-long repo
✔ no dependency violations found (12 modules, 19 dependencies cruised)
$ echo $?
0
Twelve modules, nineteen dependencies, no violations. Twelve rather than eleven because this
was recorded in a scratch directory without vitest installed, so the test
file’s vitest import stayed unresolved and was counted as a module of its own.
Where vitest resolves, this configuration excludes node_modules and
the same command reports eleven modules and eighteen dependencies. Either count is fine once you
know what it is counting. That is the baseline: the sentences are true of the tree.
It did not start that way. The first encoding of the memory-adapter rule forgot that tests are allowed to use a memory store, and the baseline said so. This is the useful branch: the expected zero did not appear, and the fix was to the rule, not the code.
$ npx dependency-cruiser --config .dependency-cruiser.cjs --output-type err-long repo # memory rule before the .spec.ts exception
error memory-adapters-are-for-root-and-tests: repo/services/notes/notes.spec.ts → repo/services/notes/notes.memory.ts
Memory mode is chosen at the root or in a test, never by a handler or a
service.
x 1 dependency violations (1 errors, 0 warnings). 12 modules, 19 dependencies cruised.
$ echo $?
1
Then a second run, on the same files, that looked fine and was not. The command was run from a directory where the tool could not find a TypeScript compiler. It warned, scanned zero modules, found zero violations, and exited with success.
$ npx --yes dependency-cruiser@18.2.0 --config .dependency-cruiser.cjs --output-type err-long repo
✔ no dependency violations found (0 modules, 0 dependencies cruised)
‼ missing-typescript-transpiler: dependency-cruiser detected a TypeScript environment,
but not a compatible TypeScript compiler (typescript: >=2.0.0 <7.0.0). This means
it's likely to have missed TypeScript sources and dependencies.
Install typescript to get better results (e.g. npm i -D typescript@^6).
=> Support for typescript@>=7 will follow when its API is published and stable.
$ echo $?
0
A baseline is not done until it can fail
- Count the modules. Zero violations over zero modules is the check not running. Make the module count part of what you read, and fail the build if it is implausibly small.
- Break it on purpose once. Add a forbidden import, run, see red, remove it. Until you have seen the check fail, you have not seen it work.
- Fix the rule, not the tree, when the rule is wrong. The test-file
exception belongs in
pathNot. A global severity of warn would have hidden tomorrow’s real violation too.
A green run whose module count you have read, a red run you produced deliberately, and any pre-existing violations written down as scoped, dated exceptions.
Apply the agent’s change. Read the verdict.
Here is the change from the case file, in the shape an agent hands back: the whole file. It is an authored illustration written for this lesson, not a transcript, and the check does not care either way. Read it as a reviewer first. It is short, it works, and the comment gives a reason.
The brief. Asked to add a POST handler. The agent noticed production always uses Supabase and skipped the root “to keep the handler simple”. Types pass. The notes tests pass. It works in dev with a .env file.
import { createSupabaseClient } from '../../../server/supabase/client';
import { supabaseNotes } from '../../../server/supabase/notes';
import { addNote, listNotes } from '../../../services/notes/notes.api';
// Production always runs on Supabase, so build the store here and skip the root.
const store = (env: { SUPABASE_URL?: string; SUPABASE_KEY?: string }) =>
supabaseNotes(createSupabaseClient(env.SUPABASE_URL ?? '', env.SUPABASE_KEY ?? ''));
export async function GET(event: { env: { SUPABASE_URL?: string; SUPABASE_KEY?: string } }) {
return Response.json(await listNotes(store(event.env)));
}
export async function POST(event: {
env: { SUPABASE_URL?: string; SUPABASE_KEY?: string };
request: Request;
}) {
const { text } = (await event.request.json()) as { text: string };
const result = await addNote(store(event.env), text);
return result.ok ? Response.json(result.value) : Response.json(result.failure, { status: 400 });
}
Now the check. The same command, the same rules, one file replaced. Two lines, each naming the rule, the file, and the module it reached, followed by the sentence from the configuration. The exit code is not zero.
$ npx dependency-cruiser --config .dependency-cruiser.cjs --output-type err-long repo
error only-server-root-touches-supabase: repo/routes/api/notes/+server.ts → repo/server/supabase/notes.ts
Column names and client errors live in one place. Only the server root may
import a Supabase adapter.
error only-server-root-touches-supabase: repo/routes/api/notes/+server.ts → repo/server/supabase/client.ts
Column names and client errors live in one place. Only the server root may
import a Supabase adapter.
x 2 dependency violations (2 errors, 0 warnings). 12 modules, 20 dependencies cruised.
$ echo $?
2
That is the whole technique in one screen. The reviewer did not have to notice the import. The agent does not have to agree with the reasoning. The sentence was written down in a form that can say no, and it said no.
Run it yourself. The lab holds this repo and its four rules, and a second surface you will meet below. Pick a change, predict which rule fires before you run, then switch rules off to see what each one was catching. The fourth change is the same POST request done through the root; it passes, which is worth seeing too.
Change one file. Run the rules. Read the verdict.
The rule engine shown in this lesson runs here over the miniature repo you pick, with the file you choose replaced by the agent’s version. Edit the file and run again to test your own import.
Pick a change, predict which rule forbids it, then run the check.
Expect one line per forbidden import, then a count of modules and dependencies scanned.
This engine resolves imports against the files it is given (11 on this surface) and matches
paths with the same regular expressions as the real configuration, including a capture from from used as $1 in to. It does not read node_modules, follow re-exports through a barrel, or see a dependency that is not
an import statement.
Rules a linter will not write for you.
A backend has obvious tiers. A frontend has them too, and an agent crosses them just as quietly: a shared component reaches into a feature’s state, a page imports a service to save a hop, a primitive picks up navigation. Each compiles. The linter looks at one file and does not know what folder it lives in. The type checker sees types, not layering. The bundler only refuses server code in the browser.
So here is the same move on a miniature frontend: twelve files in the shape of this site, with display primitives, chrome, two features, service clients, a browser root, and two routes. Five sentences become five rules.
- 01
A route may use the library. The library may not know which routes exist.
lib-never-imports-routes - 02
A primitive is props in, markup out. No app state, no navigation, no content.
display-primitives-stay-portable - 03
Shared components sit below features. A feature composes components, never the reverse.
components-do-not-import-features - 04
One feature per folder. What two features share is passed in, or lives in a named shared feature.
features-do-not-cross - 05
A page renders. Data access goes through the browser root or a feature, not a service import.
pages-do-not-call-services
Read the twelve files and the rulesThe miniature frontend
import type { HttpClient } from '../http/port';
import { sessionClient } from '../services/session/session.client';
import { notesClient } from '../services/notes/notes.client';
// The only place in the browser that builds service clients. A page asks the
// root; a feature receives what it needs as a prop or an argument.
export function createBrowserRoot(http: HttpClient) {
return { session: sessionClient(http), notes: notesClient(http) };
}
export type BrowserRoot = ReturnType<typeof createBrowserRoot>;
<script lang="ts">
import Button from '../display/Button.svelte';
let { title, onhome }: { title: string; onhome?: () => void } = $props();
</script>
<header>
<strong>{title}</strong>
<Button label="Home" onclick={onhome} />
</header>
<script lang="ts">
// A primitive: props in, markup out. It knows nothing about the app.
let { label, onclick }: { label: string; onclick?: () => void } = $props();
</script>
<button type="button" {onclick}>{label}</button>
import type { Note, NotesClient } from '../../services/notes/notes.client';
export function createNotesState(client: NotesClient) {
let notes = $state<Note[]>([]);
return {
get notes() {
return notes;
},
async load() {
notes = await client.list();
},
async add(text: string) {
notes = [...notes, await client.add(text)];
}
};
}
export type NotesState = ReturnType<typeof createNotesState>;
<script lang="ts">
import Button from '../../components/display/Button.svelte';
import type { NotesState } from './notes.svelte';
let { notes }: { notes: NotesState } = $props();
let draft = $state('');
</script>
<ul>
{#each notes.notes as note (note.id)}<li>{note.text}</li>{/each}
</ul>
<input bind:value={draft} aria-label="New note" />
<Button label="Add" onclick={() => notes.add(draft)} />
import type { SessionClient, SessionUser } from '../../services/session/session.client';
// Session state for the UI. Reads through the service's browser half.
export function createSessionState(client: SessionClient) {
let user = $state<SessionUser | null>(null);
return {
get user() {
return user;
},
async refresh() {
user = await client.current();
}
};
}
export type SessionState = ReturnType<typeof createSessionState>;
<script lang="ts">
import Button from '../../components/display/Button.svelte';
import type { SessionState } from './session.svelte';
let { session }: { session: SessionState } = $props();
</script>
{#if session.user}
<p>Signed in as {session.user.name}.</p>
{:else}
<Button label="Sign in" onclick={() => session.refresh()} />
{/if}
// The browser's transport port. The fetch adapter behind it is the only code
// that knows a URL or a status; this example ships none.
export interface HttpClient {
get<T>(path: string): Promise<T>;
post<T>(path: string, body: unknown): Promise<T>;
}
<script lang="ts">
import Header from '../components/chrome/Header.svelte';
let { children } = $props();
</script>
<Header title="Notes" />
{@render children()}
<script lang="ts">
import NotesPanel from '../../features/notes/NotesPanel.svelte';
import { createNotesState } from '../../features/notes/notes.svelte';
import type { BrowserRoot } from '../../app/browser-root';
// A page renders. It receives the root and hands each feature its client.
let { root }: { root: BrowserRoot } = $props();
// svelte-ignore state_referenced_locally (The root is fixed for the page's life.)
const notes = createNotesState(root.notes);
</script>
<NotesPanel {notes} />
import type { HttpClient } from '../../http/port';
export interface Note {
id: number;
text: string;
}
export function notesClient(http: HttpClient) {
return {
list: () => http.get<Note[]>('/api/notes'),
add: (text: string) => http.post<Note>('/api/notes', { text })
};
}
export type NotesClient = ReturnType<typeof notesClient>;
import type { HttpClient } from '../../http/port';
export interface SessionUser {
name: string;
}
// The browser half of the session service: it asks the site's own /api
// handlers, never an identity provider, and takes its client as an argument.
export function sessionClient(http: HttpClient) {
return {
current: () => http.get<SessionUser | null>('/api/session')
};
}
export type SessionClient = ReturnType<typeof sessionClient>;
/* eslint-disable @typescript-eslint/no-require-imports -- dependency-cruiser loads this file with require(). */
// The miniature frontend's five rules in the real tool's format. Run from
// this examples/ directory with the resolver told about .svelte files:
// npx dependency-cruiser --config frontend-repo.dependency-cruiser.cjs 'frontend-repo/**/*.svelte' 'frontend-repo/**/*.ts'
// svelte/compiler must be importable from the tool's own location, or every
// import of a component is silently dropped. Even then, type-only imports
// inside .svelte files are invisible: the compiler removes them first.
// frontend-repo.resolve.cjs supplies the extension list.
const path = require('node:path');
module.exports = {
forbidden: [
{
name: 'lib-never-imports-routes',
severity: 'error',
comment: 'A route may use the library. The library may not know which routes exist.',
from: { path: '^frontend-repo/(components|features|services|http|app)/' },
to: { path: '^frontend-repo/routes/' }
},
{
name: 'display-primitives-stay-portable',
severity: 'error',
comment: 'A primitive is props in, markup out. No app state, no navigation, no content.',
from: { path: '^frontend-repo/components/display/' },
to: { path: '^frontend-repo/(features|services|app|routes)/|^\\$app/' }
},
{
name: 'components-do-not-import-features',
severity: 'error',
comment:
'Shared components sit below features. A feature composes components, never the reverse.',
from: { path: '^frontend-repo/components/' },
to: { path: '^frontend-repo/features/' }
},
{
name: 'features-do-not-cross',
severity: 'error',
comment:
'One feature per folder. What two features share is passed in, or lives in a named shared feature.',
from: { path: '^frontend-repo/features/([^/]+)/' },
to: { path: '^frontend-repo/features/', pathNot: '^frontend-repo/features/$1/' }
},
{
name: 'pages-do-not-call-services',
severity: 'error',
comment:
'A page renders. Data access goes through the browser root or a feature, not a service import.',
from: { path: '^frontend-repo/routes/' },
to: { path: '^frontend-repo/(services|http)/' }
}
],
options: {
tsPreCompilationDeps: true,
webpackConfig: { fileName: path.join(__dirname, 'frontend-repo.resolve.cjs') },
exclude: { path: 'node_modules' }
}
};
The brief. Asked to show the signed-in user’s name in the header. The agent imported the session feature’s state into the shared header component. It renders correctly on every page that has a session.
<script lang="ts">
import Button from '../display/Button.svelte';
import { createSessionState } from '../../features/session/session.svelte';
let {
title,
onhome,
session
}: { title: string; onhome?: () => void; session: ReturnType<typeof createSessionState> } =
$props();
</script>
<header>
<strong>{title}</strong>
{#if session.user}<span>Hi, {session.user.name}</span>{/if}
<Button label="Home" onclick={onhome} />
</header>
$ npx dependency-cruiser --config frontend-repo.dependency-cruiser.cjs --output-type err-long 'frontend-repo/**/*.svelte' 'frontend-repo/**/*.ts'
error components-do-not-import-features: frontend-repo/components/chrome/Header.svelte → frontend-repo/features/session/session.svelte.ts
Shared components sit below features. A feature composes components, never
the reverse.
x 1 dependency violations (1 errors, 0 warnings). 14 modules, 26 dependencies cruised.
$ echo $?
1
One line: the shared header now knows a feature exists. The fix is not in the header. The page knows both features, so the page passes the name down as a prop, and the header stays a component. The lab’s frontend surface has this change, three others, and the version that passes; one of the others sends a primitive into the framework’s navigation, which the check catches even though the module cannot be resolved.
Two more green runs that meant nothing, both recorded. With no svelte/compiler installed next to the tool, it read the Svelte files but silently
dropped every import of a component: twelve modules and eleven dependencies, where the run with
the compiler cruises fourteen and twenty-five. The count is the tell.
The second is quieter. Even with the compiler, the tool reads a component after compiling it, and the compiler removes type-only imports. The lab’s second frontend change imports only the session feature’s state type into the notes panel. The lab’s engine reads the import statement and says no; the real tool passes it. Of the sixteen import statements in the twelve files, the tool sees thirteen; the other twelve dependencies it counts are Svelte internals the compiler adds. A type-only import still ties one feature to another, so know the gap: review type imports in components, or have the agent pass the value down, as the version that passes does.
$ npx dependency-cruiser --config frontend-repo.dependency-cruiser.cjs --output-type err-long 'frontend-repo/**/*.svelte' 'frontend-repo/**/*.ts' # svelte/compiler not installed next to the tool
✔ no dependency violations found (12 modules, 11 dependencies cruised)
$ echo $?
0
$ npx dependency-cruiser --config frontend-repo.dependency-cruiser.cjs --output-type err-long 'frontend-repo/**/*.svelte' 'frontend-repo/**/*.ts' # features/notes/NotesPanel.svelte replaced; its session import is type-only
✔ no dependency violations found (14 modules, 25 dependencies cruised)
$ echo $?
0
Then on this site.
Eight rules for the real tree, run over every Svelte and TypeScript file in it: the library never imports routes; display primitives take props and return markup; shared components sit below features; features do not import each other except through named shared ones; a lesson owns one feature folder; a page renders and does not call a service; the browser never imports Supabase; UI never uses a Node built-in.
The eight rules for this siteRun from the repository root
/* eslint-disable @typescript-eslint/no-require-imports -- dependency-cruiser loads this file with require(). */
// Frontend layering for this site, as rules. Run from the repository root:
// npx dependency-cruiser --config src/lib/content/lessons/enforcement-layer/examples/frontend/.dependency-cruiser.cjs 'src/**/*.svelte' 'src/**/*.ts'
// Globs or the directory `src` give the same result, as long as svelte/compiler
// is importable from the tool's own location; without it every component
// import is silently dropped. Type-only imports inside .svelte files are never
// seen: the Svelte compiler removes them before the tool reads the output.
// node_modules is not excluded, only not followed, so the Supabase rule below
// can see an import of @supabase/*. Excluding it would drop those edges.
const path = require('node:path');
module.exports = {
forbidden: [
{
name: 'lib-never-imports-routes',
severity: 'error',
comment: 'A route may use the library. The library may not know which routes exist.',
from: { path: '^src/lib/' },
to: { path: '^src/routes/' }
},
{
name: 'display-primitives-stay-portable',
severity: 'error',
comment: 'A primitive is props in, markup out. No app state, no navigation, no content.',
from: { path: '^src/lib/components/display/' },
to: { path: '^src/lib/(features|content|server|services|app)/|^\\$app/' }
},
{
name: 'components-do-not-import-features',
severity: 'error',
comment:
'Shared components sit below features. A feature composes components, never the reverse.',
from: { path: '^src/lib/components/' },
to: { path: '^src/lib/features/' }
},
{
name: 'features-do-not-cross',
severity: 'error',
comment:
'One feature per folder. Shared state lives in a named shared feature, not in a neighbor.',
from: { path: '^src/lib/features/([^/]+)/' },
to: {
path: '^src/lib/features/',
pathNot: '^src/lib/features/($1|reader-context|preferences)/'
}
},
{
name: 'lessons-own-one-feature',
severity: 'error',
comment:
'A lesson imports its own feature folder and the shared ones. Never another lesson’s. The enforcement lesson’s examples are shared material for the agents area.',
from: { path: '^src/lib/content/lessons/([^/]+)/' },
to: {
path: '^src/lib/(features|content/lessons)/',
pathNot:
'^src/lib/(features|content/lessons)/($1|reader-context|preferences)/|^src/lib/content/lessons/enforcement-layer/examples/'
}
},
{
name: 'pages-do-not-call-services',
severity: 'error',
comment:
'A page renders. Data access goes through the app root or a feature, not a service import.',
from: { path: '^src/routes/.*\\+(page|layout)\\.svelte$' },
to: { path: '^src/lib/(services|server|http)/' }
},
{
name: 'browser-never-imports-supabase',
severity: 'error',
comment:
'The browser never holds a key. Only server code may import the Supabase client or adapters.',
from: {
pathNot:
'^src/lib/server/|\\+server\\.ts$|\\+(page|layout)\\.server\\.ts$|^src/hooks\\.server'
},
to: { path: 'node_modules/@supabase/|^src/lib/server/supabase/' }
},
{
name: 'ui-does-not-use-node-builtins',
severity: 'error',
comment:
'Browser code cannot use fs, path, or crypto from Node. If it compiles, nothing checked.',
from: { path: '^src/lib/(components|features)/', pathNot: '\\.spec\\.ts$' },
to: { dependencyTypes: ['core'] }
}
],
options: {
tsPreCompilationDeps: true,
webpackConfig: { fileName: path.join(__dirname, 'resolve.cjs') },
doNotFollow: { path: 'node_modules' }
}
};
Break one on purpose before trusting the rest. A probe file in a feature folder imports
the Supabase client, and the Supabase rule names it. The first version of this
configuration also excluded node_modules, which drops every edge into it
before any rule sees one; with that option the same probe is green. The rule said the
right thing and could never fire. The fixed configuration only declines to follow node_modules.
$ npx dependency-cruiser --config src/lib/content/lessons/enforcement-layer/examples/frontend/.dependency-cruiser.cjs --output-type err-long src/lib/features/enforcement-layer/supabase-leak.ts # a probe file, deleted after the run
error browser-never-imports-supabase: src/lib/features/enforcement-layer/supabase-leak.ts → node_modules/@supabase/supabase-js/dist/index.cjs
The browser never holds a key. Only server code may import the Supabase
client or adapters.
x 1 dependency violations (1 errors, 0 warnings). 2 modules, 1 dependencies cruised.
$ echo $?
1
$ npx dependency-cruiser --config src/lib/content/lessons/enforcement-layer/examples/frontend/.dependency-cruiser.cjs --output-type err-long src/lib/features/enforcement-layer/supabase-leak.ts # the same probe, with exclude: { path: 'node_modules' } added to options
✔ no dependency violations found (1 modules, 0 dependencies cruised)
$ echo $?
0
The first run, on 12 September, found six edges. Two pages imported a service module directly. Two chrome components reached into features for theme and session state. One feature imported another feature’s filter, and one shared component rendered a feature’s preview. None of these is a bug. Each is a question the sentence makes explicit: is the catalog a shared feature, or should its preview move below the line? Are theme and session shared state that chrome may read, or should the layout pass them down?
A baseline with existing violations is normal. The wrong response is to soften the rule. The tool has the right one: record the exact edges you are tolerating in a known-violations file, commit it so its history carries the date, and keep failing on any edge outside that list. The six went into that file on 12 September.
The known-violations fileSix edges, each named
[
{
"type": "dependency",
"from": "src/lib/components/catalog/CatalogPage.svelte",
"to": "src/lib/features/catalog/CatalogPreview.svelte",
"unresolvedTo": "$lib/features/catalog/CatalogPreview.svelte",
"dependencyTypes": ["aliased", "aliased-webpack", "local", "import"],
"rule": {
"severity": "error",
"name": "components-do-not-import-features"
}
},
{
"type": "dependency",
"from": "src/lib/components/chrome/AccountControl.svelte",
"to": "src/lib/features/session/session.svelte.ts",
"unresolvedTo": "$lib/features/session/session.svelte",
"dependencyTypes": ["aliased", "aliased-webpack", "local", "import"],
"rule": {
"severity": "error",
"name": "components-do-not-import-features"
}
},
{
"type": "dependency",
"from": "src/lib/components/chrome/ThemeToggle.svelte",
"to": "src/lib/features/preferences/preferences.svelte.ts",
"unresolvedTo": "$lib/features/preferences/preferences.svelte",
"dependencyTypes": ["aliased", "aliased-webpack", "local", "import"],
"rule": {
"severity": "error",
"name": "components-do-not-import-features"
}
},
{
"type": "dependency",
"from": "src/lib/features/roadmap/model.ts",
"to": "src/lib/features/catalog/filter.ts",
"unresolvedTo": "$lib/features/catalog/filter",
"dependencyTypes": ["aliased", "aliased-webpack", "local", "import"],
"rule": {
"severity": "error",
"name": "features-do-not-cross"
}
},
{
"type": "dependency",
"from": "src/routes/account/+page.svelte",
"to": "src/lib/services/session/index.ts",
"unresolvedTo": "$lib/services/session",
"dependencyTypes": ["aliased", "aliased-webpack", "local", "import"],
"rule": {
"severity": "error",
"name": "pages-do-not-call-services"
}
},
{
"type": "dependency",
"from": "src/routes/auth/sign-in/+page.svelte",
"to": "src/lib/services/session/index.ts",
"unresolvedTo": "$lib/services/session",
"dependencyTypes": ["aliased", "aliased-webpack", "local", "import"],
"rule": {
"severity": "error",
"name": "pages-do-not-call-services"
}
}
]
Then nothing ran it. This site’s CI runs a different, older check under tools/architecture, and this configuration was never wired to it. Here is the
same command on 23 September: twenty-two edges over 4,668 modules and 14,996 dependencies.
The six are still there, and sixteen more arrived in eleven days: the progress, review,
sync, and preferences features reach into each other and into the session feature, and one
lesson’s feature borrows another lesson’s stylesheets.
$ npx dependency-cruiser --config src/lib/content/lessons/enforcement-layer/examples/frontend/.dependency-cruiser.cjs --output-type err-long 'src/**/*.svelte' 'src/**/*.ts'
error pages-do-not-call-services: src/routes/auth/sign-in/+page.svelte → src/lib/services/session/index.ts
A page renders. Data access goes through the app root or a feature, not a
service import.
error pages-do-not-call-services: src/routes/account/+page.svelte → src/lib/services/session/index.ts
A page renders. Data access goes through the app root or a feature, not a
service import.
error features-do-not-cross: src/lib/features/sync/SyncBridge.svelte → src/lib/features/session/session.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/roadmap/TopicTree.svelte → src/lib/features/progress/ReadMark.svelte
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/roadmap/TopicTree.svelte → src/lib/features/progress/progress.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/roadmap/model.ts → src/lib/features/catalog/filter.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/review/ReviewQuiz.svelte → src/lib/features/progress/progress.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/review/ReviewPrompt.svelte → src/lib/features/progress/progress.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/review/ReviewBridge.svelte → src/lib/features/session/session.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/review/local.ts → src/lib/features/sync/index.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/progress/ProgressList.svelte → src/lib/features/review/review.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/progress/ProgressBridge.svelte → src/lib/features/session/session.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/progress/local.ts → src/lib/features/sync/index.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/progress/LessonProgress.svelte → src/lib/features/review/ReviewPrompt.svelte
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/preferences/PreferencesBridge.svelte → src/lib/features/session/session.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/preferences/local.ts → src/lib/features/sync/index.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/extracting-a-service/ExtractionLab.svelte → src/lib/features/consistency-across-modules/labs.css
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/extracting-a-service/DiffExercise.svelte → src/lib/features/consistency-across-modules/diff.css
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error components-do-not-import-features: src/lib/components/chrome/ThemeToggle.svelte → src/lib/features/preferences/preferences.svelte.ts
Shared components sit below features. A feature composes components, never
the reverse.
error components-do-not-import-features: src/lib/components/chrome/AccountControl.svelte → src/lib/features/session/session.svelte.ts
Shared components sit below features. A feature composes components, never
the reverse.
error components-do-not-import-features: src/lib/components/catalog/TopicCard.svelte → src/lib/features/progress/ReadMark.svelte
Shared components sit below features. A feature composes components, never
the reverse.
error components-do-not-import-features: src/lib/components/catalog/CatalogPage.svelte → src/lib/features/catalog/CatalogPreview.svelte
Shared components sit below features. A feature composes components, never
the reverse.
x 22 dependency violations (22 errors, 0 warnings). 4668 modules, 14996 dependencies cruised.
$ echo $?
22
With the known-violations file, the six are ignored and the sixteen fail, exit sixteen. That is the file doing its job; the missing piece was a place in the loop that runs it. A known-violations file is only a ratchet when something turns it.
$ npx dependency-cruiser --config src/lib/content/lessons/enforcement-layer/examples/frontend/.dependency-cruiser.cjs --output-type err-long --ignore-known src/lib/content/lessons/enforcement-layer/examples/frontend/known-violations.json 'src/**/*.svelte' 'src/**/*.ts'
error features-do-not-cross: src/lib/features/sync/SyncBridge.svelte → src/lib/features/session/session.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/roadmap/TopicTree.svelte → src/lib/features/progress/ReadMark.svelte
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/roadmap/TopicTree.svelte → src/lib/features/progress/progress.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/review/ReviewQuiz.svelte → src/lib/features/progress/progress.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/review/ReviewPrompt.svelte → src/lib/features/progress/progress.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/review/ReviewBridge.svelte → src/lib/features/session/session.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/review/local.ts → src/lib/features/sync/index.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/progress/ProgressList.svelte → src/lib/features/review/review.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/progress/ProgressBridge.svelte → src/lib/features/session/session.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/progress/local.ts → src/lib/features/sync/index.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/progress/LessonProgress.svelte → src/lib/features/review/ReviewPrompt.svelte
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/preferences/PreferencesBridge.svelte → src/lib/features/session/session.svelte.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/preferences/local.ts → src/lib/features/sync/index.ts
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/extracting-a-service/ExtractionLab.svelte → src/lib/features/consistency-across-modules/labs.css
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/extracting-a-service/DiffExercise.svelte → src/lib/features/consistency-across-modules/diff.css
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error components-do-not-import-features: src/lib/components/catalog/TopicCard.svelte → src/lib/features/progress/ReadMark.svelte
Shared components sit below features. A feature composes components, never
the reverse.
x 16 dependency violations (16 errors, 0 warnings). 4668 modules, 14996 dependencies cruised.
‼ 6 known violations ignored. Run with --no-ignore-known to see them.
$ echo $?
16
And once more, a count that meant something was wrong. The first attempt at this run, on
12 September, pointed the $lib alias one directory too shallow; every aliased import
went unresolved, no rule matched, and the run was green. Repeated on 23 September, the same
mistake reports two violations, both from relative imports the alias never touches, where the
correct run reports twenty-two. The tell is in the count: 6,620 modules, nearly two thousand
more than the correct run, because each unresolved specifier was counted as a module of its
own.
$ npx dependency-cruiser --config src/lib/content/lessons/enforcement-layer/examples/frontend/.dependency-cruiser.cjs --output-type err-long 'src/**/*.svelte' 'src/**/*.ts' # resolve.cjs pointing $lib one directory too shallow
error features-do-not-cross: src/lib/features/extracting-a-service/ExtractionLab.svelte → src/lib/features/consistency-across-modules/labs.css
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
error features-do-not-cross: src/lib/features/extracting-a-service/DiffExercise.svelte → src/lib/features/consistency-across-modules/diff.css
One feature per folder. Shared state lives in a named shared feature, not
in a neighbor.
x 2 dependency violations (2 errors, 0 warnings). 6620 modules, 14996 dependencies cruised.
$ echo $?
2
Not every rule is a from and a to. Two rules about the graph as a whole, run on the same tree with the lesson example repos left out: no cycles, and no module that nothing imports. They found two pairs of files in the animation design system that import each other, and two orphans: the default library index and a documentation module in the query layer. A cycle is the kind of thing an agent introduces in a single helpful edit and no file-at-a-time instrument will ever see.
$ npx dependency-cruiser --config src/lib/content/lessons/enforcement-layer/examples/frontend/graphwide.cjs --output-type err-long 'src/**/*.svelte' 'src/**/*.ts'
warn no-orphans: src/lib/query/doc.ts
Nothing imports this file. Either it is an entry point, or it is dead.
warn no-orphans: src/lib/index.ts
Nothing imports this file. Either it is an entry point, or it is dead.
error no-circular: src/lib/design-system/animations/explorations.ts →
src/lib/design-system/animations/studies.ts →
src/lib/design-system/animations/explorations.ts
A cycle means neither module can be understood, tested, or replaced alone.
error no-circular: src/lib/design-system/animations/diagrams.ts →
src/lib/design-system/animations/exploration-diagrams.ts →
src/lib/design-system/animations/diagrams.ts
A cycle means neither module can be understood, tested, or replaced alone.
x 4 dependency violations (2 errors, 2 warnings). 2734 modules, 11086 dependencies cruised.
$ echo $?
2
Two things this check does that a linter does not: it reasons about folders as tiers, and it can express “everything except” with a capture group, so one rule says a feature may import itself and the named shared features and nothing else. Two things it still cannot see: a fetch to an internal URL, and a permitted import used for the wrong job. Those belong to other instruments.
A verdict you can hand to the author: the rule’s name, the importing file, the module it reached, and the sentence explaining why. That is enough for an agent to fix the change without a conversation.
Put it where nobody has to remember it.
A check that a person runs when they think of it is advice. It becomes enforcement when it runs on every path to the main branch, and it becomes cheap when the agent runs it before reporting done. Three places, one command.
# .github/workflows/check.yml runs this step on every pull request.
# The exit code is the gate: dependency-cruiser exits non-zero on any error-level violation.
yarn depcruise --config .dependency-cruiser.cjs src The exit code is the gate. Nothing merges while it is non-zero, and the failure text carries the rule’s sentence.
# .git/hooks/pre-commit (or the hook your tool manages)
# Fail the commit before it exists, cruising only the staged TypeScript and Svelte files.
# With nothing staged, skip the run: given no files, the tool prints its usage and exits 0.
files=$(git diff --cached --name-only --diff-filter=ACMR -- ':(glob)src/**/*.ts' ':(glob)src/**/*.svelte')
[ -z "$files" ] || yarn depcruise --config .dependency-cruiser.cjs $files A hook catches it seconds after it is written, when the fix is one line. Cruising only the staged files keeps it fast. Skip the run when nothing matching is staged: given no files, the tool prints its usage and exits zero, a green hook that checked nothing.
# The agent's definition of done, in the instructions it reads before it starts.
# It runs the same command and reports the output, not a summary of it.
yarn check && yarn lint && yarn test && yarn depcruise --config .dependency-cruiser.cjs src Put the command in the instructions the agent reads. It runs the gate itself, reads the verdict, and fixes the change before a person sees it.
Before you call it enforced
- It can fail. You have seen red from a deliberate violation, in the place it is wired, not only on your machine.
- It runs on every path. A check that only runs in CI is skipped by a direct push; one that only runs in a hook is skipped by a clone that never installed it.
- Exceptions are scoped and dated. An old violation gets its exact edge listed, with a date. A global warn is a way of turning the check off politely.
- Rules name boundaries, not files. A rule that forbids one file is defeated by a barrel that re-exports it. Say what a tier may import, and forbid the rest.
Know what the verdict establishes. An import graph sees import statements. It does not see a string URL to an internal endpoint, a global, a dynamic import with a computed path, or a permitted edge used for the wrong purpose. A green run says the sentences are still true of the graph. It says nothing about whether the code is correct; that is the tests’ job, and only tests the agent did not write from the code can do it.
Other ecosystemsSame idea, other tools; not exercised here
For Go, depguard under golangci-lint forbids imports by package
pattern, and go-arch-lint checks a declared component graph. These recipes were
not run for this lesson; the shape is the point.
What does this verdict establish?
Choose the next move before touching the rules.
An agent made a service call fetch("/api/notes") with a string URL instead of using its store. Every import is allowed. The gate is green.
Leave a record the next agent can read.
- Rule
- The sentence, its two sides, and the name it has in the configuration.
- Baseline
- Module count, violations, and the exceptions listed with their dates.
- Proof it fails
- The deliberate violation you ran, where, and what the output said.
- Wired
- Which of CI, hook, and the agent’s instructions run it, and the command.
- Limits
- What this graph cannot see, and which other check covers it.
Write the note in the same file the agent reads before it starts. The rule’s sentence is then in three places that agree: the doc, the configuration, and the failure output.
Explain the gate without saying “enforcement layer.”
“We wrote each boundary as a sentence that can be false, turned it into a rule the build runs on every change, and read the module count so a green run means the check looked.” That is the whole technique. The name is what you call it in a review.
Before moving on, explain three things without the name: why “keep the layers clean” cannot be enforced, why a zero over zero modules is not a pass, and what the import graph still cannot see. Then pick one boundary your team repeats in review comments and write its sentence.
Connections to follow nextRelated lessons
- Architecture as rules declares the tiers once and derives these rules, the diagram, and the doc from the same source.
- Spec before code covers what this gate cannot: whether the code does what was intended, checked by tests the agent did not write from the code.
- Hexagonal / ports & adapters asks why the application core must not know its adapters, the direction these rules enforce.
- Layered architecture is the tier-by-tier shape the backend miniature follows.