Deslop
Static import-graph analyzer for TypeScript. You write architecture rules in YAML; Deslop checks them on every run.
It catches what a linter structurally cannot: a Client Component that reaches your database client through two helpers, a feature quietly importing another feature (spaghetti), a hook shipped without a test.
Use-case: deterministic architecture guardrails for the move-fast AI era. Code now lands faster than a human can review it, Deslop is what keeps the architecture from drifting.
$ npx @ivy-apps/deslop check .Rules are YAML, so your agent can write them for you. Give it this prompt:
Read https://deslop.dev/llms.txt and write Deslop rules for my architecture.llms.txt is the complete rule-writing reference, written for coding agents.
No AI and no heuristics — Deslop walks the import graph, so the same code always produces the same result. 100% determinism
MIT licensed · free · no account
What it checks
Your rules
Rules live in YAML files you create under deslop/rules/ and apply to modules - the import paths in your code, like @/features/home/home-screen, rather than file paths on disk. Each rule picks its modules with target, optionally narrows that selection with exclude, and then states a constraint.
id: architecture # names this file in every violation it reports
name: Architecture
description: Import boundaries for this codebase.
rules:
- id: db-stays-on-the-server
description: The database client is server-only.
target: "**/*" # every module in the project...
exclude:
- "@/lib/db" # ...except the client itself,
- "@/server/**" # the server layer,
- "@/app/**/route" # and route handlers
forbids: # the constraint
- import: "@/lib/db"
transitive: true # a helper that imports it counts too
fix: Call a server action in @/server instead of reaching for the database.effective target = target − exclude
Patterns are written in Glob+, ordinary globs plus variables. A variable in the target captures a name from whichever module matched, and the same variable in a clause substitutes it back:
target @/features/**/use{{FileName}}ViewModel
matched @/features/profile/useUserProfileViewModel
{{FileName}} UserProfile
{{TARGET_DIR}} @/features/profileSo {{TARGET_DIR}}/use{{FileName}}ViewModel.spec in a clause means exactly @/features/profile/useUserProfileViewModel.spec, and one rule covers every ViewModel in the codebase. The captured name is available in all four casings - {{FileName}}, {{fileName}}, {{file-name}} and {{FILE_NAME}} - so the pattern can follow whatever your files are named.
The constraint is one of four clauses:
forbids- This module may not import that one. By default only the imports written in the file are checked; transitive: true widens that to everything reachable through them, so a dependency pulled in by a helper three modules away is caught too.
forbids: - import: "react" transitive: trueallows- Carves an exception out of a broad forbids. Unlike exclude, the module stays in the target and stays checked; only the listed import is let through.
allows: - import: "@/features/auth/**" - import: "{{TARGET_DIR}}/**"uses- This module must import that one.
uses: - import: "@/lib/auth/session"exists- A companion module must be present — a test, a story, a sibling file.
exists: - module: "{{TARGET_DIR}}/{{FileName}}.spec"
You do not have to write these by hand. Describe your architecture to your coding agent and point it at llms.txt, the full rule-writing reference written for agents rather than people.
Built-in checks
Two checks are always on and need no rulebook. They report through the same pipeline as your own rules, so deslop baseline silences them the same way.
no-relative-imports- Catches
../../lib/utilwhere an alias like@/lib/utilexists. - [AUTO-FIXABLE] Rewritten for you by
deslop fix. no-import-cycles- Catches circular imports, printed as the loop they form:
@/a → @/b → @/c → @/a. - Not auto-fixable - which import to cut is a design decision, not a rewrite.
What a violation looks like
Every violation names the rule, the module that broke it, and what to do about it. The fix text is written by whoever wrote the rule, so it can say something specific about your codebase.
A dependency reached through another module
The repository never imports React. It imports a screen, and the screen imports React — so React is in the data layer, and nothing that reads one file at a time can see it.
- id: no-react-in-data
description: >-
Data layer modules in @/features/**/data must not transitively
depend on React.
target: "@/features/**/data/**"
forbids:
- import: react
transitive: true
fix: >-
Remove any imports that transitively pull in React. Move any
React-dependent logic to a ViewModel or Container.# architecture#no-react-in-data#@/features/home/data/bad-repository
Data layer modules in @/features/**/data must not transitively depend on React. Repositories and data access code must stay framework-agnostic so they can be reused in server-side and non-React contexts without pulling in the entire React runtime.
Module '@/features/home/data/bad-repository' transitively imports 'react' via: @/features/home/data/bad-repository → @/features/home/home-screen → react.
```ts
import { HomeScreen } from "@/features/home/home-screen";
```
FIX: Remove any imports that transitively pull in React. Data layer modules must only import from @/lib, external non-React packages, or other framework-agnostic modules. Move any React-dependent logic to a ViewModel or Container in the features layer.One exception to a broad ban
Checkout may not import other features, except auth. The forbids clause is deliberately broad and allows carves the one hole in it.
- id: checkout-imports-only-auth
description: >-
Checkout must not depend on other features, except auth.
target: "@/features/checkout/**"
forbids:
- import: "@/features/**"
allows:
- import: "@/features/auth/**"
fix: >-
Remove the cross-feature import. Only @/features/auth is allowed -
promote anything else to a shared module.This module imports both:
import { currentSession } from "@/features/auth/auth-session";
import { formatInvoice } from "@/features/billing/invoice-formatter";
export function placeOrder() {
return formatInvoice(currentSession());
}# architecture#checkout-imports-only-auth#@/features/checkout/checkout-service
Checkout must not depend on other features, except auth.
Module '@/features/checkout/checkout-service' directly imports '@/features/billing/invoice-formatter'.
```ts
import { formatInvoice } from "@/features/billing/invoice-formatter";
```
FIX: Remove the cross-feature import. Only @/features/auth is allowed - promote anything else to a shared module.Only the billing import is reported. The auth import is not an oversight in the output - it is the exception doing its job.
An import that has to be there
Where forbids catches an import that is there, uses catches one that is not. Here a spec that never imports the module it is named after is a test passing for the wrong reason.
- id: spec-tests-its-module
description: >-
Every spec must import the module it is named after.
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.# quality#spec-tests-its-module#@/features/cart/CartTotals.spec
Every spec must import the module it is named after.
Module '@/features/cart/CartTotals.spec' must import '@/features/cart/CartTotals'.
FIX: Import the module this spec is named after, or rename the spec to match the module it actually tests.A file that should exist and does not
exists checks the shape of the codebase rather than the import graph. The {{FileName}} variable makes the rule relative to whichever module it matched.
- id: viewmodel-has-spec
description: >-
Every ViewModel must have a colocated unit test.
target: "@/features/**/use{{FileName}}ViewModel"
exists:
- module: "{{TARGET_DIR}}/use{{FileName}}ViewModel.spec"
fix: >-
Create the missing .spec module next to the ViewModel and
cover its state transitions.# architecture#viewmodel-has-spec#@/features/profile/useUserProfileViewModel
Every ViewModel must have a colocated unit test. ViewModels hold the presentation logic of a screen, so they are the highest-value place to test and the cheapest to test in isolation.
Module '@/features/profile/useUserProfileViewModel' requires '@/features/profile/useUserProfileViewModel.spec' to exist.
FIX: Create the missing .spec module next to the ViewModel and cover its state transitions.A relative import, fixed for you
The last two need no rule — Deslop ships with them. Violations it can repair itself are marked [AUTO-FIXABLE].
[AUTO-FIXABLE] # no-relative-imports#src/features/home/home-component.ts
Relative imports are not allowed. Use aliased ones.
```ts
import { capitalize } from '../../lib/util';
```
FIX: Use ```import { capitalize } from '@/lib/util';``` instead.Run fix and it rewrites them:
Changelog: modified src/features/home/home-component.ts ✨ Cleaned 1 files successfully!
- import { capitalize } from '../../lib/util';
+ import { capitalize } from '@/lib/util';An import cycle
Also always on. Deslop prints the loop itself rather than the fact that one exists, so the shortest edge to cut is visible from the output.
# no-import-cycles#src/features/cart/cart-actions.ts
Circular dependency (import cycle) detected: @/features/cart/cart-actions → @/features/cart/cart-selectors → @/features/cart/cart-store → @/features/cart/cart-actions
```ts
import { itemCount } from "@/features/cart/cart-selectors";
```
FIX: Import cycles are not allowed. Break the loop by removing one of its imports - usually by extracting the shared code into a module that both sides can depend on.Install
npm install --save-dev @ivy-apps/deslopOr run it without installing anything:
npx @ivy-apps/deslop check .First run
Give your project a path alias. Deslop identifies modules by their aliased import path, so without one no rule will match anything. It reads the root
tsconfig.jsononly and does not followextends, so this key has to be in that file.tsconfig.json{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }Create
deslop/rules/architecture.yaml. The rulebook above is a complete file - copy it and change the paths to match your own layout, or start from a ready-made rulebook.Run it. Every violation names the rule, the module and the fix.
npx @ivy-apps/deslop check .
- deslop check <dir>
- Report every violation.
- deslop fix <dir>
- Rewrite what can be fixed automatically.
- deslop baseline <dir>
- Record current violations so they stop being reported.
Use baseline for violations you have decided not to fix yet. For a rule matching something it should not, narrow its target with exclude instead.
In CI it is the same command — no key and no account. Prebuilt binaries ship for darwin-arm64, linux-x64 and linux-arm64. Windows is not supported yet.
If it catches something on your codebase, a star helps other people find it.