eslint-plugin-import-boundaries 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/eslint-plugin-import-boundaries)](https://www.npmjs.com/package/eslint-plugin-import-boundaries)
6
6
  [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)
7
7
 
8
- **Note: This is a beta release, originally developed for a personal project. It is not yet stable and may have breaking changes.**
8
+ **Note: This is an alpha release, originally developed for a personal project. It is not yet stable and may have breaking changes.**
9
9
 
10
10
  An opinionated ESLint rule that enforces architectural boundaries using deterministic import path rules. This rule determines when to use alias vs relative imports based on your architecture, rather than enforcing a single pattern for all imports.
11
11
 
@@ -20,6 +20,7 @@ An opinionated ESLint rule that enforces architectural boundaries using determin
20
20
  - **Auto-fixable**: Legal import paths are auto-fixable and will always converge to the correct import string.
21
21
  - **Zero I/O**: Pure path math and AST analysis - fast even on large codebases
22
22
  - **Type-aware**: Different rules for type-only imports vs value imports
23
+ - **Test-ready**: Flexible configuration for test files (skip boundary rules while maintaining path format)
23
24
  - **Circular Dependency Prevention**: Blocks ancestor barrel imports
24
25
  - **Configurable**: Works with any architectural pattern
25
26
 
@@ -29,6 +30,8 @@ An opinionated ESLint rule that enforces architectural boundaries using determin
29
30
  npm install --save-dev eslint-plugin-import-boundaries
30
31
  ```
31
32
 
33
+ ### Using Alias Paths (Default)
34
+
32
35
  ```javascript
33
36
  // eslint.config.js
34
37
  import importBoundaries from "eslint-plugin-import-boundaries";
@@ -42,6 +45,7 @@ export default {
42
45
  "error",
43
46
  {
44
47
  rootDir: "src",
48
+ crossBoundaryStyle: "alias", // Default: use alias paths
45
49
  boundaries: [
46
50
  { dir: "domain", alias: "@domain" },
47
51
  { dir: "application", alias: "@application" },
@@ -53,6 +57,21 @@ export default {
53
57
  };
54
58
  ```
55
59
 
60
+ **Import patterns with aliases:**
61
+
62
+ ```typescript
63
+ // Cross-boundary imports → use alias
64
+ import { Entity } from "@domain"; // ✅
65
+ import { UseCase } from "@application"; // ✅
66
+
67
+ // Same-boundary, close imports → use relative
68
+ import { helper } from "./helper"; // ✅ Same directory
69
+ import { utils } from "../utils"; // ✅ Parent's sibling
70
+
71
+ // Same-boundary, distant imports → use alias
72
+ import { useCase } from "@application/use-cases"; // ✅ Distant in same boundary
73
+ ```
74
+
56
75
  ## What Problem Does This Solve?
57
76
 
58
77
  Most projects suffer from inconsistent import patterns that create ambiguity and technical debt:
@@ -104,8 +123,8 @@ import { helper } from "./helper"; // ✅
104
123
  // Parent's sibling (cousin, max one ../)
105
124
  import { utils } from "../utils"; // ✅
106
125
 
107
- // Top-level or distant → Use alias
108
- import { useCase } from "@application/topLevel"; // ✅
126
+ // Distant within same boundary → Use alias (with subpath allowed for same-boundary imports)
127
+ import { useCase } from "@application/use-cases"; // ✅ Same boundary, distant location
109
128
  ```
110
129
 
111
130
  ### 3. Architectural Boundary Enforcement
@@ -132,31 +151,70 @@ import { Database } from "@infrastructure";
132
151
 
133
152
  #### Nested Boundaries
134
153
 
135
- Boundaries can be nested, and each boundary must explicitly declare its import rules:
154
+ Boundaries can be nested, and each boundary must explicitly declare its import rules. Each nested boundary has its own independent rules (no inheritance from parent boundaries):
136
155
 
137
156
  ```javascript
138
157
  {
139
158
  boundaries: [
140
159
  {
141
- dir: 'application',
142
- alias: '@application',
143
- allowImportsFrom: ['@domain'],
160
+ dir: 'interface',
161
+ alias: '@interface',
162
+ allowImportsFrom: ['@application', '@domain'], // @interface can import from @application and @domain
163
+ // Implicitly denies all other boundaries (including @infrastructure, @composition, etc.)
144
164
  },
145
165
  {
146
- dir: 'application/ports',
147
- alias: '@ports',
148
- allowImportsFrom: ['@infrastructure', '@domain'], // Can import from infrastructure even though parent cannot
166
+ dir: 'interface/api',
167
+ alias: '@api',
168
+ allowImportsFrom: ['@domain', '@public-use-cases'],
169
+ // @api (public REST API) only allows public use cases, not all of @application
170
+ // This demonstrates selective access within an allowed parent boundary
171
+ // Note: @public-use-cases and @internal-use-cases would be separate boundaries
172
+ // defined elsewhere in your boundaries array
173
+ denyImportsFrom: ['@internal-use-cases'],
149
174
  },
150
175
  {
151
- dir: 'interface',
152
- alias: '@interface',
153
- allowImportsFrom: ['@application', '@public-use-cases'],
154
- denyImportsFrom: ['@use-cases'], // Deny specific sub-boundary even though parent @application is allowed
176
+ dir: 'interface/graphql',
177
+ alias: '@graphql',
178
+ allowImportsFrom: ['@application', '@domain'],
179
+ // @graphql can import from all of @application (different rules than @api sibling)
180
+ // This shows how sibling boundaries can have different rules
181
+ },
182
+ {
183
+ dir: 'composition',
184
+ alias: '@composition',
185
+ allowImportsFrom: ['@domain', '@application', '@infrastructure', '@interface'],
186
+ // @composition can import from all boundaries (wiring layer)
187
+ },
188
+ {
189
+ dir: 'composition/di',
190
+ alias: '@di',
191
+ allowImportsFrom: ['@domain', '@application', '@infrastructure'],
192
+ // @di (dependency injection setup) doesn't need @interface
193
+ // This shows how nested boundaries can be more restrictive than parent
155
194
  },
156
195
  ],
157
196
  }
158
197
  ```
159
198
 
199
+ **Example behavior:**
200
+
201
+ ```typescript
202
+ // File: interface/api/user-controller.ts
203
+ import { Entity } from "@domain"; // ✅ Allowed: @api can import from @domain
204
+ import { CreateUser } from "@public-use-cases"; // ✅ Allowed: @api can import from @public-use-cases
205
+ import { InternalAudit } from "@internal-use-cases"; // ❌ Violation: @api explicitly denies @internal-use-cases
206
+
207
+ // File: interface/graphql/user-resolver.ts
208
+ import { Entity } from "@domain"; // ✅ Allowed: @graphql can import from @domain
209
+ import { CreateUser } from "@public-use-cases"; // ✅ Allowed: @graphql can import from any @application code
210
+ import { InternalAudit } from "@internal-use-cases"; // ✅ Allowed: @graphql has different rules than @api sibling
211
+
212
+ // File: composition/di/container.ts
213
+ import { Repository } from "@infrastructure"; // ✅ Allowed: @di can import from @infrastructure for wiring
214
+ import { UseCase } from "@application"; // ✅ Allowed: @di can import from @application
215
+ import { Controller } from "@interface"; // ❌ Violation: @di cannot import from @interface (more restrictive than parent)
216
+ ```
217
+
160
218
  **Key behaviors:**
161
219
 
162
220
  - Each boundary has rules: explicit (via `allowImportsFrom`/`denyImportsFrom`) or implicit "deny all" (if neither is specified)
@@ -167,11 +225,23 @@ Boundaries can be nested, and each boundary must explicitly declare its import r
167
225
 
168
226
  **Rule semantics:**
169
227
 
170
- - If both `allowImportsFrom` and `denyImportsFrom` exist: `allowImportsFrom` takes precedence (items in allow list are allowed even if also in deny list)
171
228
  - If only `allowImportsFrom`: deny-all by default (only items in allow list are allowed)
172
229
  - If only `denyImportsFrom`: allow-all by default (everything except deny list is allowed)
173
230
  - If neither: deny-all by default (strictest)
174
- - **Important**: When `allowImportsFrom` is specified, `denyImportsFrom` can deny specific sub-boundaries (e.g. deny `@utils` within allowed `@application`), but is otherwise redundant since anything not in the allow list is already denied by default. Note that this works recursively: It is possible to allow a boundary within a denied boundary within an allowed boundary, and so on.
231
+ - If both `allowImportsFrom` and `denyImportsFrom` exist: Both lists apply independently. Items in the allow list are allowed (unless also in deny list), items in the deny list are denied, and items in neither list are denied by default (whitelist behavior). This allows you to deny specific sub-boundaries within an allowed parent boundary. For example, you can allow `@application` but deny its sub-boundary `@units`:
232
+
233
+ ```javascript
234
+ {
235
+ dir: 'interface',
236
+ alias: '@interface',
237
+ allowImportsFrom: ['@application'], // Allow all of @application
238
+ denyImportsFrom: ['@units'], // Deny this specific sub-boundary
239
+ }
240
+ ```
241
+
242
+ Note: This works recursively - you can allow a boundary within a denied boundary within an allowed boundary, and so on.
243
+
244
+ **Conflict resolution:** If the same boundary identifier appears in both lists (which indicates a configuration error), `denyImportsFrom` takes precedence - the import will be denied. This ensures safety: explicit denials override allows.
175
245
 
176
246
  ### 4. Type-Only Imports
177
247
 
@@ -182,7 +252,7 @@ Different rules for types vs values (types don't create runtime dependencies):
182
252
  dir: 'infrastructure',
183
253
  alias: '@infrastructure',
184
254
  allowImportsFrom: ['@domain'], // Value imports from domain
185
- allowTypeImportsFrom: ['@application'], // Type imports from application (port interfaces)
255
+ allowTypeImportsFrom: ['@ports'], // Type imports from ports (interfaces for dependency inversion)
186
256
  }
187
257
  ```
188
258
 
@@ -209,27 +279,23 @@ import { something } from "@application"; // When inside @application boundary
209
279
 
210
280
  ### Basic Configuration
211
281
 
282
+ Here's a complete configuration example with all boundary rules:
283
+
212
284
  ```javascript
213
285
  {
214
- rootDir: 'src', // Root directory (default: 'src')
215
- crossBoundaryStyle: 'alias', // 'alias' | 'absolute' (default: 'alias')
216
- defaultSeverity: 'error', // 'error' | 'warn' (default: 'error')
217
- allowUnknownBoundaries: false, // Allow imports outside boundaries (default: false)
218
- skipBoundaryRulesForTestFiles: true, // Skip boundary rules for tests (default: true)
219
- barrelFileName: 'index', // Barrel file name without extension (default: 'index')
220
- fileExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], // Extensions to recognize (default: all common JS/TS extensions)
286
+ rootDir: 'src', // Required: Root directory (default: 'src')
221
287
  boundaries: [ // Required: Array of boundary definitions
222
288
  {
223
289
  dir: 'domain', // Required: Relative directory path
224
- alias: '@domain', // Required when crossBoundaryStyle is 'alias', optional when 'absolute'
225
- denyImportsFrom: ['@application', '@infrastructure', '@interface', '@composition'], // Domain is pure
290
+ alias: '@domain', // Required: Import alias (e.g., '@domain')
291
+ denyImportsFrom: ['@application', '@infrastructure', '@interface', '@composition'], // Domain is pure - denies all other boundaries
226
292
  severity: 'error', // Optional: 'error' | 'warn' (overrides defaultSeverity for this boundary)
227
293
  },
228
294
  {
229
295
  dir: 'application',
230
296
  alias: '@application',
231
297
  allowImportsFrom: ['@domain'], // Application uses domain (deny-all by default)
232
- // Note: denyImportsFrom is redundant here - those boundaries are already denied
298
+ // Note: denyImportsFrom is redundant here - anything not in allowImportsFrom is already denied
233
299
  },
234
300
  {
235
301
  dir: 'infrastructure',
@@ -238,34 +304,92 @@ import { something } from "@application"; // When inside @application boundary
238
304
  allowTypeImportsFrom: ['@application'], // Infrastructure implements application ports (types only)
239
305
  },
240
306
  ],
307
+ // Optional configuration options (all have sensible defaults):
308
+ defaultSeverity: 'error', // 'error' | 'warn' (default: 'error')
309
+ enforceBoundaries: true, // Enforce boundary rules (default: true)
310
+ allowUnknownBoundaries: false, // Allow imports outside boundaries (default: false)
311
+ barrelFileName: 'index', // Barrel file name without extension (default: 'index')
312
+ fileExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], // Extensions to recognize (default: all common JS/TS extensions)
241
313
  }
242
314
  ```
243
315
 
316
+ **Important:** The `enforceBoundaries` option applies globally to all files when set. To have different behavior for test files vs regular files, you must use ESLint's file matching (see Test Files Configuration below).
317
+
244
318
  ### Test Files Configuration
245
319
 
246
- Use ESLint's file matching for test files:
320
+ **How test exclusion works:** When `enforceBoundaries: false`, the rule skips boundary rule checking (allow/deny rules) but still enforces path format (alias vs relative). This allows test files to import from any boundary while maintaining consistent import path patterns. The default is `true` (boundary rules are enforced by default).
321
+
322
+ **Why skip boundary rules for tests?** Test files often need to:
323
+
324
+ - Import from multiple boundaries to set up test scenarios (e.g., mocking infrastructure while testing application logic)
325
+ - Use test helper libraries or mocks that don't fit clean architectural boundaries
326
+ - Access internal implementation details for thorough testing
327
+ - Create test fixtures that span multiple boundaries
328
+
329
+ By setting `enforceBoundaries: false` for test files, you maintain architectural boundaries in production code while giving tests the flexibility they need. Path format (alias vs relative) is still enforced, keeping import paths consistent and readable.
330
+
331
+ **Alternative approach:** You can also define separate boundaries for test directories (e.g., `test/domain`, `test/application`) with their own import rules, but this has two downsides: it discourages test collocation (tests must live in separate test directories rather than alongside source files), and it requires much more configuration overhead than most projects need. The `enforceBoundaries: false` approach is simpler and sufficient for most use cases.
332
+
333
+ **Configuration pattern:** Use ESLint's file matching to apply different configs to test files vs regular files. Define boundaries once and reuse them in both config blocks:
247
334
 
248
335
  ```javascript
336
+ import importBoundaries from "eslint-plugin-import-boundaries";
337
+
338
+ // Define boundaries once - shared between regular files and test files
339
+ const boundaries = [
340
+ {
341
+ dir: "domain",
342
+ alias: "@domain",
343
+ // No imports allowed by default
344
+ },
345
+ {
346
+ dir: "application",
347
+ alias: "@application",
348
+ allowImportsFrom: ["@domain"],
349
+ },
350
+ {
351
+ dir: "infrastructure",
352
+ alias: "@infrastructure",
353
+ allowImportsFrom: ["@domain"],
354
+ },
355
+ ];
356
+
249
357
  export default [
358
+ // Test files - skip boundary rules but keep path format enforcement
359
+ // Put test files first so they take precedence over regular file patterns
250
360
  {
251
- files: ["src/**/*.ts"],
361
+ files: [
362
+ "**/*.test.{ts,js}",
363
+ "**/*.spec.{ts,js}",
364
+ "**/__tests__/**/*.{ts,js}",
365
+ ],
252
366
  rules: {
253
367
  "import-boundaries/enforce": [
254
368
  "error",
255
369
  {
256
- /* config */
370
+ rootDir: "src",
371
+ enforceBoundaries: false, // Tests can import from any boundary
372
+ boundaries, // Same boundaries - needed for path format calculation
257
373
  },
258
374
  ],
259
375
  },
260
376
  },
377
+ // Regular source files - enforce boundary rules
378
+ // Excludes test files via ignores to prevent overlap
261
379
  {
262
- files: ["**/*.test.{ts,js}", "**/*.spec.{ts,js}"],
380
+ files: ["src/**/*.ts", "src/**/*.tsx"],
381
+ ignores: [
382
+ "**/*.test.{ts,js}",
383
+ "**/*.spec.{ts,js}",
384
+ "**/__tests__/**/*.{ts,js}",
385
+ ],
263
386
  rules: {
264
387
  "import-boundaries/enforce": [
265
388
  "error",
266
389
  {
267
- skipBoundaryRulesForTestFiles: true, // Tests can import from any boundary
268
- // ... rest of config
390
+ rootDir: "src",
391
+ enforceBoundaries: true, // Enforce boundary rules
392
+ boundaries,
269
393
  },
270
394
  ],
271
395
  },
@@ -273,6 +397,59 @@ export default [
273
397
  ];
274
398
  ```
275
399
 
400
+ **What gets checked in test files:**
401
+
402
+ - ✅ Path format (alias vs relative) - still enforced
403
+ - ✅ Barrel file imports - still enforced
404
+ - ✅ Ancestor barrel prevention - still enforced
405
+ - ❌ Boundary allow/deny rules - **skipped** (tests can import from any boundary)
406
+
407
+ **What gets checked in regular files:**
408
+
409
+ - ✅ Path format (alias vs relative)
410
+ - ✅ Barrel file imports
411
+ - ✅ Ancestor barrel prevention
412
+ - ✅ Boundary allow/deny rules - **enforced**
413
+
414
+ ### Using Absolute Paths
415
+
416
+ By default, the rule uses alias paths (e.g., `@domain`). If your build configuration doesn't support path aliases, or you prefer absolute paths, you can use `crossBoundaryStyle: 'absolute'`:
417
+
418
+ ```javascript
419
+ {
420
+ rootDir: 'src',
421
+ crossBoundaryStyle: 'absolute', // Use absolute paths instead of aliases
422
+ boundaries: [
423
+ { dir: 'domain' }, // No alias required when using absolute paths
424
+ { dir: 'application' },
425
+ { dir: 'infrastructure' },
426
+ ],
427
+ }
428
+ ```
429
+
430
+ **Import patterns with absolute paths:**
431
+
432
+ ```typescript
433
+ // Cross-boundary imports → use absolute path
434
+ import { Entity } from "src/domain"; // ✅
435
+ import { UseCase } from "src/application"; // ✅
436
+
437
+ // Same-boundary, close imports → use relative (same as alias style)
438
+ import { helper } from "./helper"; // ✅ Same directory
439
+ import { utils } from "../utils"; // ✅ Parent's sibling
440
+
441
+ // Same-boundary, distant imports → use absolute path
442
+ import { useCase } from "src/application/use-cases"; // ✅ Distant in same boundary
443
+ ```
444
+
445
+ **When to use absolute paths:**
446
+
447
+ - Your build configuration doesn't support path aliases (e.g., some bundlers or older tooling)
448
+ - You prefer explicit paths over aliases for clarity
449
+ - You're working in a codebase that already uses absolute paths
450
+
451
+ **Note:** Alias paths are recommended for readability, especially for boundaries defined at deeper directory levels (e.g., `@entities/user` vs `src/hexagonal/domain/entities/user`). However, this rule does not require them since not all build configurations support path aliases. When using `crossBoundaryStyle: 'absolute'`, the `alias` property in boundary definitions becomes optional, and the rule will use paths like `src/domain` instead of `@domain`.
452
+
276
453
  ## How It Works
277
454
 
278
455
  The rule uses pure path math - no file I/O, just deterministic algorithms:
@@ -139,8 +139,8 @@ function matchesBoundaryIdentifier(identifier, targetBoundary) {
139
139
  *
140
140
  * Semantics:
141
141
  * - If both allowImportsFrom and denyImportsFrom are specified, they work as:
142
- * - allowImportsFrom: explicit allow list (overrides deny for those items)
143
- * - denyImportsFrom: explicit deny list (overrides default allow for those items)
142
+ * - Both lists apply independently (allow applies to items in allow list, deny applies to items in deny list)
143
+ * - If the same identifier appears in both lists (configuration error), denyImportsFrom takes precedence for safety
144
144
  * - If only allowImportsFrom: only those boundaries are allowed (deny-all by default)
145
145
  * - If only denyImportsFrom: all boundaries allowed except those (allow-all by default)
146
146
  * - If neither: deny-all by default (strictest)
@@ -153,8 +153,8 @@ function checkBoundaryRules(fileBoundary, targetBoundary, allBoundaries, isTypeO
153
153
  if (isTypeOnly && fileBoundary.allowTypeImportsFrom?.some((id) => matchesBoundaryIdentifier(id, targetBoundary))) return null;
154
154
  const hasAllowList = fileBoundary.allowImportsFrom && fileBoundary.allowImportsFrom.length > 0;
155
155
  const hasDenyList = fileBoundary.denyImportsFrom && fileBoundary.denyImportsFrom.length > 0;
156
+ if (hasDenyList && fileBoundary.denyImportsFrom.some((id) => matchesBoundaryIdentifier(id, targetBoundary))) return { reason: hasAllowList && fileBoundary.allowImportsFrom.some((id) => matchesBoundaryIdentifier(id, targetBoundary)) ? `Boundary '${fileIdentifier}' explicitly denies imports from '${targetIdentifier}' (deny takes precedence over allow)` : `Boundary '${fileIdentifier}' explicitly denies imports from '${targetIdentifier}'` };
156
157
  if (hasAllowList && fileBoundary.allowImportsFrom.some((id) => matchesBoundaryIdentifier(id, targetBoundary))) return null;
157
- if (hasDenyList && fileBoundary.denyImportsFrom.some((id) => matchesBoundaryIdentifier(id, targetBoundary))) return { reason: `Boundary '${fileIdentifier}' explicitly denies imports from '${targetIdentifier}'` };
158
158
  if (hasAllowList && !hasDenyList) return { reason: `Cross-boundary import from '${targetIdentifier}' to '${fileIdentifier}' is not allowed. Add '${targetIdentifier}' to 'allowImportsFrom' if this import is intentional.` };
159
159
  if (hasDenyList && !hasAllowList) return null;
160
160
  return { reason: `Cross-boundary import from '${targetIdentifier}' to '${fileIdentifier}' is not allowed. Add '${targetIdentifier}' to 'allowImportsFrom' if this import is intentional.` };
@@ -497,7 +497,7 @@ const rule = {
497
497
  type: "boolean",
498
498
  default: false
499
499
  },
500
- skipBoundaryRulesForTestFiles: {
500
+ enforceBoundaries: {
501
501
  type: "boolean",
502
502
  default: true
503
503
  },
@@ -529,7 +529,7 @@ const rule = {
529
529
  },
530
530
  create(context) {
531
531
  if (!context.options || context.options.length === 0) throw new Error("boundary-alias-vs-relative requires boundaries configuration");
532
- const { rootDir = "src", boundaries, crossBoundaryStyle = "alias", defaultSeverity, allowUnknownBoundaries = false, skipBoundaryRulesForTestFiles = true, barrelFileName = "index", fileExtensions = [
532
+ const { rootDir = "src", boundaries, crossBoundaryStyle = "alias", defaultSeverity, allowUnknownBoundaries = false, enforceBoundaries = true, barrelFileName = "index", fileExtensions = [
533
533
  ".ts",
534
534
  ".tsx",
535
535
  ".js",
@@ -592,7 +592,7 @@ const rule = {
592
592
  defaultSeverity,
593
593
  allowUnknownBoundaries,
594
594
  isTypeOnly,
595
- skipBoundaryRules: skipBoundaryRulesForTestFiles,
595
+ skipBoundaryRules: !enforceBoundaries,
596
596
  barrelFileName,
597
597
  fileExtensions
598
598
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eslint-plugin-import-boundaries",
3
3
  "type": "module",
4
- "version": "0.2.2",
4
+ "version": "0.3.1",
5
5
  "description": "Enforce architectural boundaries with deterministic import paths",
6
6
  "author": "ClassicalMoser",
7
7
  "license": "ISC",