# 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 ` | Reports every violation. Exits non-zero if any are found. | | `deslop fix ` | Rewrites relative imports to aliased ones. | | `deslop baseline ` | Writes `deslop/baseline.yaml` recording all current violations. | `` defaults to `.`. `--version` and `--help` are available. **`deslop fix` does not evaluate rulebooks.** It only applies the auto-fixable built-in check (`no-relative-imports`). It will never report or repair a `forbids` / `uses` / `exists` violation. Always use `deslop check` to see rule violations. After `deslop fix`, run your formatter: rewritten import lines may no longer match your import-order lint rule. The recommended script is `deslop fix . && npm run lint:fix`. In CI there is no key and no account: run `npx @ivy-apps/deslop check .` and let the exit code fail the job. ### Reading a violation ```` 🚀 Checking project: /path/to/project 📚 Loaded 2 rulebooks, 9 rules Found 32 problems: ───────────────────────────────────────── # architecture#no-react-in-data#@/features/home/data/bad-repository Data layer modules must not transitively depend on React. Module '@/features/home/data/bad-repository' transitively imports 'react' (2 hops) via: @/features/home/data/bad-repository → @/features/home/home-screen → react. ```ts import { HomeScreen } from "@/features/home/home-screen"; ``` FIX: Move React-dependent logic to a ViewModel or Container. ───────────────────────────────────────── ⏱ Checked 45 modules enforcing 11 rules in 10ms ❌ Error: Found 32 problems, 17 of them auto-fixable. Run `deslop fix` to fix the 17 auto-fixable problems. Run `deslop baseline` to silence all 32 problems. ```` The first line is the violation id: - Rule violations: `{rulebook-id}#{rule-id}#{module-id}` - Built-in checks: `{check-id}#{relative-file-path}` Then the rule's `description` (interpolated), the specific failure, the offending import statement, and the rule's `fix` (interpolated). The final `Checked N modules enforcing M rules` line is your sanity check on scope. If `N` is far smaller than your codebase, your alias or your ignore rules are wrong. ### Duplicate transitive violations are compacted One forbidden import usually drags a whole subtree of forbidden modules behind it, and every module in that subtree is a transitive violation of the same rule, all repaired by the same single edit. Deslop collapses them: ``` Module '@/hooks/useThing' transitively imports '@/ui/View' (1 hop) via: @/hooks/useThing → @/ui/View. Also reaches 3 more forbidden modules through this import. ``` The survivor is the **shortest** chain, and it names the imports the dropped ones came through, so nothing actionable is hidden. Only same-kind duplicates of the same violation id are collapsed: a rule a module breaks in two different ways still reports both. Compaction never drops a violation id, so it cannot change what a baseline suppresses. ### Baseline `deslop baseline .` writes `deslop/baseline.yaml` listing every violation that exists right now, so they stop being reported. Use it to adopt Deslop on a codebase that already violates the rules you want going forward. ```yaml - "architecture#features-isolated#@/features/cart/cart-service" - "no-import-cycles#src/features/cart/cart-actions.ts" ``` Rules for agents working with the baseline: - **Never write or edit `deslop/baseline.yaml` by hand.** Always regenerate it with `deslop baseline .`. The keys must match the engine's output exactly, and a hand-typed key silences nothing while looking like it does. - `deslop baseline` **overwrites** the file with the full current set. It is a re-record, not an append: violations you have since fixed drop out automatically. - Baseline is for real violations you are deferring. For a rule matching something it should not, narrow the rule with `exclude` instead. --- ## 12. Rule cookbook Adapt these to the project's actual directory names. They are written for a `@/*` alias pointing at `src/`. Every recipe here has been run against a fixture that proves it fires on a violation and stays silent on correct code. ### Keep server-only code out of client components ```yaml - id: no-server-in-client description: >- Client components must not import server-only modules, even transitively. A helper that imports a server action drags it into the browser bundle. target: "@/components/**" forbids: - import: "@/server/**" transitive: true - import: "**/*.server" transitive: true fix: >- Move the logic into a Server Component, a server action under @/server, or a route handler, and pass the result down as props. ``` ### Keep the database client on the server ```yaml - id: db-stays-on-the-server description: >- The database client and its credentials must never be reachable from code that can end up in the browser bundle. target: "**/*" exclude: - "@/lib/db" - "@/server/**" - "@/app/**/route" forbids: - import: "@/lib/db" transitive: true fix: Call a server action in @/server instead of reaching for the database. ``` ### Features must not import each other Note the shape: the feature root is named by a **variable**, in both the target and the `allows`. This is depth-safe. Writing `{{TARGET_DIR}}/**` here would be wrong for any file in a subfolder: see [Advanced usage](#13-advanced-usage). ```yaml - id: features-isolated description: >- Features are independent slices. A cross-feature import couples two slices and makes either one impossible to move or delete alone. target: "@/features/{{feature-name}}/**" forbids: - import: "@/features/**" allows: - import: "@/features/{{feature-name}}/**" # a feature's own directory, at any depth fix: >- Promote the shared code to @/components, @/hooks or @/lib and import it from there. ``` ### One feature may depend on exactly one other ```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. ``` ### Shared libraries stay framework-agnostic ```yaml - id: lib-is-framework-agnostic description: >- @/lib must be usable from tests, scripts and server code, so it must not pull in React or any UI or feature module. target: "@/lib/**" forbids: - import: "react" transitive: true - import: "@/components/**" - import: "@/features/**" - import: "@/app/**" fix: >- Move the React-dependent logic into a component or hook and keep @/lib to pure functions. ``` ### Production code must not import test code ```yaml - id: no-tests-in-prod description: Test utilities must never be reachable from shipping code. target: "**/*" exclude: - "**/*.spec" - "**/*.test" - "**/*.stories" - "@test/**" - "**/vitest.*" forbids: - import: "@test/**" transitive: true - import: "**/*.spec" transitive: true fix: >- Remove the import. If production needs this helper, extract it into a non-test module. ``` ### Every test imports the module it is named after ```yaml - id: spec-tests-its-module description: >- A spec that never imports its subject is a test passing for the wrong reason. Catches renamed modules and empty test files. target: "**/{{FileName}}.spec" uses: - import: "{{TARGET_DIR}}/{{FileName}}" fix: >- Import the module this spec is named after, or rename the spec to match the module it actually tests. ``` ### Every hook has a unit test ```yaml - id: hooks-have-specs description: Hooks hold logic worth testing and are cheap to test in isolation. target: "@/hooks/use{{FileName}}" exists: - module: "{{TARGET_DIR}}/use{{FileName}}.spec" fix: Add a use{{FileName}}.spec.ts next to the hook. ``` ### Every shared component has a Storybook story ```yaml - id: components-have-stories description: Shared components are documented by their stories. target: "@/components/{{FileName}}" exists: - module: "{{TARGET_DIR}}/{{FileName}}.stories" fix: Add a {{FileName}}.stories.tsx next to the component. ``` ### Next.js pages delegate to a feature view ```yaml - id: page-renders-a-feature-view description: >- App Router pages are entry points for routing, metadata and data fetching. UI belongs in a feature View so it can be tested and reused. target: "@/app/**/page" uses: - import: "@/features/**/*View" fix: >- Move the JSX into @/features//View.tsx and render it from the page. ``` ### HTTP access stays in the data layer ```yaml - id: http-client-only-in-data description: >- Only the data layer talks to the network, so caching, auth headers and error handling live in one place. target: "@/features/**" exclude: - "@/features/**/data/**" forbids: - import: "@/lib/http-client" fix: >- Add a function to the feature's data/ module and call that from the component or hook. ``` Note this one is deliberately **direct**, not `transitive: true`. The whole point of the layer is that a component reaches the network *through* `data/`, so the data module is exactly the intermediate that is allowed to launder the import. Adding `transitive: true` here would report every component that correctly calls its own `data/` module - the rule would fire on the code its own `fix` asks for. `exclude` cannot prevent that: it drops the data module from the rule's *target*, not from anyone else's import chain. ### Environment variables are read in one module ```yaml - id: env-access-is-centralized description: >- Reading process.env in many places makes it impossible to know what the app requires to boot. @/lib/env parses and validates once. target: "**/*" exclude: - "@/lib/env" - "**/*.config" forbids: - import: "@/lib/raw-env" fix: Import the parsed values from @/lib/env instead. ``` --- ## 13. Advanced usage Everything in this section changes what a rule matches. Read it before writing a rule that uses `{{TARGET_DIR}}`, `..`, or a variable in more than one casing. ### `{{TARGET_DIR}}` is relative to the file, not to the rule `{{TARGET_DIR}}` is the directory of the **matched file**. Under a target containing `**`, matched files sit at different depths, so the same clause resolves to a different directory for each of them. This is the worked example, verified against a real run: ```yaml - id: feature-isolation description: >- Features must not import other features, or reach into a shared tree they do not own. target: "@/features/{{feature-name}}/**" forbids: - import: "@/features/**" - import: "@/shared/**" allows: - import: "{{TARGET_DIR}}/**" # the file's OWN dir, not the feature root - import: "{{TARGET_DIR}}/../shared/**" - import: "{{TARGET_DIR}}/../../shared/**" fix: Promote the shared code out of the feature folders. ``` Both `forbids` entries matter to what follows. An `allows` only ever narrows a `forbids`, so a module no `forbids` entry matches is never reported however the `allows` resolve - and `@/shared/Util` does not match `@/features/**`. Because the target ends in `**`, it matches files at every depth, and each one gets its own `{{TARGET_DIR}}`: | matched module | `{{TARGET_DIR}}` | `{{TARGET_DIR}}/**` | `../shared/**` | `../../shared/**` | |---|---|---|---|---| | `@/features/home/HomeView` | `@/features/home` | `@/features/home/**` | `@/features/shared/**` | `@/shared/**` | | `@/features/home/widgets/Card` | `@/features/home/widgets` | `@/features/home/widgets/**` | `@/features/home/shared/**` | `@/features/shared/**` | | `@/features/home/widgets/deep/Deep` | `@/features/home/widgets/deep` | `.../deep/**` | `.../widgets/shared/**` | `@/features/home/shared/**` | Run against files at all three depths, each importing the same two modules: | module | `@/features/shared/Button` | `@/shared/Util` | |---|---|---| | `@/features/home/HomeView` | allowed via `../shared` | allowed via `../../shared` | | `@/features/home/widgets/Card` | allowed via `../../shared` | **violation** | | `@/features/home/widgets/deep/Deep` | **violation** | **violation** | The same import, with the same intent, is reported for one file and not another. Three consequences worth knowing: **1. `{{TARGET_DIR}}/**` does not mean "my feature".** It means "my containing directory". For `@/features/home/widgets/Card` it hydrates to `@/features/home/widgets/**`, so the file cannot import its own feature's `@/features/home/HomeService`. This is a false positive, and it is the most common mistake in real rulebooks. **2. A clause that widens can neutralise the `forbids` it qualifies.** ```yaml forbids: - import: "@/features/**" allows: - import: "{{TARGET_DIR}}/../**" # "my parent folder" ``` | matched file | resolves to | effect | |---|---|---| | `@/features/home/widgets/Card` | `@/features/home/**` | scoped as intended | | `@/features/home/HomeView` | `@/features/**` | **forbids nothing at all** | **3. `exists` can demand a file in the wrong place**, for exactly the same reason. **The fix: name the directory, do not count back to it.** Pin the feature root with a variable, and the rule is depth-safe: ```yaml - id: feature-isolation description: >- Features must not import other features, or reach into a shared tree they do not own. target: "@/features/{{feature-name}}/**" forbids: - import: "@/features/**" - import: "@/shared/**" allows: - import: "@/features/{{feature-name}}/**" # own feature, at ANY depth - import: "@/features/shared/**" # sibling shared dir - import: "@/shared/**" # top-level shared dir fix: Promote the shared code out of the feature folders. ``` Now every one of the three files above is silent, at every depth. Alternatively, pin the depth so `{{TARGET_DIR}}` is constant, by writing a target with no `**`: ```yaml target: "@/features/{{feature-name}}/{{FileName}}View" # always @/features/ allows: - import: "{{TARGET_DIR}}/../shared/**" # always @/features/shared ``` ### Too many `..` clamps to a silently dead clause As `/..` is `/` on a Unix path, a `..` with nothing left to cancel is a no-op. A clause that counts back further than `{{TARGET_DIR}}` is deep therefore resolves to a path with **no leading alias segment**, which matches no module id at all: ```yaml target: "@/{{feature-name}}/**" allows: - import: "{{TARGET_DIR}}/../../shared/**" ``` | matched file | `{{TARGET_DIR}}` | resolves to | result | |---|---|---|---| | `@/home/a/b/DeepView` | `@/home/a/b` | `@/home/shared/**` | live | | `@/home/HomeView` | `@/home` | `shared/**` | **dead, matches nothing** | Nothing warns about this. In an `allows` it means extra violations; in a `forbids` it means silence. **Count the `..` against the shallowest file your target can match.** ### Polarity: `forbids` accepts more spellings than `uses` Writing a variable in a casing it was not captured in is a guess, because PascalCase and camelCase mark word boundaries with a capital and any word may be written wholly in capitals. Deslop guesses in whichever direction costs a **false positive** rather than a **false negative**: a false positive is visible and can be silenced, while a rule that quietly stops enforcing is not visible at all. | Field | Polarity | A match means | Spellings accepted | |---|---|---|---| | `target` | **Widen** | the rule applies here | any name that spells every occurrence | | `exclude` | n/a | the module is dropped | *(no variables allowed)* | | `forbids` | **Widen** | a violation | **every** spelling of the name | | `allows` | **Narrow** | exempt from `forbids` | the canonical spelling only | | `uses` | **Narrow** | the rule is satisfied | the canonical spelling only | | `exists` | **Narrow** | the rule is satisfied | the canonical spelling only | Verified, with one file `src/internal/DBConnection.ts` and a target capturing kebab-case `db-connection`: ```yaml target: "@/widgets/{{file-name}}" # captures "db-connection" forbids: - import: "@/internal/{{FileName}}" # MATCHES the real DBConnection (widen) uses: - import: "@/internal/{{FileName}}" # DEMANDS DbConnection, reports it missing (narrow) ``` Same pattern text, same capture, opposite behaviour. **Same-casing use is never a guess**, so polarity only bites where a clause writes a variable in a casing its target did not capture it in. Name the folder in kebab-case or CONSTANT_CASE in the target and the reading is exact. ### Acronyms cannot always be read back A run of capitals carries no word boundary, so Deslop reads it as one word: ``` DBConnection -> db-connection HTTPClient -> http-client IOStream -> io-stream ``` Two readings it cannot recover: | Written | Read as | You probably meant | |---|---|---| | `AWSS3Client` | `awss3-client` | `aws-s3-client` | | `ABTest` | `ab-test` | `a-b-test` | This only bites where the name is captured **only** in PascalCase or camelCase. Name the folder in the target too and the reading is exact, because kebab-case and CONSTANT_CASE have no ambiguity. ### Variables bind greedily, unless something else pins them Where a boundary within one segment is genuinely ambiguous, the leftmost variable takes as much as it can: ``` @/x/{{provider-name}}-{{service-type}} on @/x/stripe-connect-payment-service -> provider-name = "stripe-connect-payment", service-type = "service" ``` Greedy is only the order the splits are tried in: the first that satisfies every variable in the rule wins, so naming the variable again elsewhere settles it exactly. ``` @/c/{{provider-name}}/{{provider-name}}-{{service-type}} on @/c/stripe/stripe-connect-payment -> provider-name = "stripe", service-type = "connect-payment" ``` Otherwise, separate them with a character no casing can contain, such as `/` or `.`. ### A target's casing is a filter `{{provider-name}}` matches a kebab-case segment and nothing else. A directory named `AWS_S3` is simply not a target of that rule, and Deslop says nothing about it. A rule that matches no module at all is **not reported**: it may be guarding a layer you have not built yet. If a rule seems to be doing nothing, check that its casings match your conventions. --- ## 14. Probing and verifying your rules A rule that matches nothing passes. A green run is therefore **not** evidence your rule works. Prove it. ### The verification loop 1. Write the rule. 2. Create **two** fixtures: one file that *should* violate it, and one that *should not*. 3. Run `deslop check .`. 4. Confirm **both** verdicts. The violation must be reported, and the correct file must be silent. A scratch project is enough, and takes about thirty seconds: ```bash mkdir -p probe/src/features/home/widgets probe/deslop/rules cd probe echo '{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }' > tsconfig.json # ... write the fixture files and deslop/rules/probe.yaml ... npx @ivy-apps/deslop check . ``` Check the `Checked N modules enforcing M rules` line: if `N` is 0 or far too small, your alias or your ignore rules are wrong, and nothing below it means anything. ### Bisecting a silent rule The rule loads, the run is green, and you do not know whether it works. 1. **Widen the target to `**/*`.** If violations appear, the target was the problem. If not, the clause is. 2. **Add a deliberate violation** in a file you are certain the target matches. Still silent means the clause pattern never matches. 3. **Narrow the target back one segment at a time**, re-running each time. The step where the violation disappears is the segment that is wrong. 4. Common culprits, in order of frequency: - the casing variable does not match the real file naming - the pattern does not start with an alias, so it matches no module id - `{{TARGET_DIR}}` hydrated to a deeper directory than you expected - a `..` clamped to a dead clause - the chain passes through a directory-form barrel - the module has no alias at all, so its id is an absolute file path ### Workflow for agents 1. **Read the codebase first.** Confirm the alias in `tsconfig.json` and the real top-level directories under `src/`. Never emit `@/features/**` for a project that has no `features/` directory. 2. **Confirm the architecture with the user** if it is not obvious from the tree. Rules encode intent, and intent is not always visible in the code. 3. **Start with three to six rules**, not thirty. Each should describe a boundary the team actually cares about. 4. **Write `description` and `fix` as prose a teammate can act on.** These are the entire output of a violation. 5. **Run `deslop check .`** and read every violation. 6. **Triage each one:** - The rule matched something it should not: add an `exclude` pattern. - The import is a deliberate, permanent exception: add an `allows` entry. - It is a real violation you are fixing now: fix the code. - It is a real violation being deferred: leave it for the baseline. 7. **Run `deslop baseline .`** once, when the remaining violations are all deliberate deferrals. Never hand-write the file. 8. **Wire it into CI** with `deslop check .` so the exit code fails the build. ### Self-check before finishing - [ ] Every pattern starts with an alias (`@/`), a package name, or `**`. - [ ] No `{{TARGET_DIR}}` and no `..` in a `target` or `exclude`. - [ ] No variables in an `exclude` pattern. - [ ] No wildcards in an `exists` pattern (it would abort only once a target matches, so a green run does not clear this). - [ ] The casing variable matches the project's actual file naming. - [ ] `{{TARGET_DIR}}/**` is not being used to mean "my feature" under a `**` target. Name the root with a variable instead. - [ ] Every `..` was counted against the **shallowest** file the target matches. - [ ] No rule depends on a transitive chain passing through a barrel imported by its directory form. - [ ] No `uses` or `exists` targets a barrel. - [ ] `deslop/rules/` is flat and contains only rulebook YAML files. - [ ] `deslop check .` was actually run, its output read, and at least one rule proven to fire on a deliberate violation. --- ## 15. Limitations ### Barrels split into two graph nodes, silently dropping transitive violations This is a known engine defect, not a design choice. A file `src/a/index.ts` is registered under the id `@/a/index`. An import written `"@/a"` resolves to that same file but keeps the directory form, creating a second node `@/a` with **no outgoing edges**. Reproducer: ```ts // src/app/DirForm.ts import { a } from "@/a"; // NO violation reported // src/app/IdxForm.ts import { a } from "@/a/index"; // violation: @/app/IdxForm -> @/a/index -> react // src/a/index.ts import * as React from "react"; ``` ```yaml target: "@/app/**" forbids: - import: "react" transitive: true ``` Same file, same `react` import, two import spellings, two different verdicts. Consequences: - Any transitive chain entering a barrel by its directory form **stops there**. - `target: "@/a"` matches nothing. Use `@/a/index`. - `exists: "@/a"` passes vacuously whenever anything imports that specifier. Until this is fixed: do not write rules whose transitive chain depends on passing through a barrel, and do not target barrels with `uses` or `exists`. Rules that name the concrete module work correctly. ### Re-exports are not edges `export { x } from "@/a"` and `export * from "@/a"` create no edge, independently of the issue above. A barrel written purely with re-exports has no outgoing edges under either id. ### `exists` wildcards abort at runtime, not at load A rulebook containing a wildcard in an `exists` pattern loads successfully and reports no problems for as long as its target matches nothing. The run aborts the first time a target does match. ### A repeated variable matches less, not more `@/components/{{provider-name}}/{{ProviderName}}View` applies only where the folder and the file are two spellings of one name. `stripe-connect/PaypalView` is not a target of it, and is not reported. To *require* the matching file, use `exists`. ### A variable cannot sit between two `**` `@/**/{{provider-name}}/**/{{FileName}}View` does not compile. With a globstar on both sides the *path* would decide which directory the variable names. Anchor it against a literal, or use `*` to fix the depth. ### Other - **Windows is not supported yet.** - **Monorepos** need one run per package; full multi-tsconfig support is in progress. - **`extends` in `tsconfig.json` is not followed.** Inline `baseUrl` and `paths`. - **JavaScript is never analyzed.** `.js`, `.jsx`, `.mjs`, `.cjs` are invisible. - **A rule matching zero modules is never reported.** Silence is not success. --- ## Further reading - [Ivy-Apps/deslop](https://github.com/Ivy-Apps/deslop) - the repository and the canonical README - [Glob+ reference](https://github.com/Ivy-Apps/deslop/blob/main/docs/GLOB+.md) - full pattern-matching semantics - [Example rulebooks](https://github.com/Ivy-Apps/deslop/tree/main/examples/rules) - MVI, Clean Architecture, Feature-Sliced Design, Next.js App Router, quality - [deslop.dev](https://deslop.dev) - the overview for humans - [npm package](https://www.npmjs.com/package/@ivy-apps/deslop)