# 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.11.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, relative imports or relative re-exports? (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 **modules** 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-arm64`, `linux-x64` and `win32-x64`. There is no `darwin-x64` build, so an Intel Mac builds from source. **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`. | | `allows-only` | no | `forbids: "**"` and `allows:` said in one breath. Sugar, expanded before compilation. | | `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.11.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: an unknown key fails the load.** A typo no longer produces a rule that quietly passes on everything - the run aborts, names the file and names the field. Verified at every level of the document: ``` deslop/rules/probe.yaml AesonException "Error in $.rules[0]: parsing Deslop.Rule.Book.Dto.RuleDto(RuleDto) failed, unknown fields: ["forbid"]" ``` A misspelt clause key is caught the same way, with the path to it: `Error in $.rules[0].forbids[0]: unknown fields: ["imports"]`. So is a misspelt top-level key: `Error in $: ... unknown fields: ["ruless"]`. **A rulebook key of two or more words is kebab-case.** `allows-only` is the first and so far only one; every other key is a single word. --- ## 4. Module names and resolution Deslop works with **module names**: 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 names, so every pattern you write should start with an alias (`@/...`) or be a package name. Resolution facts that change what your rules match: - The project's root `tsconfig.json` is read, and so is **every config it `extends`**, resolved into one effective config. Only `compilerOptions.baseUrl` and `compilerOptions.paths` are used. JSONC comments are stripped, so a commented tsconfig is fine. - A module not covered by any alias gets a **project-rooted, `/`-prefixed name** built from its path: `scripts/release.ts` is `/scripts/release`. It is still targetable - `target: "/scripts/**"` matches it - but no `@/...` pattern will. If rules mysteriously match nothing, check this first. ### A module answers to every name that resolves to it A module is identified by the *file it resolves to*, not by the text an author typed, so one module can have several names and a pattern matching **any** of them matches the module. - A barrel at `src/features/home/index.ts` answers to both `@/features/home/index` and `@/features/home`. Both `target:` spellings select it; both import spellings reach it. - If `tsconfig.json` maps two aliases onto the same directory, both alias spellings name the same module. Verified with `"@/*"` and `"~/*"` both pointing at `./src/*`: a rule written `forbids: - import: "@/server/**"` fires on a file whose source says `import { db } from "~/server/db"`, and reports it as `@/server/db`. - **A violation id is built from the module's canonical name, not from the spelling in your rule.** Verified: a rule written `target: "@/a"` matching a barrel reports `probe#rule#@/a/index`. So switching a rule between two spellings of the same module does not churn `deslop/baseline.yaml`. ```yaml target: "@/features/home" # matches the barrel target: "@/features/home/index" # matches the same barrel ``` Write whichever spelling your imports use. ### `extends` in `tsconfig.json` The whole chain is resolved and folded into one effective config, following `tsc` exactly: - A base is named by a **relative** (`./config/base.json`) or **rooted** path, and `.json` is appended when absent. An **array** of bases is followed left to right, later entries winning. - `compilerOptions` overlay key by key, and **`paths` is one key**: a config declaring any `paths` discards its base's entirely. There is no union. This is what `tsc` does, and disagreeing with it would resolve aliases the compiler rejects. - A relative `baseUrl` is made absolute against the directory of the file that *declared* it. With no `baseUrl` anywhere in the chain, `paths` values resolve against the directory of the config that declared `paths`. - A base named by a **package specifier** (`@repo/typescript-config/base.json`) is **skipped with a warning**, not followed. Shared config packages carry strictness flags rather than `paths`, so failing would break every Turborepo workspace over a file Deslop does not read. If your aliases live in one, copy them into a config Deslop can reach. ``` WARNING: Ignoring "extends": "@repo/typescript-config/base.json" in 'tsconfig.json'. Deslop resolves only relative and rooted paths, not package specifiers, so any path alias declared in that package is not applied. ``` - Everything else in the chain is fatal - a missing base, an unparseable base, a cycle, an `extends` of the wrong type - and the error carries the trail that reached it: ``` ❌ Error: TS config not found: 'config/nope.json' extended from: 'tsconfig.json' ``` ### 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"` | **yes**, a re-export is an import edge | | `export * from "@/a"` | **yes** | | `export type { T } from "@/a"` | **yes** | | `require("@/a")` | no | ### Barrels are ordinary modules A barrel written entirely with re-exports has outgoing edges like any other file, so a transitive chain runs straight through it: ```ts // src/a/index.ts export * from "@/a/impl"; // src/a/impl.ts import * as React from "react"; ``` ```yaml target: "@/app/**" forbids: - import: "react" transitive: true ``` Verified: both `import { impl } from "@/a"` and `import { impl } from "@/a/index"` are reported, with the same chain and the same hop count - ``` Module '@/app/DirForm' transitively imports 'react' (3 hops) via: @/app/DirForm → @/a/index → @/a/impl → react. ``` Nothing about barrels needs working around. Target them, require them with `uses`, assert them with `exists`, and route transitive rules through them. The one thing to know is that the re-export grammar is deliberately strict, because `deslop fix` rewrites what it classifies. The full `export [type] (* [as ns] | { ... }) from "specifier"` shape is an edge; anything else is ordinary code. `export const cfg = "./config"` is a string, not a re-export, and is never rewritten. ### 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 name 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 names, so there is nothing to be relative to. Write the path you mean. ### `..*` goes zero or many directories back `..*` is `..` repeated as many times as it can be. One written clause stands for one resolution per **zero-or-more** directories back - `{{TARGET_DIR}}` itself, then each ancestor above it - and the clause matches if **any** of them matches. Verified: a file whose own directory holds the `shared/` folder satisfies the clause just as one four levels below it does. ```yaml target: "@/client/**/{{FileName}}View" allows-only: - import: "{{TARGET_DIR}}/**" - import: "{{TARGET_DIR}}/..*/shared/**" # a shared/ at or above me ``` Matched at `@/client/billing/invoices/InvoiceView`, `{{TARGET_DIR}}` is `@/client/billing/invoices` and the second clause stands for all of: ``` @/client/billing/invoices/shared/** @/client/billing/shared/** @/client/shared/** @/shared/** shared/** (clamped past the root; matches no module) ``` **This is the answer to the depth problem.** A fixed `../shared/**` names a different folder at each depth and holds at only one of them; `..*` names them all, so the rule means the same thing however deep the matched file sits. See [Advanced usage](#13-advanced-usage) for the failure it removes. How many resolutions there are is decided by how deep `{{TARGET_DIR}}` actually is, when the clause is hydrated. There is no depth limit to configure and no constant to tune. `..*` is a whole segment or it is nothing, exactly like `..`: `..*shared` and `a..*b` are plain text. **Every segment behind a `..*` must name one directory.** A single `..` is checked against the one segment it would cancel; a `..*` may cancel any prefix, so all of them are checked: ``` {{TARGET_DIR}}/..*/shared/** OK @/client/..*/shared/** OK, literals all the way back @/client/{{feature-name}}/..*/x OK, a variable is literal text once substituted @/client/**/..*/shared/** ERROR, ** is zero or many segments: which one? @/**/a/b/..*/shared/** ERROR, the ..* could reach that ** too @/client/*View/..*/shared ERROR, a segment holding * names no directory ``` A `**` *ahead* of the `..*` is fine - `{{TARGET_DIR}}/..*/shared/**` is the motivating example. Neither rejection costs you anything: in the first case the `..*` could climb nothing at all and would be a silent no-op, and in the second the widest resolution is `@/**/shared/**`, which already subsumes every narrower one. Write that instead. **`..*` is rejected in `target`, `exclude` and `exists`.** The first two for the same reason `..` is; `exists` because it must name exactly one module. All three fail when the rulebook loads: ``` rule 'exists-cannot-name-one-module' exists.module: "{{TARGET_DIR}}/..*/registry" "..*" cannot be used here: this pattern must name exactly one module. "*", "**" and "..*" each stand for more than one path, so there would be no single module to require. Write the module you mean, or reach it from {{TARGET_DIR}}. ``` **In a `uses:` message, `..*` is printed as written.** There is no single module to name, so the token is kept the way `**` already is: ``` Module '@/client/billing/invoices/InvoiceContainer' must import '@/client/billing/invoices/..*/shared/registry'. ``` --- ## 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** | **no** | | `exclude` | yes | **no** | **no** | **no** | **no** | | `forbids.import` | yes | yes (substitutes) | yes | yes | yes | | `allows.import` | yes | yes (substitutes) | yes | yes | yes | | `allows-only.import` | yes | yes (substitutes) | yes | yes | yes | | `uses.import` | yes | yes (substitutes) | yes | yes | yes | | `exists.module` | **no** | yes (substitutes) | yes | yes | **no** | Every **no** in this table is a load-time error, not a silent no-op. 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. ### `allows-only` Syntax sugar: `allows-only: [x]` is exactly `forbids: "**"` plus `allows: [x]`. These two rules are the same rule. ```yaml # with allows-only # written out longhand target: "@/features/**" target: "@/features/**" allows-only: forbids: - import: "{{TARGET_DIR}}/**" - import: "**" allows: - import: "{{TARGET_DIR}}/**" ``` Use it when the allowance is the point and the `forbids: "**"` is only how you say "and nothing else". It is expanded before the rulebook is compiled, so it adds no semantics of its own and everything true of `forbids` and `allows` is true of it. **It appends to a hand-written `forbids` or `allows`, it does not replace one.** The combination is meaningful, because the generated forbid covers **direct** imports only: ```yaml target: "@/client/**" forbids: - import: "@/server/**" transitive: true # allows-only's generated forbid is direct-only allows-only: - import: "{{TARGET_DIR}}/**" - import: "@/mid/**" ``` Verified: `@/mid/**` is allowed as a direct import and the transitive `@/server/**` violation through it is still reported. **`**` means everything, including npm packages.** An unresolved import such as `react` is a module like any other, so `allows-only` forbids it too. This is the same behaviour a hand-written `forbids: "**"` has always had, but `allows-only` reads as though it were narrower. Verified against a real run: a file whose only import is `import React from "react"` is reported. List what you need: ```yaml allows-only: - import: "{{TARGET_DIR}}/**" - import: "react" - import: "next/*" ``` **A compile error in an `allows-only` glob is labelled `allows.import`.** The glob is quoted verbatim so you can still find it, but the key named is not the one you wrote: ``` rule 'allows-only-bad-glob' allows.import: "@/**/{{nope}}/**" {{nope}} is ambiguous: a single-word name reads as both camelCase and kebab-case. ``` ### `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: - **`*`, `**` and `..*` are rejected when the rulebook loads**, because each entry must name exactly one module. The failure arrives with every other error in the file, before a single module is checked - a green run no longer hides an illegal `exists`: ``` rule 'exists-wildcard' exists.module: "{{TARGET_DIR}}/*.spec" "*.spec" cannot be used here: this pattern must name exactly one module. "*", "**" and "..*" each stand for more than one path, so there would be no single module to require. Write the module you mean, or reach it from {{TARGET_DIR}}. ``` - **`exists` means a module Deslop actually parsed answers to that name.** It is not satisfied by the fact that something imports the specifier. Verified: with `import { g } from "@/ghost/missing"` in the source and no such file on disk, `exists: "@/ghost/missing"` is **reported as missing**. It follows that `exists` on an npm package always fails, and that a file excluded from the scan - gitignored, or under a skipped directory - does not count as existing. - A file satisfies `exists` even if nothing imports it. Orphan test files count. - A barrel satisfies `exists` under **either** of its names, because both name the file that is really 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.11.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 Three checks run with no rulebook: | Id | Catches | Auto-fixable | |---|---|---| | `no-relative-imports` | `import { x } from "../../lib/util"` where an alias like `@/lib/util` exists | yes | | `no-relative-exports` | `export * from "./util"` where an alias like `@/lib/util` exists | yes | | `no-import-cycles` | circular imports, printed as the loop | no | `no-relative-exports` is a **separate id** from `no-relative-imports`, not an extension of it, because a lint problem's baseline key is `{check-id}#{file}`. One shared id would mean that accepting a legacy relative import in a file also silenced - and stopped `deslop fix` repairing - every relative re-export in it. All four re-export spellings are covered: ``` [AUTO-FIXABLE] # no-relative-exports#src/lib/index.ts src/lib/index.ts:3 Relative re-exports are not allowed. Use aliased ones. ```ts export type { X } from "./types"; ``` FIX: Use ```export type { X } from "@/lib/types";``` instead. ``` 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 three count toward the rule total in the run summary, so a project with no rulebook still reports `enforcing 3 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 two auto-fixable built-in checks, `no-relative-imports` and `no-relative-exports`. 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: my-app 📚 Loaded 2 rulebooks, 9 rules Found 32 problems: ───────────────────────────────────────── # architecture#no-react-in-data#@/features/home/data/bad-repository src/features/home/data/bad-repository.ts:4 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 12 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-name}` - Built-in checks: `{check-id}#{relative-file-path}` The second line is **where**: the file, relative to the project root, and the 1-based line the offending statement is written on. It is printed for the two kinds of violation that have a statement to point at - a forbidden import and a cycle. A `uses` or `exists` violation prints the file with **no line**, because the complaint is that nobody wrote the statement. Then the rule's `description` (interpolated), the specific failure, the offending import statement, and the rule's `fix` (interpolated). **The location is not part of the violation id**, so editing a file above an accepted problem does not unsuppress it and `deslop/baseline.yaml` does not churn when lines move. 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. Most of it is the depth problem, and `..*` is the way out of it. ### `{{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. **Two fixes. Either name the directory, or climb with `..*`.** `..*` resolves once per ancestor and matches if any resolution matches, so the clause means the same thing at every depth: ```yaml allows: - import: "{{TARGET_DIR}}/..*/shared/**" # a shared/ at or above me ``` The other fix pins the feature root with a variable, which is better whenever you mean one specific directory rather than "any ancestor": ```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 name 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** - or write `..*`, which climbs as far as there is anything to climb and so has nothing to count. ### 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 name - `{{TARGET_DIR}}` hydrated to a deeper directory than you expected - a `..` clamped to a dead clause, where `..*` would have held - the module has no alias at all, so its name is `/`-rooted rather than `@/`-prefixed ### 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 `/`-rooted path, a package name, or `**`. - [ ] No `{{TARGET_DIR}}`, `..` or `..*` in a `target` or `exclude`. - [ ] No variables in an `exclude` pattern. - [ ] No `*`, `**` or `..*` in an `exists` pattern. - [ ] Every key is spelled exactly as this document spells it. An unknown key aborts the load, so a typo shows up immediately - but check the spelling of `allows-only`, the one kebab-case key. - [ ] 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, or reach it with `..*`. - [ ] Every `..` was counted against the **shallowest** file the target matches, or replaced with `..*`. - [ ] Every `allows-only` lists the npm packages the target legitimately needs. `**` forbids `react` too. - [ ] `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 ### 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 - **One `tsconfig.json` per run.** The `extends` chain is followed, so aliases declared in a shared base config are picked up, but a workspace with a `tsconfig.json` per package still needs one run per package. - **A base named by a package specifier is skipped**, with a warning. Aliases declared in a shared config *package* are not applied. - **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)