# Deslop > Static import-graph analyzer for TypeScript. You write architecture rules in > YAML; Deslop checks them on every run. No AI, no heuristics: it walks the > import graph, so the same code always produces the same result. This file is the complete reference for writing Deslop rules. It is written for coding agents: everything needed to author a correct rulebook is here, with no other page to fetch. Applies to `@ivy-apps/deslop` 0.10.x. Canonical documentation for humans: https://github.com/Ivy-Apps/deslop --- ## 1. What Deslop is for Deslop enforces *structure between modules*, not style within them. It is complementary to ESLint and Biome, not a replacement: it replaces the architecture enforcement you would otherwise spread across Dependency Cruiser configs and hand-written ESLint plugins. It answers four kinds of question: - May this module import that one? (`forbids`, `allows`) - Must this module import that one? (`uses`) - Does the companion file for this module exist? (`exists`) - Are there import cycles or relative imports? (built in, always on) Every check runs against the whole import graph, so a dependency reached through three intermediate modules is caught the same as a direct one. ### The mental model Deslop holds one data structure: a directed graph whose nodes are **module ids** and whose edges are **import statements**. Everything else is a query over it. ``` rule = (a set of modules) × (a predicate about their edges) ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ target - exclude forbids / allows / uses / exists ``` A rule that selects no modules asks nothing and passes. This is the single most common failure mode when writing rules, and Deslop does not warn about it: see [Probing](#14-probing-and-verifying-your-rules). --- ## 2. Project setup Deslop needs three things: 1. **A `tsconfig.json` in the project root** with `compilerOptions.paths` defining at least one alias, conventionally `"@/*": ["./src/*"]`. 2. **A `deslop/rules/` directory** holding one or more rulebook YAML files. 3. **The CLI**: `npm install --save-dev @ivy-apps/deslop`, or run it with `npx @ivy-apps/deslop check .`. Recommended `package.json` scripts: ```json { "scripts": { "deslop": "deslop check .", "deslop:fix": "deslop fix . && npm run lint:fix", "deslop:baseline": "deslop baseline ." } } ``` Prebuilt binaries ship for `darwin-arm64`, `linux-x64` and `linux-arm64`. Windows is not supported yet. **Constraints on `deslop/rules/`** (both abort the run, so they matter): - The directory must be **flat**. A subdirectory crashes the run with `withFile: inappropriate type (is a directory)`. - Every entry in it is parsed as a rulebook. A stray `README.md` or `.gitkeep` fails the load with a parse error. Put nothing else in there. --- ## 3. Rulebook and rule anatomy Every file in `deslop/rules/` is a rulebook. A rulebook is a YAML document with four required top-level fields: ```yaml id: architecture # used in violation ids, keep it short name: Architecture # human-readable title description: What this rulebook enforces as a whole rules: - id: rule-id description: What this rule checks and why target: "@/features/**" forbids: - import: "@/app/**" fix: What the developer should do about a violation ``` Rules across files are independent and all of them run. Split rulebooks by concern, layer or team as you like: `architecture.yaml`, `quality.yaml`, `nextjs.yaml`. ### Rulebook fields | Field | Required | Meaning | |---|---|---| | `id` | yes | Prefix of every violation id this rulebook produces. | | `name` | yes | Human-readable title. | | `description` | yes | What the rulebook enforces as a whole. | | `rules` | yes | The list of rules. | ### Rule fields | Field | Required | Meaning | |---|---|---| | `id` | yes | Unique within the rulebook. Appears in violation ids and baseline keys. | | `description` | yes | Printed above every violation. Say what *and why*. Variables are interpolated. | | `target` | yes | Glob+ pattern selecting the modules this rule applies to. | | `exclude` | no | List of plain-glob patterns removed from the target. | | `forbids` | no | Imports this module may not have. | | `allows` | no | Exceptions carved out of `forbids`. | | `uses` | no | Imports this module must have. | | `exists` | no | Modules that must exist. | | `fix` | yes | Plain-language instruction printed with every violation. Variables are interpolated. | | `example` | no | TypeScript snippet showing correct code. Not printed in 0.10.x. | A rule with no clause fields is valid YAML and checks nothing. If you write a rule, give it at least one of `forbids` / `uses` / `exists`. Do not invent fields. Unknown keys are ignored silently, so a typo like `forbid:` or `excludes:` produces a rule that passes on everything. --- ## 4. Module ids and resolution Deslop works with **module ids**: the aliased import path, without the file extension, not file paths on disk. ``` src/features/auth/AuthService.ts -> @/features/auth/AuthService ``` The mapping comes from `compilerOptions.paths` in the project's root `tsconfig.json`. Rule patterns are matched against module ids, so every pattern you write should start with an alias (`@/...`) or be a package name. Resolution facts that change what your rules match: - Only the **root `tsconfig.json`** is read, and only `compilerOptions.baseUrl` (defaulting to `.`) and `compilerOptions.paths`. JSONC comments are stripped, so a commented tsconfig is fine. - **`extends` is not followed.** A `tsconfig.json` whose `compilerOptions` live in a base file aborts the run with `Could not parse TS config`. Inline the `baseUrl` and `paths` in the root config. - A module not covered by any alias gets its **absolute file path** as its id, and no `@/...` pattern will ever match it. If rules mysteriously match nothing, this is why. ### `index.ts` and barrels This one is subtle and it changes what your rules can see. - The **file** `src/features/home/index.ts` is registered under the module id **`@/features/home/index`**. That node owns the file's outgoing edges. - An **import written `"@/features/home"`** is resolved to that same file, but the edge deliberately keeps the directory form and points at **`@/features/home`**. These are two distinct nodes in the graph. The directory-form node is a **sink with no outgoing edges**. Three consequences: ```yaml target: "@/features/home" # matches NOTHING, the file is /index target: "@/features/home/index" # correct ``` - A transitive chain that enters a barrel by its directory form **stops there**, even when the barrel uses real `import` statements. - `exists: "@/features/home"` passes as soon as anything imports that specifier, because the phantom sink node counts as existing. It is not evidence the file is there. See [Limitations](#15-limitations) for the full reproducer. ### Which files are analyzed Only `.ts` and `.tsx`, including `.d.ts`. JavaScript is never analyzed: `.js`, `.jsx`, `.mjs` and `.cjs` files are invisible to the graph. These directories are skipped: `node_modules`, `.git`, `dist`, `build`, `out`, `.next`, `.output`, `coverage`, `storybook-static`, `.turbo`, `.cache`, `.parcel-cache`, `.yarn`, `.direnv`, `.devenv`, `.svelte-kit`, `.nuxt`, `.astro`, `.vercel`, `.wrangler`, plus `next-env.d.ts`. Files ignored by `.gitignore` are skipped too. Deslop reads only `.gitignore` files found in the project tree; it ignores `.git/info/exclude` and the user's global git ignore file, so a given commit lints identically on every machine and in CI. --- ## 5. What counts as an import Deslop lexes `import` statements. Verified behaviour: | Statement | Edge in the graph? | |---|---| | `import { x } from "@/a"` | yes | | `import type { T } from "@/a"` | **yes**, type-only imports count | | `import "@/a"` (side effect) | yes | | `await import("@/a")` (dynamic) | **yes** | | `export { x } from "@/a"` | **no** | | `export * from "@/a"` | **no** | | `require("@/a")` | no | ### Re-exports are not edges A barrel file written with re-exports: ```ts // @/features/home/index.ts export * from "@/features/home/home-service"; ``` is a graph node with **no outgoing edges**. Combined with the directory-form split above, this means barrels break transitive reachability in two independent ways. Rules that name the concrete module (`@/features/home/home-service`) work correctly; rules whose transitive chain has to pass through a barrel do not. ### Third-party packages Packages are modules too, addressed by their import specifier: ```yaml forbids: - import: "react" # exact - import: "@tanstack/**" # scoped packages match with globs - import: "react-dom/client" # subpaths match literally - import: "next/navigation" ``` --- ## 6. Glob+ patterns Glob+ is ordinary glob plus **named variables** captured from the matched module. ### Paths are segments A module id is a list of `/`-separated **segments**, and a Glob+ pattern is a list of segment patterns matched against them one for one. Everything else follows from that: `**` is the only token that changes how many segments a pattern consumes; every other token consumes exactly one segment, or part of one. ``` @/components/stripe/CheckoutView -> [@] [components] [stripe] [CheckoutView] ``` ### Wildcards | Token | Scope | Meaning | |---|---|---| | `**` | a whole segment | zero or many segments | | `*` | inside a segment | zero or more characters, never a `/` | | `..` | a whole segment | one directory back, **clause patterns only** | `**` must stand alone as a segment. `@/a/**View` does not compile. Write `@/a/*View` to match inside one segment, or `@/a/**/*View` to cross segments. `*` is an ordinary part of a segment and may sit beside literals and variables: `use*ViewModel`, `*.spec`, and a bare `*` matching exactly one segment of any content. ### `**` means zero, so `a/**` matches `a` ``` @/lib/** matches @/lib (** stands for nothing) matches @/lib/jwt matches @/lib/auth/user ``` This is verified behaviour, and it is why `forbids: "@/internal/**"` catches an import of the module `@/internal` itself, not only what sits beneath it. It also means a `**` target selects files at *every* depth, which matters enormously for `{{TARGET_DIR}}`: see [Advanced usage](#13-advanced-usage). ### Variables A variable is **a name written in a casing**. The spelling determines both: the words give the variable its identity, and the way they are cased says which form you want at that spot. ``` {{ProviderName}} -> variable "provider-name", PascalCase {{providerName}} -> variable "provider-name", camelCase {{provider-name}} -> variable "provider-name", kebab-case {{PROVIDER_NAME}} -> variable "provider-name", CONSTANT_CASE ``` All four are the **same variable**. Capture it in one casing and you can use it in any of the others. `{{FileName}}` is not a special token: it is a variable named `file-name`, which is why `{{file-name}}` refers to the same value. Capturing `UserProfile` makes all four forms available: | Variable | Casing | Renders as | |---|---|---| | `{{FileName}}` | PascalCase | `UserProfile` | | `{{fileName}}` | camelCase | `userProfile` | | `{{file-name}}` | kebab-case | `user-profile` | | `{{FILE_NAME}}` | CONSTANT_CASE | `USER_PROFILE` | Every variable is enriched independently from its own capture, so a rule with three variables gets twelve usable forms. ### Casing is inferred, never declared A token compiles to whichever casing it is a valid spelling of: | Casing | Valid spelling | |---|---| | PascalCase | `^[A-Z][a-zA-Z0-9]*$` | | camelCase | `^[a-z][a-zA-Z0-9]*$` | | kebab-case | `^[a-z0-9]+(-[a-z0-9]+)*$` | | CONSTANT_CASE | `^[A-Z0-9]+(_[A-Z0-9]+)*$` | If exactly one matches, that is the casing. If none or more than one match, the rule fails to compile: | Token | Result | |---|---| | `{{ProviderName}}` | `provider-name`, PascalCase | | `{{provider-name}}` | `provider-name`, kebab-case | | `{{Provider}}` | `provider`, PascalCase, a lone capitalised word is unambiguous | | `{{provider}}` | **error**, reads as both camelCase and kebab-case | | `{{PROVIDER}}` | **error**, reads as both PascalCase and CONSTANT_CASE | | `{{Provider-Name}}` | **error**, not a recognised casing | | `{{provider_name}}` | **error**, snake_case is not supported | | `{{HTTPClient}}` | **error**, consecutive capitals have no word boundary | Use two or more words and every case resolves. This is about the **pattern**, not your files: a file named `HTTPClient.tsx` is captured happily by `{{ProviderName}}`. You choose what your rule says, so an ambiguous variable there is a mistake worth stopping for; you do not choose what the codebase is called, so an ambiguous value is read as generously as it can be. ### Capturing several parts of a path A pattern may capture as many variables as the path has meaningful parts: ```yaml target: "@/components/{{provider-name}}/{{service-type}}/{{FileName}}View" ``` Matching `@/components/stripe-connect/payment/CheckoutView`: | Variable | kebab-case | PascalCase | camelCase | CONSTANT_CASE | |---|---|---|---|---| | `provider-name` | `stripe-connect` | `StripeConnect` | `stripeConnect` | `STRIPE_CONNECT` | | `service-type` | `payment` | `Payment` | `payment` | `PAYMENT` | | `file-name` | `checkout` | `Checkout` | `checkout` | `CHECKOUT` | A clause may then compose them: ```yaml - id: view-model-calls-its-own-provider-service description: A ViewModel may only talk to its own provider's service module. target: "@/components/{{provider-name}}/{{service-type}}/use{{FileName}}ViewModel" uses: - import: "@/services/{{provider-name}}/{{service-type}}-{{file-name}}" fix: Import your own provider's service module. ``` `@/components/stripe-connect/payout/useTransferViewModel` must import `@/services/stripe-connect/payout-transfer`. Because the expected module name is derived from all three variables, no other provider's service satisfies it. ### Where a variable may stand **A variable must be anchored.** In a `target`, a variable may not have `**` on both sides: ``` @/{{provider-name}}/**/{{FileName}}View OK, each variable anchored on one side @/**/{{provider-name}}/{{FileName}}View OK, anchored from the end @/**/{{provider-name}}/**/{{FileName}}View ERROR, nothing says which directory it is @/**/{{provider-name}}/** ERROR, same ``` With `**` on both sides the *path* would decide which directory the variable names rather than the pattern. Anchor it against a literal, or use `*` to fix the depth. Clause patterns are exempt: they substitute rather than capture. **Two variables need a literal between them.** ``` @/x/{{FileName}}{{ServiceType}} ERROR, no boundary @/x/{{FileName}}*{{ServiceType}} ERROR, a * can match nothing @/x/{{provider-name}}-{{service-type}} OK @/x/{{provider-name}}/{{service-type}} OK ``` ### Repeating a variable The same variable may appear twice, which constrains both places to the same value: ```yaml target: "@/components/{{provider-name}}/{{ProviderName}}View" ``` ``` @/components/stripe-connect/StripeConnectView matches @/components/stripe-connect/PaypalView does not match, rule does not apply ``` A repeated variable is a **narrower** filter: it matches strictly less than two distinct variables would. To *require* the matching file rather than skip the files that lack it, use `exists`. ### `{{TARGET_DIR}}` The directory of the matched module: every segment but the last. ``` matched: @/features/home/HomeContainer {{TARGET_DIR}} -> @/features/home ``` **Legal in clause patterns only**, never in `target` or `exclude`, because there is no matched module yet. The name `target-dir` is reserved in every casing: `{{targetDir}}`, `{{target-dir}}` and `{{TargetDir}}` are all errors pointing at the one accepted spelling. `{{TARGET_DIR}}` is the directory of the **matched file**, so under a target containing `**` it is a different directory for files at different depths. This is the single sharpest edge in the DSL: see [Advanced usage](#13-advanced-usage). ### `..` goes one directory back A clause pattern may use `..`, which cancels the segment to its left. It is what lets a clause reach *sideways* from `{{TARGET_DIR}}` rather than only downwards. ```yaml target: "@/client/{{feature-name}}/{{FileName}}View" forbids: - import: "@/client/**" allows: - import: "{{TARGET_DIR}}/**" # my own folder - import: "{{TARGET_DIR}}/../shared/**" # my sibling shared/ folder ``` Matched at `@/client/home/HomeView`, `{{TARGET_DIR}}` is `@/client/home` and the second `allows` resolves to `@/client/shared/**`: ``` [@] [client] [home] [..] [shared] [**] -> @/client/shared/** ^^^^^^ ^^^^ cancel each other ``` Each `..` goes back exactly **one** directory. Cancellation happens after substitution, which is why a `..` after `{{TARGET_DIR}}` drops one directory of it rather than all of it. `..` is a whole segment or it is nothing. `..foo`, `...` and `a..b` are plain text; `.` is not special either. **It may only go back past a directory the pattern names:** ``` {{TARGET_DIR}}/../shared/** OK @/client/home/../shared/** OK, a literal @/client/{{feature-name}}/../x OK, a variable is literal text once substituted @/client/**/widgets/../shared OK, the .. cancels widgets, not the ** @/client/**/../shared/** ERROR, ** is zero or many segments: which one? @/client/*/../shared/** ERROR, a * names no directory in particular @/a*/b/../../shared ERROR, the second .. reaches a* ``` `target` and `exclude` reject `..` outright: both are matched against whole module ids, so there is nothing to be relative to. Write the path you mean. --- ## 7. Targeting: `target` and `exclude` `target` selects modules. `exclude` removes modules from that selection. ``` effective target = target - exclude ``` ```yaml target: "@/features/**/*" exclude: - "**/*.spec" - "**/*.stories" ``` A module removed by `exclude` is not checked by the rule at all. **`exclude` is a plain glob.** It supports `*` and `**` and nothing else. Variables are rejected at load time, because an exclude pattern filters the target and binds nothing, so a variable there could never resolve: ``` rule 'exclude-var' exclude: "@/lib/{{FileName}}" {{FileName}} cannot be used in an exclude pattern. An exclude pattern filters the target and binds no variables. Use a wildcard instead, e.g. * or **. ``` ### Where each pattern type is legal | Field | `*` and `**` | Variables | `{{TARGET_DIR}}` | `..` | |---|---|---|---|---| | `target` | yes | yes (captures) | **no** | **no** | | `exclude` | yes | **no** | **no** | **no** | | `forbids.import` | yes | yes (substitutes) | yes | yes | | `allows.import` | yes | yes (substitutes) | yes | yes | | `uses.import` | yes | yes (substitutes) | yes | yes | | `exists.module` | **no** | yes (substitutes) | yes | yes | A clause may only use variables its own rule's `target` captures. A typo is caught at load time rather than silently widening the rule: ``` rule 'view-wires-view-model' uses.import: "{{TARGET_DIR}}/{{provider-nam}}Service" unknown variable {{provider-nam}}. Variables bound by this rule's target: file-name, provider-name, service-type Did you mean {{provider-name}}? ``` ### `exclude` vs `allows` vs baseline The three are the most commonly confused part of the DSL: | Situation | Use | |---|---| | The rule matches modules it was never meant to | `exclude` | | The rule is right, but this one import is deliberate | `allows` | | A real violation you are not fixing today | `deslop baseline .` | `exclude` removes the module from the rule entirely. `allows` keeps the module checked and permits one specific import. --- ## 8. Clauses ### `forbids` The target module may not import the pattern. ```yaml forbids: - import: "@/data/http-client" # direct import only - import: "@/server/**" transitive: true # reachable through any chain ``` `transitive: true` checks the entire reachable import graph. Use it whenever the thing you are protecting against can arrive through a helper: server-only code, database clients, heavy runtime dependencies. Use direct (the default) when you are enforcing a layering convention that intermediate modules are allowed to launder. All entries are checked independently; any match is a violation. ### `allows` Whitelists imports that a `forbids` clause in the *same rule* would otherwise catch. ```yaml - id: checkout-imports-only-auth description: Checkout must not depend on other features, except auth. target: "@/features/checkout/**" forbids: - import: "@/features/**" allows: - import: "@/features/checkout/**" - import: "@/features/auth/**" fix: Remove the cross-feature import. Only @/features/auth is allowed. ``` Two things to know, both verified: - **`allows` only ever modifies `forbids`.** It has no effect on `uses` or `exists`, and a rule with `allows` and no `forbids` does nothing. - **On a transitive `forbids`, `allows` matches the endpoint of the chain, not the modules along it.** You cannot whitelist a path. ```yaml # @/app/A -> @/mid/M -> @/bad/Bad forbids: - import: "@/bad/**" transitive: true allows: - import: "@/mid/**" # does NOT suppress: @/mid is an intermediate - import: "@/bad/Bad" # DOES suppress: @/bad/Bad is the endpoint ``` `allows` is scoped to the rule it appears in, and every matching rule is evaluated independently. Shipping a narrow rule *alongside* a blanket one means the blanket rule still fires. Pick one: either the blanket rule with a per-feature `exclude`, or per-feature rules. ### `uses` The target module must import the pattern. A missing import is a violation. ```yaml uses: - import: "{{TARGET_DIR}}/{{FileName}}StateEvent" # must import directly - import: "@/lib/auth/session" transitive: true # anywhere in the chain ``` Every entry is required. `transitive: true` is satisfied if the module appears anywhere in the reachable graph, which is the right choice when a Screen delegates to a Container that does the actual wiring. ### `exists` A module must exist at the resolved path. This checks the shape of the codebase rather than the import graph. ```yaml exists: - module: "{{TARGET_DIR}}/{{FileName}}View.stories" - module: "{{TARGET_DIR}}/use{{FileName}}ViewModel.spec" ``` Three things to know: - **Wildcards are rejected**, because each entry must resolve to exactly one path. But this is a **runtime** abort, raised the first time a target actually matches: ``` Error: Invalid rule configuration: Rule 'does-match' in rulebook 'probe': 'exists' patterns must not contain wildcards (* or **). ``` A rulebook containing a wildcard `exists` **loads successfully and reports no problems** as long as its target matches nothing. A green run is not evidence the pattern is legal. - A file satisfies `exists` even if nothing imports it. Orphan test files count. - `exists` on a **directory-form barrel** passes vacuously. `moduleExists` only asks whether the graph has a node with that id, and an import written `"@/features/home"` creates one whether or not the file is there. --- ## 9. `fix`, `description` and `example` `fix` is printed with every violation and is the field agents and developers act on. Make it an instruction, not a restatement of the problem: ```yaml # Good fix: Move the query into a server action under @/server and call that instead. # Useless fix: This import is not allowed. ``` ### Variables are interpolated into `fix` and `description` Any variable the rule's `target` captured, plus `{{TARGET_DIR}}`, is substituted in both fields, in the casing you spell it in, so the message names the actual file rather than the pattern. ```yaml target: "@/features/**/{{FileName}}Container" description: "{{FileName}}Container must be driven by a ViewModel." fix: Import use{{FileName}}ViewModel from {{TARGET_DIR}} and drive the View from it. ``` For `@/features/checkout/PaymentContainer` that prints: ``` PaymentContainer must be driven by a ViewModel. FIX: Import usePaymentViewModel from @/features/checkout and drive the View from it. ``` All four casings work. A token naming nothing in scope, whether a typo or a variable this rule's target never captured, is printed **exactly as written** rather than blanked out, and does **not** fail the load: ```yaml fix: "unknown={{nope-here}}" # prints literally: unknown={{nope-here}} ``` This is the one place an unbound variable is not a compile error, so a typo here is silent. Read your own output once. **`..` is not collapsed in a message.** Interpolation is plain text substitution; `..` cancellation is a *pattern* operation. So a `fix` containing `{{TARGET_DIR}}/..` prints the literal `@/features/home/widgets/..` rather than `@/features/home`. Write the directory you mean in prose instead of counting back to it. ### `example` An optional TypeScript snippet of correct code. It is accepted and validated, but **0.10.x does not print it**: a violation shows the rule's `description`, the offending import statement, and `fix`, and nothing else. Put anything a developer must actually read into `fix`. ```yaml example: | import { HomeStateEvent } from "@/features/home/HomeStateEvent"; export function HomeContainer() { ... } ``` --- ## 10. Built-in checks Two checks run with no rulebook: | Id | Catches | Auto-fixable | |---|---|---| | `no-relative-imports` | `../../lib/util` where an alias like `@/lib/util` exists | yes | | `no-import-cycles` | circular imports, printed as the loop | no | They report through the same pipeline as your rules, so `deslop baseline` silences them the same way. Their baseline keys use the lint format `{check-id}#{relative-file-path}`. Cycles are reported **one problem per strongly connected component**, not one per distinct loop, because the number of elementary cycles is exponential and would make the baseline unbounded. Each component is reported against its alphabetically first module, showing the shortest loop from that module back to itself. These two count toward the rule total in the run summary, so a project with no rulebook still reports `enforcing 2 rules`. --- ## 11. CLI, violations and the baseline | Command | What it does | |---|---| | `deslop check