# 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