Skip to content

Latest commit

 

History

274 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Deslop

npm npm downloads Haskell TypeScript GitHub Stars Quality

Static import-graph analyzer for TypeScript. You write architecture rules in YAML; Deslop checks them on every run.

You define your architecture once — what modules may import, what they must import, and what companion files must exist (unit tests, Storybook stories). When a rule breaks, Deslop reports exactly what broke and how to fix it, in plain language that both your team and your AI agents can act on.

No AI and no heuristics: it walks the import graph, so the same code always produces the same result.

Note

Deslop is not a replacement for ESLint or Biome — it's complementary. What Deslop replaces is the architecture enforcement part: the import-boundary rules and companion-file checks you'd otherwise spread across Dependency Cruiser configs and hand-written ESLint plugins.

Learn more at deslop.dev.


Key Capabilities

  • Flexible targeting - target TS modules minus optional exclude using Glob+ patterns
  • Forbid imports — forbids, direct or transitive, catching violations through any import chain
  • Carve out exceptions — allows whitelists specific imports against a broad forbids
  • Require imports — uses enforces mandatory dependencies at the module level
  • Require companion files — exists asserts that a test, story, or sibling module is there
  • Detect import cycles — circular dependencies are found automatically across your whole module graph, with the exact loop printed
  • Glob+ patterns - named variables like {{provider-name}}, {{FileName}} and {{TARGET_DIR}} capture parts of a path and reuse them, in any casing, across a rule
  • Plain-language fix messages — every violation tells your team (and your agents) exactly what to do

Quick Start

npm install --save-dev @ivy-apps/deslop

Or run without installing: npx @ivy-apps/deslop check .

Recommended package.json scripts:

{
  "scripts": {
    "lint:fix": "your favorite linter",
    "deslop": "deslop check .",
    "deslop:fix": "deslop fix . && npm run lint:fix",
    "deslop:baseline": "deslop baseline ."
  }
}

Then write your first rulebook in deslop/rules/ — see Writing Rules, or copy one from examples/rules/.

Commands

Command What it does
deslop check <project> Report all rule violations
deslop fix <project> Auto-fix violations where possible
deslop baseline <project> Write deslop/baseline.yaml to silence current violations

CI with GitHub Actions

Example GitHub Actions workflow
name: Architecture Check

on:
  push:
    branches: [main]
  pull_request:

jobs:
  deslop:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm

      # Assumes Deslop is in devDependencies
      # and "deslop": "deslop check ." is in your scripts
      - run: npm ci

      - run: npm run deslop

Prebuilt binaries ship for darwin-arm64, linux-x64, linux-arm64, and win32-x64.


How It Works

You describe your architecture in declarative YAML rulebooks and drop them in deslop/rules/. Multiple files are supported — split rules by concern, team, or layer however you like. On every run, Deslop reads all rulebooks and enforces them across your entire codebase.

Rules are concise and self-documenting. A non-engineer can read a rulebook and understand the intended architecture. No plugins to author, no regex to wrestle with — just YAML that says what's allowed and what isn't.

Deslop works with module names — the aliased import paths your code already uses, like @/features/auth/AuthService, rather than relative file paths (not src/features/auth/AuthService.ts).

A module answers to every name that resolves to it, and a rule matching any one of them matches the module. A barrel at src/features/home/index.ts is named by both @/features/home and @/features/home/index; if your tsconfig.json maps two aliases onto the same directory, both name the same module. Write whichever you write in your imports.

Tip

Make sure your project has a @/ path alias configured in tsconfig.json so Deslop can resolve all modules.

Built-in Checks

Three checks are always on and need no rulebook:

Rule What it catches Auto-fixed by deslop fix
no-relative-imports ./util or ../../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 — @/a → @/b → @/c → @/a No

They report through the same pipeline as rulebook violations, so deslop baseline silences them like anything else. Their baseline keys use the lint format, {rule-id}#{relative-file-path}:

