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, with a shorthand for the commonest pair of them:
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}}/**"allows-only- The target may import only what this clause lists, and nothing else. It is syntax sugar for a
forbids: "**"and anallows:written as one clause, and it adds to a hand-writtenforbidsrather than replacing it. Note that**means every module, npm packages included, so list the ones the target legitimately needs. allows-only: - import: "{{TARGET_DIR}}/**" - import: "react"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
Three 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-relative-exports- Catches
export * from './util'where 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
src/features/home/data/bad-repository.ts:1
Data layer modules in @/features/**/data 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: Remove any imports that transitively pull in React. Move any React-dependent logic to a ViewModel or Container.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
src/features/checkout/checkout-service.ts:2
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
src/features/cart/CartTotals.spec.ts
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
src/features/profile/useUserProfileViewModel.ts
Every ViewModel must have a colocated unit test.
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 and a relative re-export, fixed for you
The last three 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
src/features/home/home-component.ts:1
Relative imports are not allowed. Use aliased ones.
```ts
import { capitalize } from '../../lib/util';
```
FIX: Use ```import { capitalize } from '@/lib/util';``` instead.A re-export is an import edge like any other, so a barrel written export * from './util' is caught too — under its own id, no-relative-exports, so baselining one spelling never silences the other:
[AUTO-FIXABLE] # no-relative-exports#src/lib/index.ts
src/lib/index.ts:1
Relative re-exports are not allowed. Use aliased ones.
```ts
export * from './util';
```
FIX: Use ```export * from '@/lib/util';``` instead.One run of fix rewrites both:
Changelog: modified src/features/home/home-component.ts modified src/lib/index.ts ✨ Fixed 2 problems!
- import { capitalize } from '../../lib/util';
+ import { capitalize } from '@/lib/util';- export * from './util';
+ export * 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
src/features/cart/cart-actions.ts:1
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.