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.
- Flexible targeting -
targetTS modules minus optionalexcludeusing Glob+ patterns - Forbid imports —
forbids, direct or transitive, catching violations through any import chain - Carve out exceptions —
allowswhitelists specific imports against a broadforbids - Require imports —
usesenforces mandatory dependencies at the module level - Require companion files —
existsasserts 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
fixmessages — every violation tells your team (and your agents) exactly what to do
npm install --save-dev @ivy-apps/deslopOr 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/.
| 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 |
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 deslopPrebuilt binaries ship for darwin-arm64, linux-x64, linux-arm64, and win32-x64.
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.
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"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.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 exampleRequired 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.
Glob+ pattern selecting which modules the rule applies to.
target: "@/app/**/route" # all API routes
target: "@/features/**/*View" # all View modulesRemoves modules from the effective target. Accepts a list of Glob+ patterns.
target: "@/features/**/*"
exclude:
- "**/*.spec"
- "**/*.stories"Effective target =
target−exclude
| 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+ extends glob with casing variables that capture and transform a name from the matched target module.
| 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.
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/ foldertarget 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.
Prevents the target module from importing something.
forbids:
- import: "@/data/http-client" # direct import forbidden
- import: "react"
transitive: true # indirect imports forbidden tootransitive: 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/**/*"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.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 asreactis a module like any other, soallows-onlyforbids it too. This is the same behaviour a hand-writtenforbids: "**"has always had, butallows-onlyreads as though it were narrower. List what you need:allows-only: - import: "{{TARGET_DIR}}/**" - import: "react" - import: "next/*"
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 chaintransitive: 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.
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.
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.
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() { ... }Everything above uses one variable: the file name. A rule can capture as many as the path has meaningful parts.
{{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.
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.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}}?
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.
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.
| 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 |
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.
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.
{{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.
@/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"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 ..
@/**/{{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.
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.
{{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.
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.
win32-x64— Windows x64 is supported via GitHub Actionswindows-latest. Windows ARM64 (win32-arm64) will be added when GitHub Actions provides native Windows ARM64 runners.- Monorepos: Deslop resolves the root
tsconfig.json'sextendschain, 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.jsonis skipped with a warning. Deslop still uses one config per run, so a workspace with atsconfig.jsonper package needs one run per package. exists:patterns cannot contain*,**or..*, since each entry has to name exactly one module. Rejected at load time.
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.
MIT © Ivy Apps Ltd