- "no-import-cycles#src/a.ts"
- "no-relative-imports#src/lib/util.ts"
- "no-relative-exports#src/lib/index.ts"

Example: Feature-Sliced (Vertical-Sliced) Architecture

The rulebook below enforces a demo Feature-Sliced architecture and demonstrates all Deslop clause types: forbids (direct and transitive), allows (exceptions to forbids), uses (requires an import), and exists (requires a module to exist in the module graph).

id: feature-sliced
name: Feature-Sliced Architecture
description: A demo for Feature-Sliced (Vertical-Sliced) architecture.
rules:
  - id: feature-isolation
    description: Features must not import from other features.
    target: "@/features/**" # all TS modules in features
    forbids:
      - import: "@/features/**" # can't import anything from features
    allows: # forbids exception
      - import: "{{TARGET_DIR}}/**" # from own feature dir is fine
    fix: >-
      Promote shared logic to @/components, @/hooks, @/lib or an appropriate shared folder.

  - id: lib-feature-agnostic
    description: src/lib must not be coupled to specific features.
    target: "@/lib/**"
    forbids:
      - import: "@/features/**"
    fix: Promote the violating code to @/lib or an appropriate shared folder.

  # @/components, @/hooks, @/types should be feature-agnostic

  - id: no-server-in-client
    description: Client components must not import server-only modules, even transitively.
    target: "@/components/**"
    forbids:
      - import: "**/*.server"
        transitive: true
      - import: "@/server/**"
        transitive: true # a helper that imports a server action is still a violation
    fix: Move the logic to a Server Component, a server action, or an API route.

  - id: hooks-has-tests
    description: Each hook must have unit tests.
    target: "@/hooks/use{{FileName}}"
    exists:
      - module: "{{TARGET_DIR}}/use{{FileName}}.spec"
    fix: Add a use{{FileName}}.spec.ts test suite in the same directory as the hook.

  - id: tests-test-the-module-under-test
    description: Each test suite must import the TS module that it's testing.
    target: "**/{{FileName}}.spec"
    uses:
      - import: "{{TARGET_DIR}}/{{FileName}}"
    fix: Import the TypeScript module that the test is named after.

  # components/pages has a Storybook using "exists: module"

  - id: no-tests-in-prod
    description: Production code must never import test utilities, even transitively.
    target: "**/*"
    exclude:
      - "**/*.spec"
      - "**/*.stories"
      - "@test/**"
      - "**/vitest.*"
    forbids:
      - import: "@test/**/*"
        transitive: true
      - import: "**/*.spec"
        transitive: true
    fix: Remove the import. If needed in production, extract to a non-test utility.

Writing Rules

Rulebook Structure

Every .yaml file in deslop/rules/ is a rulebook.

id: my-rulebook
name: My Rulebook
description: What this rulebook enforces
rules:
  - id: my-rule
    description: What this rule checks
    target: "@/features/**/*View"
    # ... clauses
    fix: How to fix a violation
    example: Optional code example

Required on a rulebook: id, name, description, rules. Required on each rule: id, description, target, fix.

An unknown key fails the load rather than being ignored, so a typo like forbid: or excludes: is reported instead of quietly producing a rule that passes on everything. Keys of two or more words are kebab-case; allows-only is the only one so far.


Targeting Modules

target

Glob+ pattern selecting which modules the rule applies to.

target: "@/app/**/route"        # all API routes
target: "@/features/**/*View"   # all View modules

exclude

Removes modules from the effective target. Accepts a list of Glob+ patterns.

target: "@/features/**/*"
exclude:
  - "**/*.spec"
  - "**/*.stories"

Effective target = target − exclude


Glob Syntax

Pattern Matches
* Any string within a single path segment (no /)
** Zero or many whole path segments; always a segment of its own
"@/features/**/data/*"   # any module inside any data/ subfolder
"@/app/**/page"          # any page module anywhere under app/
"@/lib/**"               # anything under @/lib, and @/lib itself

** stands for zero segments as readily as for many, so @/lib/** covers the module @/lib too. It must be a whole segment: write *View to match inside one, and **/*View to cross them.


Glob+ — Variables in Patterns

Glob+ extends glob with casing variables that capture and transform a name from the matched target module.

Available variables

Variable Casing Example (captured: UserAuth)
{{FileName}} PascalCase UserAuth
{{fileName}} camelCase userAuth
{{file-name}} kebab-case user-auth
{{FILE_NAME}} CONSTANT_CASE USER_AUTH

When target contains a casing variable, all four casings are derived automatically — use any of them freely in clause patterns.

Example: target @/features/**/{{FileName}}Container matches @/features/home/HomeContainer.

  • Captured name: Home
  • In clause patterns: {{FileName}} → Home, {{fileName}} → home, {{file-name}} → home, {{FILE_NAME}} → HOME

Variables are available in target and in all clause patterns. exclude is a plain glob - it filters the target and captures nothing, so variables are not allowed there.

{{TARGET_DIR}}

Available in clause patterns only. Expands to the directory of the matched module.

target matched:  @/features/home/HomeContainer
{{TARGET_DIR}} → @/features/home

..

Available in clause patterns only. Goes one directory back, as it does on any filesystem - which is what lets a clause reach sideways from {{TARGET_DIR}} rather than only downwards.

target: "@/client/{{feature-name}}/{{FileName}}View"
forbids:
  - import: "@/client/**"
allows:
  - import: "{{TARGET_DIR}}/**"              # my own folder
  - import: "{{TARGET_DIR}}/../shared/**"    # my sibling shared/ folder
target matched:               @/client/home/HomeView
{{TARGET_DIR}}/../shared/** → @/client/shared/**

{{TARGET_DIR}} is the directory of the matched file, so under a target containing ** the same clause resolves differently for files at different depths. A .. may only go back past a directory the pattern names - never past a * or a ** - and one with nothing left to go back past does nothing.

target: and exclude: reject ..: both are matched against whole module ids, so there is nothing for it to be relative to. Write the path out instead.

..*

Zero or many directories back. One clause, one resolution per ancestor, matching if any of them matches - which is how a rule reaches "a shared/ at or above me" without caring how deep the matched file sits.

target: "@/client/**/{{FileName}}View"
allows-only:
  - import: "{{TARGET_DIR}}/**"
  - import: "{{TARGET_DIR}}/..*/shared/**"
target matched:                 @/client/billing/invoices/InvoiceView
{{TARGET_DIR}}/..*/shared/** →  @/client/billing/invoices/shared/**
                                @/client/billing/shared/**
                                @/client/shared/**
                                @/shared/**

How far it climbs is decided by how deep the file actually is, so there is no depth limit to configure. Every segment behind a ..* must be one it could legally reach, so a ** behind it is rejected - one ahead of it is fine.

..* cannot be used in target: or exclude: for the same reason .. cannot, nor in exists:, which must name exactly one module.

For the full pattern-matching semantics, see docs/GLOB+.md.


Clauses

forbids

Prevents the target module from importing something.

forbids:
  - import: "@/data/http-client"   # direct import forbidden
  - import: "react"
    transitive: true               # indirect imports forbidden too

transitive: true checks the entire reachable import graph — if the module is reachable via any chain, it's a violation.

Use Glob+ variables to make patterns relative to the matched target:

target: "@/features/**/use{{FileName}}ViewModel"
forbids:
  - import: "{{TARGET_DIR}}/{{FileName}}View"   # viewmodel must not import its View
  - import: "@/**/components/**/*"

allows

Whitelists imports that would otherwise be caught by a forbids clause. Use allows to carve out exceptions from a broad forbids rule.

Example — a feature may only import from one other feature:

- id: no-cross-feature-imports
  description: Features must not depend on other features, except auth.
  target: "@/features/**"
  forbids:
    - import: "@/features/**"   # no cross-feature imports
  allows:
    - import: "@/features/auth/**"   # except: checkout needs the auth session
    - import: "{{TARGET_DIR}}/**"    # from own feature folder is fine
  fix: Remove the cross-feature import. Only @/features/auth is allowed.

allows-only

Syntax sugar for forbids: "**" plus allows:. These two rules are the same rule:

# 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 just how you say "and nothing else". It may be combined with a hand-written forbids or allows, which it appends to rather than replaces - useful when you want the blanket rule and a transitive one:

target: "@/client/**"
forbids:
  - import: "@/server/**"
    transitive: true       # allows-only's generated forbid is direct-only
allows-only:
  - import: "{{TARGET_DIR}}/**"

** 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. List what you need:

allows-only:
  - import: "{{TARGET_DIR}}/**"
  - import: "react"
  - import: "next/*"

uses

Requires the target module to import something.

uses:
  - import: "{{TARGET_DIR}}/{{FileName}}StateEvent"   # must directly import
  - import: "{{TARGET_DIR}}/{{FileName}}View"
    transitive: true                                  # must be in the import chain

transitive: true passes if the import appears anywhere in the reachable graph, not just as a direct import.

All uses entries are required — a missing import is a violation.


exists

Requires a module to exist at a given path.

exists:
  - module: "{{TARGET_DIR}}/{{FileName}}View.stories"
  - module: "{{TARGET_DIR}}/use{{FileName}}ViewModel.spec"

Note

*, ** and ..* are not allowed in exists patterns - each entry must name exactly one module. This is checked when the rulebook loads, alongside every other error in the file.



Metadata

fix

Plain-text instructions telling developers (and AI agents) how to resolve a violation. Deslop prints this message alongside every violation it reports.

fix: Promote shared logic to @/components, @/hooks, @/lib, or an appropriate shared folder.

Keep fix actionable — describe what to move, extract, or remove, not just what went wrong.

Variables work here too. Any variable the rule's target captured, plus {{TARGET_DIR}}, is substituted with the value it captured, written in the casing you spell it in, so the message names the actual file rather than the pattern.

target: "@/features/**/{{FileName}}Container"
fix: Import use{{FileName}}ViewModel from {{TARGET_DIR}} and drive the View from the state it returns.

For @/features/checkout/PaymentContainer, that prints:

FIX: Import usePaymentViewModel from @/features/checkout and drive the View from the state it returns.

A token naming nothing in scope (a typo, or a variable this rule's target never captured) is printed exactly as written rather than blanked out. description is substituted the same way.

example

Optional TypeScript snippet showing what correct code looks like. Used in violation output to guide the fix.

example: |
  import { HomeStateEvent } from "@/features/home/HomeStateEvent";
  export function HomeContainer() { ... }

Advanced Rules (multi-variable)

Everything above uses one variable: the file name. A rule can capture as many as the path has meaningful parts.

Variables are named

{{FileName}} is not a special token. It is a variable named file-name, written in PascalCase - which is why {{file-name}} refers to the same value. Any name works the same way:

{{ProviderName}}   {{providerName}}   {{provider-name}}   {{PROVIDER_NAME}}

All four are one variable. Capture it in one casing, use it in any other.

Capturing several parts of a path

Suppose components are organised by provider and service type:

src/components/stripe-connect/payment/CheckoutView.tsx
src/components/stripe-connect/payout/TransferView.tsx
src/components/paypal/payment/RefundView.tsx

One target pattern captures all three parts:

target: "@/components/{{provider-name}}/{{service-type}}/{{FileName}}View"

For @/components/stripe-connect/payment/CheckoutView that binds:

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

Each variable is enriched independently, so every clause can pick the casing it needs:

- 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.

Variables work in allows too - this isolates providers from each other without naming any of them:

- id: providers-are-isolated
  target: "@/components/{{provider-name}}/**"
  forbids:
    - import: "@/components/**"          # no cross-component imports
  allows:
    - import: "@/components/{{provider-name}}/**"   # except within your own provider
  fix: Promote shared code out of the provider folders.

Naming rules

The casing of a variable is inferred from how you spell it, so the name has to be unambiguous. Deslop refuses to load a rulebook it cannot read with certainty.

Token Result
{{ProviderName}} {{provider-name}} {{PROVIDER_NAME}} ✅ one variable, three casings
{{Provider}} ✅ a lone capitalised word is PascalCase only
{{provider}} ❌ reads as camelCase and kebab-case
{{PROVIDER}} ❌ reads as PascalCase and CONSTANT_CASE
{{Provider-Name}} {{provider_name}} ❌ not a recognised casing
{{HTTPClient}} ❌ consecutive capitals have no word boundary

This is about the pattern, not your files. A file named HTTPClient.tsx is captured fine by {{ProviderName}} — you choose what your rule says, so an ambiguous variable there is worth stopping for; you do not choose what the codebase is called.

Use two or more words and every case resolves. The fix is always in the message:

Could not load Rulebook: rule 'providers-are-isolated', target: "@/components/{{provider}}/**"
  {{provider}} is ambiguous: a single-word name reads as both camelCase and kebab-case.
    Give the variable a name of at least two words, for example:
      {{providerName}}
      {{provider-name}}

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}}?

Repeating a variable

The same variable may appear twice, which constrains both places to the same value - useful when a directory and a file name share a name in different cases:

target: "@/components/{{provider-name}}/{{ProviderName}}View"
@/components/stripe-connect/StripeConnectView   ✅ both say "stripe connect"
@/components/stripe-connect/PaypalView          ❌ they disagree, rule does not apply

For the full pattern-matching semantics, boundary rules and error reference, see docs/GLOB+.md.


Example Rulebooks

Production-ready rulebooks you can copy into your own deslop/rules/ live in examples/rules/:

File Architecture
global.yaml Universal rules that apply to any TypeScript codebase
mvi.yaml Model-View-Intent — Containers, Views, ViewModels
clean-architecture.yaml Clean Architecture — domain/application/infrastructure/presentation layers
feature-sliced-design.yaml Feature Sliced Design — strict layer hierarchy
nextjs-app-router.yaml Next.js App Router — server/client boundary, route handlers, server actions
quality.yaml Quality standards — test coverage and Storybook requirements

Important

These are examples, not a preset. Each rulebook is independent — copy the ones that fit your project and adapt them. Rulebooks can conflict with each other by design.


Comparison to Alternatives

Feature Deslop ESLint + plugin Dependency Cruiser
Rule format Declarative YAML JS config objects Regex-heavy JS/JSON
Typical rule length ~5 lines ~20–40 lines of JS ~10–20 lines of regex
Engine Haskell JavaScript JavaScript
Forbid dependencies forbids Yes forbidden
Allow exceptions allows Yes allowed
Require a dependency uses No required
Require companion files exists No No
Circular dependency detection Built in, always on import/no-cycle plugin rule no-circular rule
Transitive checks transitive: true on any rule Possible via typescript-eslint, at severe IDE/CI performance cost reachable attribute, complex regex config
Transitive require uses + transitive: true No No
Named path variables {{FileName}}, {{TARGET_DIR}} No No
Fix instructions in output Structured fix field No No
Correct-code example in rule example field, shown in output No Comment text only, not shown
Baseline deslop baseline → readable YAML, one key per violation Bulk suppressions (v9.24+) Verbose JSON per violation
Exclude from target exclude list Yes Yes
Auto-fix relative imports Built into deslop fix Third-party plugin required No
Dependency graph visualization No No Yes
Windows support win32-x64 (arm64 needs upstream Windows ARM64 runners) Yes Yes
Monorepo / multiple tsconfigs Follows extends chains; one run per package parserOptions.project glob array Run per package

Limitations

Deslop is built so that a rule is never quietly narrower than it looks: it would rather report something you have to baseline than let a violation through. These are the places where that costs you a false positive, and the places where a rule matches less than you might expect.

An acronym written next to another acronym cannot be split

A PascalCase name marks word boundaries with a capital, so a run of capitals carries no boundary at all. Deslop reads a run as one word, which is right far more often than not:

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:

# guesses, and gets AWSS3Client wrong
target: "@/widgets/{{FileName}}Widget"
uses:
  - import: "@/config/{{file-name}}"

# exact: the kebab-case folder pins the name, and {{ProviderName}} is checked against it
target: "@/components/{{provider-name}}/{{ProviderName}}View"
uses:
  - import: "@/config/{{provider-name}}"

When it does bite, the clause names a module that cannot exist and the rule reports it. Baseline it, or rename the file.

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 — writing a rule that fits your codebase is your job, not deslop's. If a rule seems to be doing nothing, check that its casings match your conventions.

For the same reason, a rule that matches no module at all is not reported: it may be guarding a layer you have not built yet.

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, say so:

target: "@/components/{{provider-name}}/{{FileName}}View"
exists:
  - module: "{{TARGET_DIR}}/{{ProviderName}}View"

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 one 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 variable cannot sit between two **

@/**/{{provider-name}}/**/{{FileName}}View does not compile. With a globstar on both sides, the path would decide which directory {{provider-name}} names (a different one in a shallow tree than in a deep one), so the rule would mean something you did not write. Anchor it against a literal, or use * to fix the depth.

More .. than the path is deep matches nothing, silently

A .. with nothing left to go back past does nothing, as /.. is / on Unix. So a clause that counts back further than {{TARGET_DIR}} is deep resolves to a path with no leading alias segment:

target: "@/{{feature-name}}/**"
allows:
  - import: "{{TARGET_DIR}}/../../shared/**"
matched file {{TARGET_DIR}} resolves to
@/home/a/b/DeepView @/home/a/b @/home/shared/**
@/home/HomeView @/home shared/** - matches no module id

Nothing warns about this. The clause is simply dead for the shallow file, which in an allows: means extra violations and in a forbids: means silence. Count the .. against the shallowest file your target can match, or use ..*, which climbs as far as there is anything to climb and so has nothing to count.

.. is relative to the file, not to the rule

{{TARGET_DIR}} is the directory of the matched file, so under a target containing ** the same .. clause reaches a different folder for a file at depth 1 than for one at depth 2. That is .. behaving as it does on a filesystem, but it means an allowance can move under you as the tree grows. Pin the depth by writing a target without **, name the folder you mean instead of counting back to it, or use ..* to mean every ancestor at once. See .. is relative to the file.

forbids: accepts more spellings than uses:

A forbidden import that slipped through unreported is worse than one reported twice, so a forbids: clause accepts every spelling of its variable, while uses:, exists: and allows: accept only the canonical one. See Polarity.

Other

  • win32-x64 — Windows x64 is supported via GitHub Actions windows-latest. Windows ARM64 (win32-arm64) will be added when GitHub Actions provides native Windows ARM64 runners.
  • Monorepos: Deslop resolves the root tsconfig.json's extends chain, so aliases declared in a shared base config are picked up. Bases named by a relative or rooted path are followed; a package specifier such as @repo/typescript-config/base.json is skipped with a warning. Deslop still uses one config per run, so a workspace with a tsconfig.json per package needs one run per package.
  • exists: patterns cannot contain *, ** or ..*, since each entry has to name exactly one module. Rejected at load time.

Contributing

Deslop is a small project with a high bar. No tech debt, no shortcuts, and every PR proves that it works. We don't care what tools you used to write it - we care that you understand every line, can defend it in review, and take responsibility for it.

Docs, examples/rules/ and the deslop.dev landing page are the easiest place to start and need no issue: just open the PR. Code changes want a green-lit issue first.

Read CONTRIBUTING.md before you start, and docs/DEVELOPMENT.md for the development setup with Nix.

License

MIT © Ivy Apps Ltd

About

Static import-graph analyzer for TypeScript. You write architecture rules in YAML, Deslop checks them on every run. No AI and no heuristics — it walks the import graph, so the same code always produces the same result.

Topics

Resources

Code of conduct

Contributing

Stars

18 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages